144 lines
5.5 KiB
React
144 lines
5.5 KiB
React
|
|
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>
|
||
|
|
);
|
||
|
|
}
|