commit 71965939a1771ab63fe60b186d83acdc44c14115 Author: Malin Date: Thu Apr 16 13:12:40 2026 +0200 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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c89d2bf --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4274b51 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.env +*.log +.DS_Store diff --git a/Dockerfile.backend b/Dockerfile.backend new file mode 100644 index 0000000..ad0a374 --- /dev/null +++ b/Dockerfile.backend @@ -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"] diff --git a/Dockerfile.frontend b/Dockerfile.frontend new file mode 100644 index 0000000..de7704a --- /dev/null +++ b/Dockerfile.frontend @@ -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;"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..89d35b4 --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/backend/data/models.json b/backend/data/models.json new file mode 100644 index 0000000..ffbf606 --- /dev/null +++ b/backend/data/models.json @@ -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 + } + } +] diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..57ac427 --- /dev/null +++ b/backend/package.json @@ -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" + } +} diff --git a/backend/routes/account.js b/backend/routes/account.js new file mode 100644 index 0000000..1d88200 --- /dev/null +++ b/backend/routes/account.js @@ -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 }; diff --git a/backend/routes/chat.js b/backend/routes/chat.js new file mode 100644 index 0000000..53708c4 --- /dev/null +++ b/backend/routes/chat.js @@ -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 }; diff --git a/backend/routes/models.js b/backend/routes/models.js new file mode 100644 index 0000000..e1fc8e9 --- /dev/null +++ b/backend/routes/models.js @@ -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 }; diff --git a/backend/routes/search.js b/backend/routes/search.js new file mode 100644 index 0000000..334cac1 --- /dev/null +++ b/backend/routes/search.js @@ -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 = /]+class="result__a"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi; + const snippetRegex = /]+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 }; diff --git a/backend/server.js b/backend/server.js new file mode 100644 index 0000000..35a6f74 --- /dev/null +++ b/backend/server.js @@ -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'}`); +}); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ada4327 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..b6e5595 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + IQAI — Multi-Model Dashboard + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..27ae5a8 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..ba80730 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {} + } +}; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..d1a02ef --- /dev/null +++ b/frontend/src/App.jsx @@ -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 ( +
+ {/* Subtle background gradient */} +
+
+
+
+ + {/* Layout */} + + +
+ +
+ + {/* Modals / overlays */} + + +
+ ); +} diff --git a/frontend/src/components/BalanceDisplay.jsx b/frontend/src/components/BalanceDisplay.jsx new file mode 100644 index 0000000..5b7b910 --- /dev/null +++ b/frontend/src/components/BalanceDisplay.jsx @@ -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 ( +
+ + Loading account... +
+ ); + } + + if (!account) { + return ( + + ); + } + + const info = account.account; + + return ( +
+
+
+
+ +
+ Replicate +
+
+ + + + +
+
+ + {info && ( +
+
+ Username + {info.username || '—'} +
+ {info.name && ( +
+ Name + {info.name} +
+ )} +
+ Type + + {info.type || 'user'} + +
+
+ )} + + + View billing & usage + +
+ ); +} diff --git a/frontend/src/components/ChatArea.jsx b/frontend/src/components/ChatArea.jsx new file mode 100644 index 0000000..77b01d2 --- /dev/null +++ b/frontend/src/components/ChatArea.jsx @@ -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 ( +
+ {/* Logo */} +
+
+ 🤖 +
+
+ +
+
+ +

+ Welcome to IQAI +

+

+ Your multi-model AI dashboard. Chat with multiple AI models simultaneously, tag them with @mentions, and augment with live web search. +

+ + {/* Model grid */} + {models.length > 0 && ( +
+ {models.slice(0, 4).map(m => ( +
+ {m.avatar} + @{m.tag} +
+ ))} +
+ )} + +
+

• Type @claude to direct a message to a specific model

+

• Enable Web Search to inject live results as context

+

• Select models from the sidebar to run queries in parallel

+
+
+ ); +} + +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 ( +
+ {/* Top bar */} + {conv && ( +
+
+

+ {conv.title} +

+ {/* Active model tags */} +
+ {conv.activeModelIds.map(id => { + const m = models.find(md => md.id === id); + if (!m) return null; + return ( + + ); + })} +
+
+
+ + +
+
+ )} + + {/* Messages */} +
+ {!conv || conv.messages.length === 0 ? ( + + ) : ( + conv.messages.map(msg => ( + + )) + )} +
+
+ + {/* Error banner */} + {error && ( +
+ {error} + +
+ )} + + {/* Input */} +
+ +

+ IQAI uses Replicate API · Models may make mistakes · Verify important information +

+
+
+ ); +} diff --git a/frontend/src/components/MentionInput.jsx b/frontend/src/components/MentionInput.jsx new file mode 100644 index 0000000..4a94a33 --- /dev/null +++ b/frontend/src/components/MentionInput.jsx @@ -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 ( +
+ {/* Mention dropdown */} + {mentionState && filteredModels.length > 0 && ( +
+
+ Tag a model +
+ {filteredModels.map((model) => ( + + ))} +
+ )} + + {/* Input container */} +
+ {/* Active model chips */} + {activeTags.length > 0 && ( +
+ {activeTags.map(m => ( + + {m.avatar} @{m.tag} + + ))} +
+ )} + +