feat: initial IQAI multi-model AI dashboard
- Express backend with Replicate API proxy (chat, models, account, search) - React + Vite + Tailwind frontend with custom Midnight Violet color scheme - @mention autocomplete to route messages to specific models - Parallel multi-model queries with model selection in sidebar - DuckDuckGo web search context injection - Model manager UI (add/edit/remove Replicate models) - Per-model system instructions per conversation - Replicate account info display in sidebar - Conversation history with local persistence (Zustand) - Full Docker deployment (backend + nginx-served frontend) - Montserrat + Poppins fonts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
143
frontend/src/components/MentionInput.jsx
Normal file
143
frontend/src/components/MentionInput.jsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Send, Search, Loader2 } from 'lucide-react';
|
||||
import { useStore } from '../store/useStore.js';
|
||||
|
||||
export default function MentionInput({ onSend, sending }) {
|
||||
const { models, webSearchEnabled, toggleWebSearch } = useStore();
|
||||
const [value, setValue] = useState('');
|
||||
const [mentionState, setMentionState] = useState(null);
|
||||
const textareaRef = useRef(null);
|
||||
|
||||
const filteredModels = mentionState
|
||||
? models.filter(m => m.tag.toLowerCase().startsWith(mentionState.query.toLowerCase()))
|
||||
: [];
|
||||
|
||||
const handleChange = (e) => {
|
||||
const text = e.target.value;
|
||||
setValue(text);
|
||||
const pos = e.target.selectionStart;
|
||||
const before = text.slice(0, pos);
|
||||
const match = before.match(/@(\w*)$/);
|
||||
if (match) {
|
||||
setMentionState({ start: match.index, query: match[1] });
|
||||
} else {
|
||||
setMentionState(null);
|
||||
}
|
||||
};
|
||||
|
||||
const insertMention = (tag) => {
|
||||
if (!mentionState) return;
|
||||
const before = value.slice(0, mentionState.start);
|
||||
const after = value.slice(mentionState.start + 1 + mentionState.query.length);
|
||||
const newVal = `${before}@${tag} ${after}`;
|
||||
setValue(newVal);
|
||||
setMentionState(null);
|
||||
textareaRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (mentionState && filteredModels.length > 0 && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
insertMention(filteredModels[0].tag);
|
||||
return;
|
||||
}
|
||||
if (mentionState && e.key === 'Escape') { setMentionState(null); return; }
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submit(); }
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
const text = value.trim();
|
||||
if (!text || sending) return;
|
||||
setValue('');
|
||||
setMentionState(null);
|
||||
onSend(text);
|
||||
};
|
||||
|
||||
const activeTags = models.filter(m => new RegExp(`@${m.tag}\\b`, 'i').test(value));
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Mention dropdown */}
|
||||
{mentionState && filteredModels.length > 0 && (
|
||||
<div className="absolute bottom-full mb-2 left-0 bg-surface-2 border border-grape/30 rounded-2xl shadow-2xl overflow-hidden z-50 min-w-[220px] animate-slide-up glow-grape">
|
||||
<div className="px-3 py-2 text-xs text-apricot/30 border-b border-grape/15 font-medium">
|
||||
Tag a model
|
||||
</div>
|
||||
{filteredModels.map((model) => (
|
||||
<button
|
||||
key={model.id}
|
||||
onMouseDown={(e) => { e.preventDefault(); insertMention(model.tag); }}
|
||||
className="flex items-center gap-3 w-full px-3 py-3 hover:bg-grape/15 transition-colors text-left"
|
||||
>
|
||||
<span className="text-xl">{model.avatar}</span>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm text-apricot/90 font-medium">@{model.tag}</div>
|
||||
<div className="text-xs text-apricot/40">{model.displayName}</div>
|
||||
</div>
|
||||
<div className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: model.color }} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input container */}
|
||||
<div className="bg-surface-2 border border-grape/25 rounded-2xl overflow-hidden focus-within:border-grape/50 transition-all focus-within:glow-grape">
|
||||
{/* Active model chips */}
|
||||
{activeTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 px-4 pt-3">
|
||||
{activeTags.map(m => (
|
||||
<span
|
||||
key={m.id}
|
||||
className="flex items-center gap-1 text-xs px-2 py-0.5 rounded-full border font-medium font-display"
|
||||
style={{ color: m.color, background: `${m.color}18`, borderColor: `${m.color}35` }}
|
||||
>
|
||||
{m.avatar} @{m.tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Message… (type @ to mention a model · Shift+Enter for newline)"
|
||||
className="w-full bg-transparent px-4 pt-3 pb-2 text-sm text-apricot/85 placeholder-apricot/20 resize-none outline-none leading-relaxed"
|
||||
rows={1}
|
||||
disabled={sending}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between px-3 pb-3 pt-1">
|
||||
<button
|
||||
onClick={toggleWebSearch}
|
||||
title="Toggle web search (injects DuckDuckGo results as context)"
|
||||
className={`flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-xl border transition-all ${
|
||||
webSearchEnabled
|
||||
? 'bg-tangerine/20 border-tangerine/40 text-tangerine'
|
||||
: 'border-grape/20 text-apricot/30 hover:text-apricot/60 hover:border-grape/40'
|
||||
}`}
|
||||
>
|
||||
<Search size={12} />
|
||||
Web Search
|
||||
{webSearchEnabled && <span className="w-1.5 h-1.5 rounded-full bg-tangerine animate-pulse" />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={!value.trim() || sending}
|
||||
className="flex items-center gap-2 text-midnight text-sm px-4 py-1.5 rounded-xl transition-all font-semibold font-display disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
style={{
|
||||
background: value.trim() && !sending
|
||||
? 'linear-gradient(90deg, #da627d, #9a348e)'
|
||||
: '#9a348e40'
|
||||
}}
|
||||
>
|
||||
{sending ? <Loader2 size={14} className="animate-spin" /> : <Send size={14} />}
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user