Files
iqAI/frontend/src/components/MentionInput.jsx
Malin 71965939a1 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>
2026-04-16 13:12:40 +02:00

144 lines
5.5 KiB
JavaScript

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>
);
}