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:
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🤖</text></svg>" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>IQAI — Multi-Model Dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
26
frontend/package.json
Normal file
26
frontend/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "iqai-frontend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-markdown": "^9.0.1",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"react-syntax-highlighter": "^15.5.0",
|
||||
"zustand": "^4.5.2",
|
||||
"lucide-react": "^0.447.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"vite": "^5.4.8"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
43
frontend/src/App.jsx
Normal file
43
frontend/src/App.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
96
frontend/src/components/BalanceDisplay.jsx
Normal file
96
frontend/src/components/BalanceDisplay.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
170
frontend/src/components/ChatArea.jsx
Normal file
170
frontend/src/components/ChatArea.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
148
frontend/src/components/MessageItem.jsx
Normal file
148
frontend/src/components/MessageItem.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
264
frontend/src/components/ModelManager.jsx
Normal file
264
frontend/src/components/ModelManager.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
158
frontend/src/components/Sidebar.jsx
Normal file
158
frontend/src/components/Sidebar.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
90
frontend/src/components/SystemPromptPanel.jsx
Normal file
90
frontend/src/components/SystemPromptPanel.jsx
Normal 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
152
frontend/src/index.css
Normal 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
10
frontend/src/main.jsx
Normal 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>
|
||||
);
|
||||
287
frontend/src/store/useStore.js
Normal file
287
frontend/src/store/useStore.js
Normal 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
35
frontend/src/utils/api.js
Normal 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')
|
||||
};
|
||||
55
frontend/tailwind.config.js
Normal file
55
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,55 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,jsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Poppins', 'Inter', 'system-ui', 'sans-serif'],
|
||||
display: ['Montserrat', 'Poppins', 'sans-serif'],
|
||||
mono: ['JetBrains Mono', 'Fira Code', 'monospace']
|
||||
},
|
||||
colors: {
|
||||
// Brand palette
|
||||
apricot: '#f9dbbd',
|
||||
tangerine: '#fca17d',
|
||||
blush: '#da627d',
|
||||
grape: '#9a348e',
|
||||
midnight: '#0d0628',
|
||||
// Surface shades (midnight-violet based)
|
||||
surface: {
|
||||
0: '#0d0628',
|
||||
1: '#110830',
|
||||
2: '#180d40',
|
||||
3: '#1e1150',
|
||||
4: '#261560'
|
||||
}
|
||||
},
|
||||
backgroundImage: {
|
||||
'brand-gradient': 'linear-gradient(135deg, #f9dbbd, #fca17d, #da627d, #9a348e, #0d0628)',
|
||||
'brand-gradient-r': 'linear-gradient(90deg, #9a348e, #da627d, #fca17d)',
|
||||
'brand-gradient-v': 'linear-gradient(180deg, #9a348e 0%, #0d0628 100%)',
|
||||
'glow-grape': 'radial-gradient(circle at center, #9a348e40, transparent 70%)',
|
||||
'glow-blush': 'radial-gradient(circle at center, #da627d30, transparent 70%)'
|
||||
},
|
||||
boxShadow: {
|
||||
'grape': '0 0 20px #9a348e40',
|
||||
'blush': '0 0 20px #da627d30',
|
||||
'tangerine': '0 0 20px #fca17d30'
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.2s ease-out',
|
||||
'slide-up': 'slideUp 0.25s ease-out',
|
||||
'glow-pulse': 'glowPulse 3s ease-in-out infinite'
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: { from: { opacity: 0 }, to: { opacity: 1 } },
|
||||
slideUp: { from: { opacity: 0, transform: 'translateY(10px)' }, to: { opacity: 1, transform: 'translateY(0)' } },
|
||||
glowPulse: {
|
||||
'0%, 100%': { boxShadow: '0 0 15px #9a348e30' },
|
||||
'50%': { boxShadow: '0 0 30px #9a348e60' }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: []
|
||||
};
|
||||
15
frontend/vite.config.js
Normal file
15
frontend/vite.config.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user