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

8
.env.example Normal file
View File

@@ -0,0 +1,8 @@
# Replicate API token (get yours at https://replicate.com/account/api-tokens)
REPLICATE_API_TOKEN=your_token_here
# Port for the frontend (default: 80)
FRONTEND_PORT=80
# Frontend URL for CORS (update if deploying to a domain)
FRONTEND_URL=http://localhost

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
dist/
.env
*.log
.DS_Store

12
Dockerfile.backend Normal file
View File

@@ -0,0 +1,12 @@
FROM node:20-alpine
WORKDIR /app
COPY backend/package.json ./
RUN npm install --omit=dev
COPY backend/ ./
EXPOSE 3001
CMD ["node", "server.js"]

25
Dockerfile.frontend Normal file
View File

@@ -0,0 +1,25 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY frontend/package.json ./
RUN npm install
COPY frontend/ ./
# Inject VITE_API_URL at build time (uses /api proxy via nginx)
ARG VITE_API_URL=/api
ENV VITE_API_URL=$VITE_API_URL
RUN npm run build
# Production stage - serve with nginx
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

68
README.md Normal file
View File

@@ -0,0 +1,68 @@
# IQAI — Multi-Model AI Dashboard
A sleek AI chat dashboard powered by [Replicate](https://replicate.com). Chat with multiple AI models simultaneously, tag them with @mentions, and augment responses with live web search.
## Features
- **Multi-model chat** — Select multiple models and query them in parallel
- **@mention routing** — Type `@claude`, `@grok`, `@gemini` to direct messages to specific models
- **Web search** — Inject DuckDuckGo search results as context before querying models
- **Model manager** — Add, edit, or remove Replicate models from the UI
- **System instructions** — Per-model system prompts per conversation
- **Account info** — View your Replicate account details in the sidebar
- **Conversation history** — Persisted locally across sessions
## Quick Start (Docker)
```bash
cp .env.example .env
# Edit .env and set your REPLICATE_API_TOKEN
docker compose up --build
# Open http://localhost
```
## Development
```bash
# Backend
cd backend && npm install && node server.js
# Frontend (in another terminal)
cd frontend && npm install && npm run dev
```
## Adding Models
Click **Manage Models** in the sidebar. Paste any Replicate model API URL to auto-fill the fields:
```
https://api.replicate.com/v1/models/owner/model-name/predictions
```
## Default Models
| Model | Tag | Type |
|-------|-----|------|
| Claude Opus 4.6 | @claude | Text |
| Grok 4 | @grok | Text |
| Gemini 3.1 Pro | @gemini | Text |
| DeepSeek R1 | @deepseek | Text |
## Architecture
```
iqai/
├── backend/ Express API server (port 3001)
│ ├── routes/ chat, models, account, search
│ └── data/ models.json (persisted config)
├── frontend/ React + Vite + Tailwind
│ └── src/
│ ├── components/
│ ├── store/ Zustand state management
│ └── utils/ API client
├── docker-compose.yml
├── Dockerfile.backend
├── Dockerfile.frontend
└── nginx.conf
```

70
backend/data/models.json Normal file
View File

@@ -0,0 +1,70 @@
[
{
"id": "claude-opus-4",
"tag": "claude",
"displayName": "Claude Opus 4.6",
"owner": "anthropic",
"name": "claude-opus-4.6",
"type": "text",
"avatar": "🤖",
"color": "#CC785C",
"description": "Anthropic's most powerful model",
"systemPromptParam": "system_prompt",
"defaultInput": {
"max_tokens": 8192,
"max_image_resolution": 0.5
}
},
{
"id": "grok-4",
"tag": "grok",
"displayName": "Grok 4",
"owner": "xai",
"name": "grok-4",
"type": "text",
"avatar": "⚡",
"color": "#1DA1F2",
"description": "xAI's advanced reasoning model",
"systemPromptParam": null,
"defaultInput": {
"max_tokens": 2048,
"temperature": 0.1
}
},
{
"id": "gemini-3-pro",
"tag": "gemini",
"displayName": "Gemini 3.1 Pro",
"owner": "google",
"name": "gemini-3.1-pro",
"type": "text",
"avatar": "✨",
"color": "#4285F4",
"description": "Google's latest multimodal model",
"systemPromptParam": null,
"defaultInput": {
"temperature": 1,
"thinking_level": "high",
"max_output_tokens": 65535,
"top_p": 0.95,
"images": [],
"videos": []
}
},
{
"id": "deepseek-r1",
"tag": "deepseek",
"displayName": "DeepSeek R1",
"owner": "deepseek-ai",
"name": "deepseek-r1",
"type": "text",
"avatar": "🔍",
"color": "#7C3AED",
"description": "DeepSeek's advanced reasoning model",
"systemPromptParam": "system_prompt",
"defaultInput": {
"max_tokens": 8192,
"temperature": 0.7
}
}
]

15
backend/package.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "iqai-backend",
"version": "1.0.0",
"type": "module",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js"
},
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2"
}
}

35
backend/routes/account.js Normal file
View File

@@ -0,0 +1,35 @@
import express from 'express';
const router = express.Router();
const REPLICATE_BASE = 'https://api.replicate.com/v1';
router.get('/', async (req, res) => {
const token = process.env.REPLICATE_API_TOKEN;
if (!token) return res.status(400).json({ error: 'REPLICATE_API_TOKEN not configured' });
try {
const response = await fetch(`${REPLICATE_BASE}/account`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
return res.status(response.status).json({ error: err.detail || 'Failed to fetch account' });
}
const account = await response.json();
// Try to get hardware/usage info
let hardware = null;
try {
const hwRes = await fetch(`${REPLICATE_BASE}/hardware`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (hwRes.ok) hardware = await hwRes.json();
} catch (_) {}
res.json({ account, hardware });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
export { router as accountRouter };

129
backend/routes/chat.js Normal file
View File

@@ -0,0 +1,129 @@
import express from 'express';
import { loadModels } from './models.js';
const router = express.Router();
const REPLICATE_BASE = 'https://api.replicate.com/v1';
function buildInput(model, prompt, systemPrompt, extraParams) {
const base = { ...model.defaultInput };
// Inject system prompt if model supports it
if (model.systemPromptParam && systemPrompt) {
base[model.systemPromptParam] = systemPrompt;
}
// Apply extra params from user
if (extraParams) {
Object.assign(base, extraParams);
}
base.prompt = prompt;
return base;
}
async function runPrediction(model, input, token) {
const url = `${REPLICATE_BASE}/models/${model.owner}/${model.name}/predictions`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Prefer': 'wait'
},
body: JSON.stringify({ input })
});
if (!response.ok) {
const err = await response.json().catch(() => ({ detail: 'Unknown error' }));
throw new Error(err.detail || `HTTP ${response.status}`);
}
return response.json();
}
function extractOutput(prediction) {
const { output } = prediction;
if (!output) return '';
if (typeof output === 'string') return output;
if (Array.isArray(output)) return output.join('');
if (typeof output === 'object') return JSON.stringify(output, null, 2);
return String(output);
}
// POST /api/chat - single model call
router.post('/', async (req, res) => {
const token = process.env.REPLICATE_API_TOKEN;
if (!token) return res.status(400).json({ error: 'REPLICATE_API_TOKEN not configured' });
const { modelId, prompt, systemPrompt, searchContext, extraParams } = req.body;
if (!modelId || !prompt) return res.status(400).json({ error: 'modelId and prompt are required' });
try {
const models = await loadModels();
const model = models.find(m => m.id === modelId);
if (!model) return res.status(404).json({ error: `Model not found: ${modelId}` });
const finalPrompt = searchContext ? `${searchContext}\n\n${prompt}` : prompt;
const input = buildInput(model, finalPrompt, systemPrompt, extraParams);
const prediction = await runPrediction(model, input, token);
const content = extractOutput(prediction);
res.json({
id: prediction.id,
modelId: model.id,
modelTag: model.tag,
modelName: model.displayName,
content,
status: prediction.status,
metrics: prediction.metrics,
urls: prediction.urls
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /api/chat/multi - send to multiple models in parallel
router.post('/multi', async (req, res) => {
const token = process.env.REPLICATE_API_TOKEN;
if (!token) return res.status(400).json({ error: 'REPLICATE_API_TOKEN not configured' });
const { modelIds, prompt, systemPrompt, searchContext, extraParams } = req.body;
if (!modelIds?.length || !prompt) return res.status(400).json({ error: 'modelIds and prompt are required' });
try {
const models = await loadModels();
const tasks = modelIds.map(async (modelId) => {
const model = models.find(m => m.id === modelId);
if (!model) return { modelId, error: 'Model not found' };
try {
const finalPrompt = searchContext ? `${searchContext}\n\n${prompt}` : prompt;
const input = buildInput(model, finalPrompt, systemPrompt, extraParams);
const prediction = await runPrediction(model, input, token);
const content = extractOutput(prediction);
return {
id: prediction.id,
modelId: model.id,
modelTag: model.tag,
modelName: model.displayName,
content,
status: prediction.status,
metrics: prediction.metrics
};
} catch (err) {
return { modelId, modelTag: model.tag, modelName: model.displayName, error: err.message };
}
});
const results = await Promise.all(tasks);
res.json({ results });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
export { router as chatRouter };

84
backend/routes/models.js Normal file
View File

@@ -0,0 +1,84 @@
import express from 'express';
import { readFile, writeFile } from 'fs/promises';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const router = express.Router();
const __dirname = dirname(fileURLToPath(import.meta.url));
const MODELS_PATH = join(__dirname, '../data/models.json');
async function loadModels() {
const raw = await readFile(MODELS_PATH, 'utf-8');
return JSON.parse(raw);
}
async function saveModels(models) {
await writeFile(MODELS_PATH, JSON.stringify(models, null, 2));
}
router.get('/', async (req, res) => {
try {
const models = await loadModels();
res.json(models);
} catch (err) {
res.status(500).json({ error: 'Failed to load models' });
}
});
router.post('/', async (req, res) => {
try {
const { tag, displayName, owner, name, type, avatar, color, description, systemPromptParam, defaultInput } = req.body;
if (!tag || !owner || !name || !displayName) {
return res.status(400).json({ error: 'Missing required fields: tag, owner, name, displayName' });
}
const models = await loadModels();
if (models.find(m => m.tag === tag)) {
return res.status(409).json({ error: `Tag @${tag} already exists` });
}
const newModel = {
id: `${owner}-${name}`.replace(/[^a-z0-9-]/gi, '-'),
tag: tag.toLowerCase().replace(/[^a-z0-9]/g, ''),
displayName,
owner,
name,
type: type || 'text',
avatar: avatar || '🤖',
color: color || '#6B7280',
description: description || '',
systemPromptParam: systemPromptParam || null,
defaultInput: defaultInput || {}
};
models.push(newModel);
await saveModels(models);
res.status(201).json(newModel);
} catch (err) {
res.status(500).json({ error: 'Failed to add model' });
}
});
router.put('/:id', async (req, res) => {
try {
const models = await loadModels();
const idx = models.findIndex(m => m.id === req.params.id);
if (idx === -1) return res.status(404).json({ error: 'Model not found' });
models[idx] = { ...models[idx], ...req.body, id: models[idx].id };
await saveModels(models);
res.json(models[idx]);
} catch (err) {
res.status(500).json({ error: 'Failed to update model' });
}
});
router.delete('/:id', async (req, res) => {
try {
const models = await loadModels();
const filtered = models.filter(m => m.id !== req.params.id);
if (filtered.length === models.length) return res.status(404).json({ error: 'Model not found' });
await saveModels(filtered);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: 'Failed to delete model' });
}
});
export { router as modelsRouter, loadModels };

117
backend/routes/search.js Normal file
View File

@@ -0,0 +1,117 @@
import express from 'express';
const router = express.Router();
// DuckDuckGo Instant Answer API - no key required
async function ddgInstantAnswer(query) {
const url = `https://api.duckduckgo.com/?q=${encodeURIComponent(query)}&format=json&no_html=1&skip_disambig=1`;
const res = await fetch(url, {
headers: { 'User-Agent': 'IQAI-Dashboard/1.0' }
});
if (!res.ok) throw new Error('DDG search failed');
return res.json();
}
// DuckDuckGo HTML search scraper for more results
async function ddgWebSearch(query) {
const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
const res = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html'
}
});
if (!res.ok) return [];
const html = await res.text();
// Parse result links and snippets from DDG HTML
const results = [];
const resultRegex = /<a[^>]+class="result__a"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi;
const snippetRegex = /<a[^>]+class="result__snippet"[^>]*>([\s\S]*?)<\/a>/gi;
const links = [...html.matchAll(resultRegex)].slice(0, 8);
const snippets = [...html.matchAll(snippetRegex)].slice(0, 8);
for (let i = 0; i < Math.min(links.length, 5); i++) {
const title = links[i][2].replace(/<[^>]+>/g, '').trim();
const snippet = snippets[i] ? snippets[i][1].replace(/<[^>]+>/g, '').trim() : '';
const href = links[i][1];
// DDG redirects through their own URLs, extract the real URL
const realUrl = href.startsWith('//duckduckgo.com/l/?uddg=')
? decodeURIComponent(href.split('uddg=')[1]?.split('&')[0] || href)
: href;
if (title && !title.includes('DuckDuckGo')) {
results.push({ title, snippet, url: realUrl });
}
}
return results;
}
router.get('/', async (req, res) => {
const { q } = req.query;
if (!q) return res.status(400).json({ error: 'Query parameter q is required' });
try {
const [instant, webResults] = await Promise.allSettled([
ddgInstantAnswer(q),
ddgWebSearch(q)
]);
const answer = instant.status === 'fulfilled' ? instant.value : null;
const results = webResults.status === 'fulfilled' ? webResults.value : [];
// Format for injection into AI prompt
const formatted = formatSearchResults(q, answer, results);
res.json({
query: q,
answer,
results,
formatted
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
function formatSearchResults(query, instant, webResults) {
const lines = [`[WEB SEARCH RESULTS FOR: "${query}"]`, ''];
if (instant?.AbstractText) {
lines.push(`Summary: ${instant.AbstractText}`);
if (instant.AbstractSource) lines.push(`Source: ${instant.AbstractSource}`);
lines.push('');
}
if (instant?.Answer) {
lines.push(`Direct Answer: ${instant.Answer}`);
lines.push('');
}
if (webResults.length > 0) {
lines.push('Web Results:');
webResults.forEach((r, i) => {
lines.push(`${i + 1}. ${r.title}`);
if (r.snippet) lines.push(` ${r.snippet}`);
lines.push(` URL: ${r.url}`);
});
lines.push('');
}
if (instant?.RelatedTopics?.length > 0) {
const topics = instant.RelatedTopics
.filter(t => t.Text)
.slice(0, 3)
.map(t => `- ${t.Text}`);
if (topics.length > 0) {
lines.push('Related Topics:');
lines.push(...topics);
lines.push('');
}
}
lines.push('[END SEARCH RESULTS]');
return lines.join('\n');
}
export { router as searchRouter };

39
backend/server.js Normal file
View File

@@ -0,0 +1,39 @@
import express from 'express';
import cors from 'cors';
import { readFile } from 'fs/promises';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import dotenv from 'dotenv';
dotenv.config();
const __dirname = dirname(fileURLToPath(import.meta.url));
// Lazy import routes after dotenv loaded
const { chatRouter } = await import('./routes/chat.js');
const { modelsRouter } = await import('./routes/models.js');
const { accountRouter } = await import('./routes/account.js');
const { searchRouter } = await import('./routes/search.js');
const app = express();
const PORT = process.env.PORT || 3001;
const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:5173';
app.use(cors({
origin: [FRONTEND_URL, 'http://localhost:80', 'http://localhost'],
credentials: true
}));
app.use(express.json({ limit: '10mb' }));
app.use('/api/chat', chatRouter);
app.use('/api/models', modelsRouter);
app.use('/api/account', accountRouter);
app.use('/api/search', searchRouter);
// Health check
app.get('/api/health', (req, res) => res.json({ status: 'ok', timestamp: new Date().toISOString() }));
app.listen(PORT, '0.0.0.0', () => {
console.log(`IQAI Backend running on http://0.0.0.0:${PORT}`);
console.log(`API Token: ${process.env.REPLICATE_API_TOKEN ? '✓ configured' : '✗ NOT SET'}`);
});

46
docker-compose.yml Normal file
View File

@@ -0,0 +1,46 @@
version: '3.9'
services:
backend:
build:
context: .
dockerfile: Dockerfile.backend
container_name: iqai-backend
restart: unless-stopped
environment:
- REPLICATE_API_TOKEN=${REPLICATE_API_TOKEN}
- FRONTEND_URL=${FRONTEND_URL:-http://localhost}
- PORT=3001
volumes:
# Persist model config across container restarts
- iqai-models:/app/data
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3001/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
networks:
- iqai-net
frontend:
build:
context: .
dockerfile: Dockerfile.frontend
container_name: iqai-frontend
restart: unless-stopped
ports:
- "${FRONTEND_PORT:-80}:80"
depends_on:
backend:
condition: service_healthy
networks:
- iqai-net
volumes:
iqai-models:
driver: local
networks:
iqai-net:
driver: bridge

13
frontend/index.html Normal file
View 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
View 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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

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

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

35
nginx.conf Normal file
View File

@@ -0,0 +1,35 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
gzip_min_length 1024;
# Proxy /api requests to backend
location /api {
proxy_pass http://backend:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 120s;
proxy_connect_timeout 10s;
}
# React SPA routing
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}