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:
Malin
2026-04-16 13:12:40 +02:00
commit 71965939a1
31 changed files with 2399 additions and 0 deletions

43
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,43 @@
import React, { useEffect } from 'react';
import Sidebar from './components/Sidebar.jsx';
import ChatArea from './components/ChatArea.jsx';
import ModelManager from './components/ModelManager.jsx';
import SystemPromptPanel from './components/SystemPromptPanel.jsx';
import { useStore } from './store/useStore.js';
export default function App() {
const { fetchModels, createConversation, conversations, activeConvId, sidebarOpen } = useStore();
useEffect(() => {
fetchModels().then(() => {
// Auto-create first conversation if none exists
const state = useStore.getState();
if (state.conversations.length === 0) {
state.createConversation();
} else if (!state.activeConvId) {
useStore.setState({ activeConvId: state.conversations[0].id });
}
});
}, []);
return (
<div className="flex h-screen overflow-hidden bg-midnight">
{/* Subtle background gradient */}
<div className="fixed inset-0 pointer-events-none">
<div className="absolute top-0 left-0 w-96 h-96 bg-grape/5 rounded-full blur-3xl -translate-x-1/2 -translate-y-1/2" />
<div className="absolute bottom-0 right-0 w-96 h-96 bg-blush/5 rounded-full blur-3xl translate-x-1/2 translate-y-1/2" />
</div>
{/* Layout */}
<Sidebar />
<main className="flex-1 flex flex-col min-w-0 relative">
<ChatArea />
</main>
{/* Modals / overlays */}
<ModelManager />
<SystemPromptPanel />
</div>
);
}

View File

@@ -0,0 +1,96 @@
import React, { useEffect } from 'react';
import { Wallet, RefreshCw, ExternalLink } from 'lucide-react';
import { useStore } from '../store/useStore.js';
export default function BalanceDisplay() {
const { account, accountLoading, fetchAccount } = useStore();
useEffect(() => {
fetchAccount();
}, []);
if (accountLoading && !account) {
return (
<div className="flex items-center gap-2 px-3 py-2 rounded-xl bg-surface-2 border border-grape/20 animate-pulse">
<Wallet size={13} className="text-tangerine/50" />
<span className="text-xs text-apricot/30">Loading account...</span>
</div>
);
}
if (!account) {
return (
<button
onClick={fetchAccount}
className="flex items-center gap-2 px-3 py-2 rounded-xl bg-surface-2 border border-grape/20 hover:border-grape/40 w-full transition-colors text-left"
>
<Wallet size={13} className="text-apricot/30" />
<span className="text-xs text-apricot/30 flex-1">Account unavailable</span>
<RefreshCw size={11} className="text-apricot/20" />
</button>
);
}
const info = account.account;
return (
<div className="bg-surface-2 border border-grape/20 rounded-xl p-3 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-lg bg-tangerine/20 flex items-center justify-center">
<Wallet size={12} className="text-tangerine" />
</div>
<span className="text-xs font-semibold text-apricot/80 font-display">Replicate</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={fetchAccount}
className="p-1 rounded hover:bg-grape/20 text-apricot/30 hover:text-apricot/60 transition-colors"
title="Refresh"
>
<RefreshCw size={11} className={accountLoading ? 'animate-spin' : ''} />
</button>
<a
href="https://replicate.com/billing"
target="_blank"
rel="noopener noreferrer"
className="p-1 rounded hover:bg-grape/20 text-apricot/30 hover:text-apricot/60 transition-colors"
title="View billing on Replicate"
>
<ExternalLink size={11} />
</a>
</div>
</div>
{info && (
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-xs text-apricot/40">Username</span>
<span className="text-xs text-apricot/70 font-mono">{info.username || '—'}</span>
</div>
{info.name && (
<div className="flex items-center justify-between">
<span className="text-xs text-apricot/40">Name</span>
<span className="text-xs text-apricot/70">{info.name}</span>
</div>
)}
<div className="flex items-center justify-between">
<span className="text-xs text-apricot/40">Type</span>
<span className="text-xs capitalize bg-grape/20 text-grape px-1.5 py-0.5 rounded-full" style={{ color: '#fca17d' }}>
{info.type || 'user'}
</span>
</div>
</div>
)}
<a
href="https://replicate.com/billing"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1 text-xs text-apricot/40 hover:text-tangerine transition-colors pt-1 border-t border-grape/15"
>
View billing & usage <ExternalLink size={10} />
</a>
</div>
);
}

View File

@@ -0,0 +1,170 @@
import React, { useEffect, useRef, useState } from 'react';
import { Sparkles, Trash2, Download } from 'lucide-react';
import { useStore } from '../store/useStore.js';
import MessageItem from './MessageItem.jsx';
import MentionInput from './MentionInput.jsx';
function EmptyState({ models, onCreate }) {
return (
<div className="flex flex-col items-center justify-center h-full text-center px-6 py-16 animate-fade-in">
{/* Logo */}
<div className="relative mb-8">
<div className="w-20 h-20 rounded-3xl bg-brand-gradient-r flex items-center justify-center text-4xl shadow-grape glow-grape">
🤖
</div>
<div className="absolute -bottom-1 -right-1 w-6 h-6 bg-tangerine rounded-full flex items-center justify-center">
<Sparkles size={12} className="text-midnight" />
</div>
</div>
<h1 className="font-display font-bold text-3xl text-apricot mb-2">
Welcome to <span className="text-gradient">IQAI</span>
</h1>
<p className="text-apricot/50 text-sm max-w-md mb-8 leading-relaxed">
Your multi-model AI dashboard. Chat with multiple AI models simultaneously, tag them with @mentions, and augment with live web search.
</p>
{/* Model grid */}
{models.length > 0 && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-8 w-full max-w-lg">
{models.slice(0, 4).map(m => (
<div
key={m.id}
className="flex flex-col items-center gap-2 p-3 rounded-xl border bg-surface-2 border-grape/20"
>
<span className="text-2xl">{m.avatar}</span>
<span className="text-xs font-medium font-display" style={{ color: m.color }}>@{m.tag}</span>
</div>
))}
</div>
)}
<div className="flex flex-col gap-2 text-xs text-apricot/30 max-w-xs">
<p> Type <code className="bg-surface-2 px-1 py-0.5 rounded text-tangerine">@claude</code> to direct a message to a specific model</p>
<p> Enable <strong className="text-apricot/50">Web Search</strong> to inject live results as context</p>
<p> Select models from the sidebar to run queries in parallel</p>
</div>
</div>
);
}
export default function ChatArea() {
const {
getActiveConv, activeConvId, sendMessage,
models, createConversation, clearConversation,
setSettingsPanelModel
} = useStore();
const [sending, setSending] = useState(false);
const [error, setError] = useState('');
const bottomRef = useRef(null);
const conv = getActiveConv();
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [conv?.messages?.length, sending]);
const handleSend = async (text) => {
setError('');
setSending(true);
try {
await sendMessage(text);
} catch (err) {
setError(err.message);
} finally {
setSending(false);
}
};
const exportConv = () => {
if (!conv) return;
const text = conv.messages.map(m => {
const who = m.role === 'user' ? 'You' : (models.find(md => md.id === m.modelId)?.displayName || m.modelId);
return `[${who}]\n${m.content}`;
}).join('\n\n---\n\n');
const blob = new Blob([text], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${conv.title.slice(0, 30)}.txt`;
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="flex flex-col flex-1 min-w-0 h-full">
{/* Top bar */}
{conv && (
<div className="flex items-center justify-between px-6 py-3 border-b border-grape/15 flex-shrink-0">
<div className="flex items-center gap-3 min-w-0">
<h2 className="font-display font-semibold text-apricot/80 truncate text-sm">
{conv.title}
</h2>
{/* Active model tags */}
<div className="flex items-center gap-1.5 flex-wrap">
{conv.activeModelIds.map(id => {
const m = models.find(md => md.id === id);
if (!m) return null;
return (
<button
key={id}
onClick={() => setSettingsPanelModel(m)}
title={`System instructions for ${m.displayName}`}
className="flex items-center gap-1 text-xs px-2 py-0.5 rounded-full border hover:opacity-100 transition-opacity"
style={{ color: m.color, background: `${m.color}15`, borderColor: `${m.color}30` }}
>
{m.avatar} {m.tag}
</button>
);
})}
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<button
onClick={exportConv}
className="p-2 rounded-lg hover:bg-grape/20 text-apricot/30 hover:text-apricot/60 transition-colors"
title="Export conversation"
>
<Download size={14} />
</button>
<button
onClick={() => clearConversation(activeConvId)}
className="p-2 rounded-lg hover:bg-blush/10 text-apricot/30 hover:text-blush transition-colors"
title="Clear conversation"
>
<Trash2 size={14} />
</button>
</div>
</div>
)}
{/* Messages */}
<div className="flex-1 overflow-y-auto px-4 sm:px-8 py-6 space-y-6">
{!conv || conv.messages.length === 0 ? (
<EmptyState models={models} onCreate={createConversation} />
) : (
conv.messages.map(msg => (
<MessageItem key={msg.id} message={msg} />
))
)}
<div ref={bottomRef} />
</div>
{/* Error banner */}
{error && (
<div className="mx-6 mb-2 bg-blush/10 border border-blush/30 rounded-xl px-4 py-2.5 text-sm text-blush animate-slide-up flex items-center justify-between">
<span>{error}</span>
<button onClick={() => setError('')} className="text-blush/60 hover:text-blush ml-3"></button>
</div>
)}
{/* Input */}
<div className="px-4 sm:px-8 pb-6 pt-2 flex-shrink-0">
<MentionInput onSend={handleSend} sending={sending} />
<p className="text-center text-xs text-apricot/20 mt-2">
IQAI uses Replicate API · Models may make mistakes · Verify important information
</p>
</div>
</div>
);
}

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

View File

@@ -0,0 +1,148 @@
import React, { useState } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { Copy, Check, User, AlertCircle, Timer } from 'lucide-react';
import { useStore } from '../store/useStore.js';
function CopyButton({ text, size = 13 }) {
const [copied, setCopied] = useState(false);
const copy = () => {
navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
return (
<button
onClick={copy}
className="p-1.5 rounded-lg text-apricot/30 hover:text-apricot/70 hover:bg-grape/20 transition-colors"
>
{copied ? <Check size={size} className="text-tangerine" /> : <Copy size={size} />}
</button>
);
}
const CodeBlock = ({ children, className }) => {
const language = className?.replace('language-', '') || 'text';
const code = String(children).replace(/\n$/, '');
return (
<div className="relative group my-3">
<div className="absolute right-2 top-2 opacity-0 group-hover:opacity-100 transition-opacity z-10">
<CopyButton text={code} />
</div>
<div className="absolute left-3 top-2 text-xs text-apricot/30 font-mono">{language}</div>
<SyntaxHighlighter
style={{
...oneDark,
'pre[class*="language-"]': { background: '#110830', margin: 0 },
'code[class*="language-"]': { background: 'none' }
}}
language={language}
PreTag="div"
customStyle={{
margin: 0,
borderRadius: '10px',
paddingTop: '2rem',
background: '#110830',
border: '1px solid #9a348e30',
fontSize: '0.84rem'
}}
>
{code}
</SyntaxHighlighter>
</div>
);
};
export default function MessageItem({ message }) {
const { models } = useStore();
const model = message.modelId ? models.find(m => m.id === message.modelId) : null;
if (message.role === 'user') {
return (
<div className="flex gap-3 justify-end animate-fade-in">
<div className="max-w-[78%]">
<div className="bg-grape/20 border border-grape/30 rounded-2xl rounded-tr-sm px-4 py-3">
<p className="text-apricot/90 text-sm leading-relaxed whitespace-pre-wrap">{message.content}</p>
</div>
<p className="text-xs text-apricot/20 mt-1 text-right">
{new Date(message.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</p>
</div>
<div className="w-8 h-8 rounded-full bg-blush/20 border border-blush/30 flex items-center justify-center flex-shrink-0 mt-0.5">
<User size={14} className="text-blush" />
</div>
</div>
);
}
// Assistant message
const modelColor = model?.color || '#9a348e';
const modelAvatar = model?.avatar || '🤖';
const modelName = model?.displayName || message.modelId || 'AI';
return (
<div className="flex gap-3 animate-slide-up">
<div
className="w-8 h-8 rounded-xl flex items-center justify-center flex-shrink-0 mt-0.5 text-base border"
style={{ background: `${modelColor}18`, borderColor: `${modelColor}35` }}
>
{modelAvatar}
</div>
<div className="flex-1 min-w-0">
{/* Model name + metrics */}
<div className="flex items-center gap-2 mb-2">
<span className="text-xs font-semibold font-display" style={{ color: modelColor }}>
{modelName}
</span>
{message.metrics?.predict_time && (
<span className="flex items-center gap-1 text-xs text-apricot/25">
<Timer size={10} />
{message.metrics.predict_time.toFixed(1)}s
</span>
)}
</div>
{/* Content */}
{message.pending ? (
<div className="flex items-center gap-1.5 h-6" style={{ color: modelColor }}>
<span className="typing-dot" />
<span className="typing-dot" />
<span className="typing-dot" />
</div>
) : message.error ? (
<div className="flex items-center gap-2 text-blush bg-blush/10 border border-blush/25 rounded-xl px-3 py-2.5">
<AlertCircle size={14} />
<span className="text-sm">{message.content}</span>
</div>
) : (
<div className="prose-dark text-sm">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({ node, inline, className, children, ...props }) {
if (inline) return <code className={className} {...props}>{children}</code>;
return <CodeBlock className={className}>{children}</CodeBlock>;
}
}}
>
{message.content}
</ReactMarkdown>
</div>
)}
{/* Footer */}
{!message.pending && !message.error && (
<div className="flex items-center gap-1 mt-2">
<CopyButton text={message.content} />
<span className="text-xs text-apricot/20">
{new Date(message.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,264 @@
import React, { useState } from 'react';
import { X, Plus, Trash2, Edit2, Check, AlertCircle, Cpu, ChevronDown, ChevronUp } from 'lucide-react';
import { useStore } from '../store/useStore.js';
const DEFAULT_FORM = {
tag: '', displayName: '', owner: '', name: '',
type: 'text', avatar: '🤖', color: '#9a348e',
description: '', systemPromptParam: 'system_prompt',
defaultInput: '{}'
};
function ModelCard({ model, onDelete, onEdit }) {
return (
<div className="flex items-center gap-3 p-3 bg-surface-3 rounded-xl border border-grape/15 group hover:border-grape/30 transition-all">
<div
className="w-10 h-10 rounded-xl flex items-center justify-center text-xl border flex-shrink-0"
style={{ background: `${model.color}18`, borderColor: `${model.color}35` }}
>
{model.avatar}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-apricot/90 font-display">{model.displayName}</span>
<span className="text-xs text-apricot/35 font-mono">@{model.tag}</span>
</div>
<div className="text-xs text-apricot/35 truncate">
{model.owner}/{model.name}
</div>
{model.description && (
<div className="text-xs text-apricot/25 truncate mt-0.5">{model.description}</div>
)}
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => onEdit(model)}
className="p-1.5 rounded-lg hover:bg-grape/20 text-apricot/30 hover:text-apricot/70 transition-colors"
>
<Edit2 size={13} />
</button>
<button
onClick={() => onDelete(model.id)}
className="p-1.5 rounded-lg hover:bg-blush/15 text-apricot/30 hover:text-blush transition-colors"
>
<Trash2 size={13} />
</button>
</div>
</div>
);
}
function AddModelForm({ onClose, editModel }) {
const { addModel, updateModel } = useStore();
const [form, setForm] = useState(editModel
? { ...editModel, defaultInput: JSON.stringify(editModel.defaultInput || {}, null, 2) }
: DEFAULT_FORM
);
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
const setF = (key, val) => setForm(f => ({ ...f, [key]: val }));
const handleUrlParse = (url) => {
const match = url.match(/models\/([^/]+)\/([^/]+)/);
if (match) {
setF('owner', match[1]);
setF('name', match[2]);
if (!form.tag) setF('tag', match[2].split(/[-_.]/)[0].toLowerCase().replace(/[^a-z0-9]/g, ''));
if (!form.displayName) setF('displayName', `${match[1]}/${match[2]}`);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
setSaving(true);
try {
let parsedInput = {};
try { parsedInput = JSON.parse(form.defaultInput || '{}'); }
catch (_) { throw new Error('Invalid JSON in Default Input field'); }
const payload = { ...form, defaultInput: parsedInput };
if (editModel) await updateModel(editModel.id, payload);
else await addModel(payload);
onClose();
} catch (err) {
setError(err.message);
} finally {
setSaving(false);
}
};
const inputClass = "w-full bg-surface-3 border border-grape/20 focus:border-grape/50 rounded-xl px-3 py-2.5 text-sm text-apricot/80 placeholder-apricot/20 focus:outline-none transition-colors";
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs text-apricot/40 mb-1.5 font-medium">Paste Replicate URL or API endpoint</label>
<input
type="text"
placeholder="https://api.replicate.com/v1/models/owner/model-name/predictions"
className={inputClass}
onChange={e => handleUrlParse(e.target.value)}
/>
<p className="text-xs text-apricot/25 mt-1">Auto-fills Owner & Model Name fields</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-apricot/40 mb-1.5 font-medium">Owner *</label>
<input required value={form.owner} onChange={e => setF('owner', e.target.value)} placeholder="anthropic" className={inputClass} />
</div>
<div>
<label className="block text-xs text-apricot/40 mb-1.5 font-medium">Model Name *</label>
<input required value={form.name} onChange={e => setF('name', e.target.value)} placeholder="claude-opus-4.6" className={inputClass} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-apricot/40 mb-1.5 font-medium">Display Name *</label>
<input required value={form.displayName} onChange={e => setF('displayName', e.target.value)} placeholder="Claude Opus 4.6" className={inputClass} />
</div>
<div>
<label className="block text-xs text-apricot/40 mb-1.5 font-medium">@Tag * (no spaces)</label>
<input
required
value={form.tag}
onChange={e => setF('tag', e.target.value.replace(/[^a-z0-9]/g, ''))}
placeholder="claude"
className={`${inputClass} font-mono`}
/>
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-xs text-apricot/40 mb-1.5 font-medium">Avatar Emoji</label>
<input value={form.avatar} onChange={e => setF('avatar', e.target.value)} placeholder="🤖" className={inputClass} />
</div>
<div>
<label className="block text-xs text-apricot/40 mb-1.5 font-medium">Accent Color</label>
<input type="color" value={form.color} onChange={e => setF('color', e.target.value)}
className="w-full h-[42px] bg-surface-3 border border-grape/20 rounded-xl px-1.5 focus:outline-none cursor-pointer" />
</div>
<div>
<label className="block text-xs text-apricot/40 mb-1.5 font-medium">Type</label>
<select value={form.type} onChange={e => setF('type', e.target.value)}
className={inputClass}>
<option value="text">Text</option>
<option value="image">Image</option>
<option value="video">Video</option>
<option value="audio">Audio</option>
</select>
</div>
</div>
<button type="button" onClick={() => setShowAdvanced(!showAdvanced)}
className="flex items-center gap-1.5 text-xs text-apricot/35 hover:text-apricot/60 transition-colors">
{showAdvanced ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
Advanced Options
</button>
{showAdvanced && (
<div className="space-y-3 border-t border-grape/15 pt-3">
<div>
<label className="block text-xs text-apricot/40 mb-1.5 font-medium">System Prompt Parameter</label>
<input value={form.systemPromptParam || ''} onChange={e => setF('systemPromptParam', e.target.value || null)}
placeholder="system_prompt (leave empty if not supported)" className={`${inputClass} font-mono`} />
</div>
<div>
<label className="block text-xs text-apricot/40 mb-1.5 font-medium">Default Input JSON</label>
<textarea value={form.defaultInput} onChange={e => setF('defaultInput', e.target.value)}
rows={4} className={`${inputClass} font-mono resize-none`} />
</div>
<div>
<label className="block text-xs text-apricot/40 mb-1.5 font-medium">Description</label>
<input value={form.description} onChange={e => setF('description', e.target.value)}
placeholder="Brief description" className={inputClass} />
</div>
</div>
)}
{error && (
<div className="flex items-center gap-2 text-blush bg-blush/10 border border-blush/25 rounded-xl px-3 py-2.5 text-sm">
<AlertCircle size={14} />
{error}
</div>
)}
<div className="flex gap-2 pt-1">
<button type="button" onClick={onClose}
className="flex-1 border border-grape/20 text-apricot/40 hover:text-apricot/70 hover:border-grape/40 rounded-xl py-2.5 text-sm transition-colors">
Cancel
</button>
<button type="submit" disabled={saving}
className="flex-1 text-midnight rounded-xl py-2.5 text-sm font-semibold font-display transition-all flex items-center justify-center gap-2 disabled:opacity-50"
style={{ background: 'linear-gradient(90deg, #da627d, #9a348e)' }}>
{saving ? 'Saving…' : <><Check size={14} /> {editModel ? 'Update Model' : 'Add Model'}</>}
</button>
</div>
</form>
);
}
export default function ModelManager() {
const { models, deleteModel, modelManagerOpen, setModelManagerOpen } = useStore();
const [showAdd, setShowAdd] = useState(false);
const [editModel, setEditModel] = useState(null);
if (!modelManagerOpen) return null;
const handleDelete = async (id) => {
if (confirm('Remove this model from the list?')) await deleteModel(id);
};
const handleEdit = (model) => { setEditModel(model); setShowAdd(true); };
const closeForm = () => { setShowAdd(false); setEditModel(null); };
return (
<div className="fixed inset-0 bg-midnight/75 backdrop-blur-sm z-50 flex items-center justify-center p-4 animate-fade-in">
<div className="bg-surface-1 border border-grape/25 rounded-2xl w-full max-w-xl max-h-[85vh] flex flex-col shadow-2xl glow-grape">
<div className="flex items-center justify-between p-5 border-b border-grape/15">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-xl bg-grape/20 border border-grape/30 flex items-center justify-center">
<Cpu size={15} className="text-tangerine" />
</div>
<div>
<h2 className="text-sm font-bold text-apricot font-display">Model Manager</h2>
<p className="text-xs text-apricot/30">{models.length} model{models.length !== 1 ? 's' : ''} configured</p>
</div>
</div>
<button onClick={() => setModelManagerOpen(false)} className="p-1.5 rounded-lg hover:bg-grape/20 text-apricot/40 hover:text-apricot transition-colors">
<X size={16} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-5 space-y-3">
{showAdd ? (
<AddModelForm onClose={closeForm} editModel={editModel} />
) : (
<>
{models.length === 0 ? (
<div className="text-center py-10 text-apricot/30">
<Cpu size={36} className="mx-auto mb-3 opacity-20" />
<p className="text-sm">No models configured.</p>
<p className="text-xs mt-1 text-apricot/20">Add your first Replicate model below.</p>
</div>
) : (
models.map(m => <ModelCard key={m.id} model={m} onDelete={handleDelete} onEdit={handleEdit} />)
)}
<button
onClick={() => setShowAdd(true)}
className="flex items-center justify-center gap-2 w-full border border-dashed border-grape/20 hover:border-grape/40 hover:bg-grape/5 rounded-xl py-3.5 text-sm text-apricot/35 hover:text-tangerine transition-all"
>
<Plus size={14} />
Add New Model
</button>
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,158 @@
import React from 'react';
import { Plus, MessageSquare, Trash2, Settings, Cpu, ChevronLeft, ChevronRight } from 'lucide-react';
import { useStore } from '../store/useStore.js';
import BalanceDisplay from './BalanceDisplay.jsx';
export default function Sidebar() {
const {
conversations, activeConvId, sidebarOpen,
createConversation, selectConversation, deleteConversation,
toggleSidebar, setModelManagerOpen, models
} = useStore();
return (
<>
{/* Collapsed toggle */}
{!sidebarOpen && (
<button
onClick={toggleSidebar}
className="fixed left-0 top-1/2 -translate-y-1/2 z-30 bg-surface-2 border border-grape/30 rounded-r-xl p-2 text-apricot/50 hover:text-apricot transition-colors"
>
<ChevronRight size={16} />
</button>
)}
{/* Sidebar */}
<aside className={`
flex flex-col bg-surface-1 border-r border-grape/20 transition-all duration-300 flex-shrink-0
${sidebarOpen ? 'w-72' : 'w-0 overflow-hidden'}
`}>
{/* Header */}
<div className="flex items-center justify-between px-4 py-4 border-b border-grape/15">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-xl bg-brand-gradient-r flex items-center justify-center text-midnight text-base font-black font-display">
IQ
</div>
<span className="font-display font-bold text-apricot text-lg tracking-tight">IQAI</span>
</div>
<button
onClick={toggleSidebar}
className="p-1.5 rounded-lg hover:bg-grape/20 text-apricot/40 hover:text-apricot/80 transition-colors"
>
<ChevronLeft size={16} />
</button>
</div>
{/* Balance */}
<div className="px-3 py-3 border-b border-grape/15">
<BalanceDisplay />
</div>
{/* New Chat */}
<div className="px-3 py-2">
<button
onClick={createConversation}
className="flex items-center gap-2 w-full bg-grape/20 hover:bg-grape/30 border border-grape/30 hover:border-grape/50 rounded-xl px-3 py-2.5 text-sm text-apricot/80 hover:text-apricot transition-all font-medium"
>
<Plus size={15} />
New Chat
</button>
</div>
{/* Conversations */}
<div className="flex-1 overflow-y-auto px-2 pb-2 space-y-0.5">
{conversations.length === 0 ? (
<div className="text-center py-8 text-apricot/25 text-xs px-4">
<MessageSquare size={24} className="mx-auto mb-2 opacity-50" />
No conversations yet
</div>
) : (
conversations.map(conv => (
<ConvItem
key={conv.id}
conv={conv}
active={conv.id === activeConvId}
onSelect={selectConversation}
onDelete={deleteConversation}
/>
))
)}
</div>
{/* Bottom section */}
<div className="border-t border-grape/15 p-3 space-y-1">
{/* Active models chips */}
<ActiveModels />
<button
onClick={() => setModelManagerOpen(true)}
className="flex items-center gap-2 w-full px-3 py-2.5 rounded-xl text-sm text-apricot/60 hover:text-apricot hover:bg-grape/15 transition-colors"
>
<Cpu size={14} />
Manage Models
<span className="ml-auto text-xs bg-grape/30 text-apricot/60 px-1.5 py-0.5 rounded-full">
{models.length}
</span>
</button>
</div>
</aside>
</>
);
}
function ConvItem({ conv, active, onSelect, onDelete }) {
return (
<div
className={`group flex items-center gap-2 px-3 py-2.5 rounded-xl cursor-pointer transition-all ${
active
? 'bg-grape/25 border border-grape/40'
: 'hover:bg-grape/10 border border-transparent'
}`}
onClick={() => onSelect(conv.id)}
>
<MessageSquare size={13} className={active ? 'text-blush' : 'text-apricot/30'} />
<span className="flex-1 text-sm truncate text-apricot/80 group-hover:text-apricot transition-colors">
{conv.title}
</span>
<button
onClick={(e) => { e.stopPropagation(); onDelete(conv.id); }}
className="opacity-0 group-hover:opacity-100 p-1 rounded-lg hover:bg-blush/20 text-apricot/30 hover:text-blush transition-all"
>
<Trash2 size={11} />
</button>
</div>
);
}
function ActiveModels() {
const { models, getActiveConv, toggleModel, activeConvId } = useStore();
const conv = getActiveConv();
if (!conv || models.length === 0) return null;
return (
<div className="px-1 pb-1">
<p className="text-xs text-apricot/30 font-medium px-2 mb-1.5">Active Models</p>
<div className="flex flex-wrap gap-1.5">
{models.filter(m => m.type === 'text').map(model => {
const active = conv.activeModelIds.includes(model.id);
return (
<button
key={model.id}
onClick={() => toggleModel(activeConvId, model.id)}
title={model.displayName}
className={`flex items-center gap-1 text-xs px-2 py-1 rounded-lg border transition-all ${
active
? 'border-current opacity-100'
: 'border-white/10 opacity-40 hover:opacity-70'
}`}
style={active ? { color: model.color, background: `${model.color}20`, borderColor: `${model.color}40` } : {}}
>
<span>{model.avatar}</span>
<span>@{model.tag}</span>
</button>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,90 @@
import React, { useState } from 'react';
import { X, Save, BookOpen } from 'lucide-react';
import { useStore } from '../store/useStore.js';
export default function SystemPromptPanel() {
const { settingsPanelModel, setSettingsPanelModel, getActiveConv, setSystemPrompt, activeConvId } = useStore();
const conv = getActiveConv();
const model = settingsPanelModel;
if (!model) return null;
const current = conv?.systemPrompts?.[model.id] || '';
const [draft, setDraft] = useState(current);
const [saved, setSaved] = useState(false);
const save = () => {
if (activeConvId) {
setSystemPrompt(activeConvId, model.id, draft);
setSaved(true);
setTimeout(() => setSaved(false), 1500);
}
};
return (
<div className="fixed inset-0 bg-midnight/70 backdrop-blur-sm z-50 flex items-center justify-center p-4 animate-fade-in">
<div className="bg-surface-1 border rounded-2xl w-full max-w-lg shadow-2xl glow-grape"
style={{ borderColor: `${model.color}30` }}>
{/* Header */}
<div className="flex items-center justify-between p-5 border-b" style={{ borderColor: `${model.color}20` }}>
<div className="flex items-center gap-3">
<div
className="w-9 h-9 rounded-xl flex items-center justify-center text-xl border"
style={{ background: `${model.color}20`, borderColor: `${model.color}40` }}
>
{model.avatar}
</div>
<div>
<h3 className="text-sm font-semibold text-apricot font-display">{model.displayName}</h3>
<p className="text-xs text-apricot/40">System Instructions</p>
</div>
</div>
<button
onClick={() => setSettingsPanelModel(null)}
className="p-1.5 rounded-lg hover:bg-grape/20 text-apricot/40 hover:text-apricot transition-colors"
>
<X size={15} />
</button>
</div>
<div className="p-5 space-y-4">
<div className="flex items-start gap-2 bg-grape/10 border border-grape/20 rounded-xl px-3 py-2.5">
<BookOpen size={13} className="text-tangerine mt-0.5 flex-shrink-0" />
<p className="text-xs text-apricot/60 leading-relaxed">
System instructions guide the model's personality, tone, and behavior. They persist across all messages in this conversation for <span style={{ color: model.color }}>@{model.tag}</span>.
</p>
</div>
<textarea
value={draft}
onChange={e => setDraft(e.target.value)}
placeholder={`You are ${model.displayName}. You are helpful, harmless, and honest...`}
rows={8}
className="w-full bg-surface-2 border border-grape/20 focus:border-grape/50 rounded-xl px-4 py-3 text-sm text-apricot/80 placeholder-apricot/20 focus:outline-none resize-none leading-relaxed transition-colors"
/>
<div className="flex gap-2">
<button
onClick={() => { setDraft(''); setSystemPrompt(activeConvId, model.id, ''); }}
className="px-4 py-2 border border-grape/20 rounded-lg text-sm text-apricot/50 hover:text-apricot hover:border-grape/40 transition-colors"
>
Clear
</button>
<button
onClick={save}
className="flex-1 flex items-center justify-center gap-2 py-2 rounded-lg text-sm font-medium transition-all"
style={{
background: saved ? '#9a348e30' : `${model.color}30`,
border: `1px solid ${model.color}50`,
color: saved ? '#9a348e' : model.color
}}
>
<Save size={13} />
{saved ? 'Saved!' : 'Save Instructions'}
</button>
</div>
</div>
</div>
</div>
);
}

152
frontend/src/index.css Normal file
View File

@@ -0,0 +1,152 @@
@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700;800&family=Poppins:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--soft-apricot: #f9dbbd;
--tangerine-dream: #fca17d;
--blush-rose: #da627d;
--grape-soda: #9a348e;
--midnight-violet: #0d0628;
}
* { box-sizing: border-box; }
html, body, #root {
height: 100%;
margin: 0;
padding: 0;
}
body {
background-color: var(--midnight-violet);
color: var(--soft-apricot);
font-family: 'Poppins', 'Inter', sans-serif;
-webkit-font-smoothing: antialiased;
}
/* Custom scrollbar */
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #9a348e50; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #9a348e90; }
/* Selection */
::selection { background: #9a348e50; color: #f9dbbd; }
/* --- Prose/Markdown styles --- */
.prose-dark {
color: #e8d5c8;
line-height: 1.75;
}
.prose-dark p { margin: 0.55em 0; }
.prose-dark p:first-child { margin-top: 0; }
.prose-dark p:last-child { margin-bottom: 0; }
.prose-dark h1, .prose-dark h2, .prose-dark h3, .prose-dark h4 {
font-family: 'Montserrat', sans-serif;
color: #f9dbbd;
font-weight: 700;
margin: 1em 0 0.4em;
line-height: 1.3;
}
.prose-dark h1 { font-size: 1.4em; }
.prose-dark h2 { font-size: 1.22em; }
.prose-dark h3 { font-size: 1.08em; }
.prose-dark ul, .prose-dark ol { padding-left: 1.4em; margin: 0.5em 0; }
.prose-dark li { margin: 0.25em 0; }
.prose-dark code {
background: #1e1150;
color: #fca17d;
padding: 0.15em 0.4em;
border-radius: 4px;
font-size: 0.87em;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
border: 1px solid #9a348e30;
}
.prose-dark pre {
background: #110830 !important;
border: 1px solid #9a348e30;
border-radius: 10px;
margin: 0.75em 0;
overflow-x: auto;
position: relative;
}
.prose-dark pre code {
background: none;
color: inherit;
padding: 0;
border: none;
font-size: 0.86em;
}
.prose-dark blockquote {
border-left: 3px solid #9a348e;
padding-left: 1em;
color: #c4a490;
margin: 0.75em 0;
font-style: italic;
}
.prose-dark table {
width: 100%;
border-collapse: collapse;
margin: 0.75em 0;
font-size: 0.88em;
}
.prose-dark th, .prose-dark td {
border: 1px solid #9a348e30;
padding: 0.5em 0.75em;
text-align: left;
}
.prose-dark th {
background: #1e1150;
font-weight: 600;
color: #f9dbbd;
font-family: 'Montserrat', sans-serif;
}
.prose-dark tr:nth-child(even) td { background: #140a35; }
.prose-dark a { color: #da627d; text-decoration: underline; }
.prose-dark a:hover { color: #fca17d; }
.prose-dark hr { border-color: #9a348e30; margin: 1em 0; }
.prose-dark strong { color: #f9dbbd; font-weight: 600; }
/* Typing indicator */
.typing-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
animation: typingBounce 1.4s ease-in-out infinite;
}
.typing-dot:nth-child(2) { animation-delay: 0.2s; }
.typing-dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes typingBounce {
0%, 60%, 100% { transform: translateY(0); opacity: 0.35; }
30% { transform: translateY(-6px); opacity: 1; }
}
/* Gradient text */
.text-gradient {
background: linear-gradient(90deg, #fca17d, #da627d, #9a348e);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Glow effects */
.glow-grape { box-shadow: 0 0 20px #9a348e40; }
.glow-blush { box-shadow: 0 0 20px #da627d30; }
/* Textarea auto-resize */
textarea {
field-sizing: content;
min-height: 44px;
max-height: 240px;
}
/* Glass card */
.glass {
background: rgba(26, 13, 64, 0.6);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}

10
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.jsx';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,287 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { api } from '../utils/api.js';
const newConv = () => ({
id: `conv-${Date.now()}`,
title: 'New Chat',
messages: [],
activeModelIds: [],
systemPrompts: {},
createdAt: new Date().toISOString()
});
export const useStore = create(
persist(
(set, get) => ({
// Models
models: [],
modelsLoading: false,
// Conversations
conversations: [],
activeConvId: null,
// Account
account: null,
accountLoading: false,
// UI state
sidebarOpen: true,
modelManagerOpen: false,
settingsPanelModel: null,
webSearchEnabled: false,
// --- Models ---
fetchModels: async () => {
set({ modelsLoading: true });
try {
const models = await api.getModels();
set({ models, modelsLoading: false });
} catch (err) {
console.error('Failed to fetch models:', err);
set({ modelsLoading: false });
}
},
addModel: async (modelData) => {
const model = await api.addModel(modelData);
set(s => ({ models: [...s.models, model] }));
return model;
},
updateModel: async (id, data) => {
const updated = await api.updateModel(id, data);
set(s => ({ models: s.models.map(m => m.id === id ? updated : m) }));
return updated;
},
deleteModel: async (id) => {
await api.deleteModel(id);
set(s => ({
models: s.models.filter(m => m.id !== id),
conversations: s.conversations.map(c => ({
...c,
activeModelIds: c.activeModelIds.filter(mid => mid !== id)
}))
}));
},
// --- Conversations ---
getActiveConv: () => {
const { conversations, activeConvId } = get();
return conversations.find(c => c.id === activeConvId) || null;
},
createConversation: () => {
const conv = newConv();
const { models } = get();
// Default to first available text model
const firstText = models.find(m => m.type === 'text');
if (firstText) conv.activeModelIds = [firstText.id];
set(s => ({
conversations: [conv, ...s.conversations],
activeConvId: conv.id
}));
return conv;
},
selectConversation: (id) => set({ activeConvId: id }),
deleteConversation: (id) => {
set(s => {
const remaining = s.conversations.filter(c => c.id !== id);
const nextId = remaining[0]?.id || null;
return { conversations: remaining, activeConvId: nextId };
});
},
clearConversation: (id) => {
set(s => ({
conversations: s.conversations.map(c =>
c.id === id ? { ...c, messages: [], title: 'New Chat' } : c
)
}));
},
toggleModel: (convId, modelId) => {
set(s => ({
conversations: s.conversations.map(c => {
if (c.id !== convId) return c;
const has = c.activeModelIds.includes(modelId);
return {
...c,
activeModelIds: has
? c.activeModelIds.filter(id => id !== modelId)
: [...c.activeModelIds, modelId]
};
})
}));
},
setSystemPrompt: (convId, modelId, prompt) => {
set(s => ({
conversations: s.conversations.map(c =>
c.id !== convId ? c : {
...c,
systemPrompts: { ...c.systemPrompts, [modelId]: prompt }
}
)
}));
},
// --- Sending messages ---
sendMessage: async (content) => {
const { getActiveConv, models, webSearchEnabled } = get();
let conv = getActiveConv();
if (!conv) {
get().createConversation();
conv = get().getActiveConv();
}
if (!conv) return;
// Parse @mentions from message
const mentionedModelIds = parseMentions(content, models);
const targetModelIds = mentionedModelIds.length > 0
? mentionedModelIds.map(m => m.id)
: conv.activeModelIds;
if (targetModelIds.length === 0) {
throw new Error('No models selected. Choose a model from the sidebar or @mention one.');
}
const userMsg = {
id: `msg-${Date.now()}`,
role: 'user',
content,
timestamp: new Date().toISOString()
};
// Add user message and pending assistant messages
const pendingMsgs = targetModelIds.map(modelId => ({
id: `msg-${Date.now()}-${modelId}`,
role: 'assistant',
modelId,
content: '',
pending: true,
timestamp: new Date().toISOString()
}));
const convId = conv.id;
set(s => ({
conversations: s.conversations.map(c =>
c.id !== convId ? c : {
...c,
title: c.title === 'New Chat' ? content.slice(0, 50) : c.title,
messages: [...c.messages, userMsg, ...pendingMsgs]
}
)
}));
// Optionally fetch search context
let searchContext = null;
if (webSearchEnabled) {
try {
const result = await api.search(content);
searchContext = result.formatted;
} catch (_) {}
}
// Build conversation history (last 10 exchanges for context)
const history = conv.messages.slice(-20).filter(m => !m.pending);
// Call each model
const calls = targetModelIds.map(async (modelId) => {
const model = models.find(m => m.id === modelId);
if (!model) return;
const systemPrompt = conv.systemPrompts?.[modelId] || '';
try {
const result = await api.chat({
modelId,
prompt: content,
systemPrompt,
searchContext,
history
});
set(s => ({
conversations: s.conversations.map(c =>
c.id !== convId ? c : {
...c,
messages: c.messages.map(m =>
m.pending && m.modelId === modelId
? { ...m, content: result.content, pending: false, predictionId: result.id, metrics: result.metrics }
: m
)
}
)
}));
} catch (err) {
set(s => ({
conversations: s.conversations.map(c =>
c.id !== convId ? c : {
...c,
messages: c.messages.map(m =>
m.pending && m.modelId === modelId
? { ...m, content: `Error: ${err.message}`, pending: false, error: true }
: m
)
}
)
}));
}
});
await Promise.all(calls);
},
// --- Account ---
fetchAccount: async () => {
set({ accountLoading: true });
try {
const data = await api.getAccount();
set({ account: data, accountLoading: false });
} catch (_) {
set({ accountLoading: false });
}
},
// --- UI ---
toggleSidebar: () => set(s => ({ sidebarOpen: !s.sidebarOpen })),
setModelManagerOpen: (v) => set({ modelManagerOpen: v }),
setSettingsPanelModel: (model) => set({ settingsPanelModel: model }),
toggleWebSearch: () => set(s => ({ webSearchEnabled: !s.webSearchEnabled }))
}),
{
name: 'iqai-store',
partialize: (s) => ({
conversations: s.conversations,
activeConvId: s.activeConvId,
sidebarOpen: s.sidebarOpen,
webSearchEnabled: s.webSearchEnabled
})
}
)
);
function parseMentions(text, models) {
if (!models?.length) return [];
const tags = models.map(m => m.tag);
const regex = new RegExp(`@(${tags.join('|')})\\b`, 'gi');
const found = new Set();
const results = [];
let match;
while ((match = regex.exec(text)) !== null) {
const model = models.find(m => m.tag.toLowerCase() === match[1].toLowerCase());
if (model && !found.has(model.id)) {
found.add(model.id);
results.push(model);
}
}
return results;
}

35
frontend/src/utils/api.js Normal file
View File

@@ -0,0 +1,35 @@
const BASE = '/api';
async function req(method, path, body) {
const res = await fetch(`${BASE}${path}`, {
method,
headers: body ? { 'Content-Type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
throw new Error(err.error || `HTTP ${res.status}`);
}
return res.json();
}
export const api = {
// Models
getModels: () => req('GET', '/models'),
addModel: (model) => req('POST', '/models', model),
updateModel: (id, data) => req('PUT', `/models/${id}`, data),
deleteModel: (id) => req('DELETE', `/models/${id}`),
// Chat
chat: (payload) => req('POST', '/chat', payload),
chatMulti: (payload) => req('POST', '/chat/multi', payload),
// Account
getAccount: () => req('GET', '/account'),
// Search
search: (query) => req('GET', `/search?q=${encodeURIComponent(query)}`),
// Health
health: () => req('GET', '/health')
};