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:
8
.env.example
Normal file
8
.env.example
Normal 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
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
12
Dockerfile.backend
Normal file
12
Dockerfile.backend
Normal 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
25
Dockerfile.frontend
Normal 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
68
README.md
Normal 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
70
backend/data/models.json
Normal 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
15
backend/package.json
Normal 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
35
backend/routes/account.js
Normal 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
129
backend/routes/chat.js
Normal 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
84
backend/routes/models.js
Normal 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
117
backend/routes/search.js
Normal 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
39
backend/server.js
Normal 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
46
docker-compose.yml
Normal 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
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🤖</text></svg>" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>IQAI — Multi-Model Dashboard</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
26
frontend/package.json
Normal file
26
frontend/package.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "iqai-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-markdown": "^9.0.1",
|
||||||
|
"remark-gfm": "^4.0.0",
|
||||||
|
"react-syntax-highlighter": "^15.5.0",
|
||||||
|
"zustand": "^4.5.2",
|
||||||
|
"lucide-react": "^0.447.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.47",
|
||||||
|
"tailwindcss": "^3.4.13",
|
||||||
|
"vite": "^5.4.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {}
|
||||||
|
}
|
||||||
|
};
|
||||||
43
frontend/src/App.jsx
Normal file
43
frontend/src/App.jsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import Sidebar from './components/Sidebar.jsx';
|
||||||
|
import ChatArea from './components/ChatArea.jsx';
|
||||||
|
import ModelManager from './components/ModelManager.jsx';
|
||||||
|
import SystemPromptPanel from './components/SystemPromptPanel.jsx';
|
||||||
|
import { useStore } from './store/useStore.js';
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const { fetchModels, createConversation, conversations, activeConvId, sidebarOpen } = useStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchModels().then(() => {
|
||||||
|
// Auto-create first conversation if none exists
|
||||||
|
const state = useStore.getState();
|
||||||
|
if (state.conversations.length === 0) {
|
||||||
|
state.createConversation();
|
||||||
|
} else if (!state.activeConvId) {
|
||||||
|
useStore.setState({ activeConvId: state.conversations[0].id });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen overflow-hidden bg-midnight">
|
||||||
|
{/* Subtle background gradient */}
|
||||||
|
<div className="fixed inset-0 pointer-events-none">
|
||||||
|
<div className="absolute top-0 left-0 w-96 h-96 bg-grape/5 rounded-full blur-3xl -translate-x-1/2 -translate-y-1/2" />
|
||||||
|
<div className="absolute bottom-0 right-0 w-96 h-96 bg-blush/5 rounded-full blur-3xl translate-x-1/2 translate-y-1/2" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Layout */}
|
||||||
|
<Sidebar />
|
||||||
|
|
||||||
|
<main className="flex-1 flex flex-col min-w-0 relative">
|
||||||
|
<ChatArea />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Modals / overlays */}
|
||||||
|
<ModelManager />
|
||||||
|
<SystemPromptPanel />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
frontend/src/components/BalanceDisplay.jsx
Normal file
96
frontend/src/components/BalanceDisplay.jsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { Wallet, RefreshCw, ExternalLink } from 'lucide-react';
|
||||||
|
import { useStore } from '../store/useStore.js';
|
||||||
|
|
||||||
|
export default function BalanceDisplay() {
|
||||||
|
const { account, accountLoading, fetchAccount } = useStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAccount();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (accountLoading && !account) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 rounded-xl bg-surface-2 border border-grape/20 animate-pulse">
|
||||||
|
<Wallet size={13} className="text-tangerine/50" />
|
||||||
|
<span className="text-xs text-apricot/30">Loading account...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={fetchAccount}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 rounded-xl bg-surface-2 border border-grape/20 hover:border-grape/40 w-full transition-colors text-left"
|
||||||
|
>
|
||||||
|
<Wallet size={13} className="text-apricot/30" />
|
||||||
|
<span className="text-xs text-apricot/30 flex-1">Account unavailable</span>
|
||||||
|
<RefreshCw size={11} className="text-apricot/20" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = account.account;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-surface-2 border border-grape/20 rounded-xl p-3 space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-6 h-6 rounded-lg bg-tangerine/20 flex items-center justify-center">
|
||||||
|
<Wallet size={12} className="text-tangerine" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-semibold text-apricot/80 font-display">Replicate</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={fetchAccount}
|
||||||
|
className="p-1 rounded hover:bg-grape/20 text-apricot/30 hover:text-apricot/60 transition-colors"
|
||||||
|
title="Refresh"
|
||||||
|
>
|
||||||
|
<RefreshCw size={11} className={accountLoading ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="https://replicate.com/billing"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="p-1 rounded hover:bg-grape/20 text-apricot/30 hover:text-apricot/60 transition-colors"
|
||||||
|
title="View billing on Replicate"
|
||||||
|
>
|
||||||
|
<ExternalLink size={11} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{info && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-apricot/40">Username</span>
|
||||||
|
<span className="text-xs text-apricot/70 font-mono">{info.username || '—'}</span>
|
||||||
|
</div>
|
||||||
|
{info.name && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-apricot/40">Name</span>
|
||||||
|
<span className="text-xs text-apricot/70">{info.name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-apricot/40">Type</span>
|
||||||
|
<span className="text-xs capitalize bg-grape/20 text-grape px-1.5 py-0.5 rounded-full" style={{ color: '#fca17d' }}>
|
||||||
|
{info.type || 'user'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="https://replicate.com/billing"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center justify-center gap-1 text-xs text-apricot/40 hover:text-tangerine transition-colors pt-1 border-t border-grape/15"
|
||||||
|
>
|
||||||
|
View billing & usage <ExternalLink size={10} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
170
frontend/src/components/ChatArea.jsx
Normal file
170
frontend/src/components/ChatArea.jsx
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Sparkles, Trash2, Download } from 'lucide-react';
|
||||||
|
import { useStore } from '../store/useStore.js';
|
||||||
|
import MessageItem from './MessageItem.jsx';
|
||||||
|
import MentionInput from './MentionInput.jsx';
|
||||||
|
|
||||||
|
function EmptyState({ models, onCreate }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-center px-6 py-16 animate-fade-in">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="relative mb-8">
|
||||||
|
<div className="w-20 h-20 rounded-3xl bg-brand-gradient-r flex items-center justify-center text-4xl shadow-grape glow-grape">
|
||||||
|
🤖
|
||||||
|
</div>
|
||||||
|
<div className="absolute -bottom-1 -right-1 w-6 h-6 bg-tangerine rounded-full flex items-center justify-center">
|
||||||
|
<Sparkles size={12} className="text-midnight" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="font-display font-bold text-3xl text-apricot mb-2">
|
||||||
|
Welcome to <span className="text-gradient">IQAI</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-apricot/50 text-sm max-w-md mb-8 leading-relaxed">
|
||||||
|
Your multi-model AI dashboard. Chat with multiple AI models simultaneously, tag them with @mentions, and augment with live web search.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Model grid */}
|
||||||
|
{models.length > 0 && (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-8 w-full max-w-lg">
|
||||||
|
{models.slice(0, 4).map(m => (
|
||||||
|
<div
|
||||||
|
key={m.id}
|
||||||
|
className="flex flex-col items-center gap-2 p-3 rounded-xl border bg-surface-2 border-grape/20"
|
||||||
|
>
|
||||||
|
<span className="text-2xl">{m.avatar}</span>
|
||||||
|
<span className="text-xs font-medium font-display" style={{ color: m.color }}>@{m.tag}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 text-xs text-apricot/30 max-w-xs">
|
||||||
|
<p>• Type <code className="bg-surface-2 px-1 py-0.5 rounded text-tangerine">@claude</code> to direct a message to a specific model</p>
|
||||||
|
<p>• Enable <strong className="text-apricot/50">Web Search</strong> to inject live results as context</p>
|
||||||
|
<p>• Select models from the sidebar to run queries in parallel</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChatArea() {
|
||||||
|
const {
|
||||||
|
getActiveConv, activeConvId, sendMessage,
|
||||||
|
models, createConversation, clearConversation,
|
||||||
|
setSettingsPanelModel
|
||||||
|
} = useStore();
|
||||||
|
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const bottomRef = useRef(null);
|
||||||
|
const conv = getActiveConv();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [conv?.messages?.length, sending]);
|
||||||
|
|
||||||
|
const handleSend = async (text) => {
|
||||||
|
setError('');
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
await sendMessage(text);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportConv = () => {
|
||||||
|
if (!conv) return;
|
||||||
|
const text = conv.messages.map(m => {
|
||||||
|
const who = m.role === 'user' ? 'You' : (models.find(md => md.id === m.modelId)?.displayName || m.modelId);
|
||||||
|
return `[${who}]\n${m.content}`;
|
||||||
|
}).join('\n\n---\n\n');
|
||||||
|
const blob = new Blob([text], { type: 'text/plain' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${conv.title.slice(0, 30)}.txt`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col flex-1 min-w-0 h-full">
|
||||||
|
{/* Top bar */}
|
||||||
|
{conv && (
|
||||||
|
<div className="flex items-center justify-between px-6 py-3 border-b border-grape/15 flex-shrink-0">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<h2 className="font-display font-semibold text-apricot/80 truncate text-sm">
|
||||||
|
{conv.title}
|
||||||
|
</h2>
|
||||||
|
{/* Active model tags */}
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
{conv.activeModelIds.map(id => {
|
||||||
|
const m = models.find(md => md.id === id);
|
||||||
|
if (!m) return null;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
onClick={() => setSettingsPanelModel(m)}
|
||||||
|
title={`System instructions for ${m.displayName}`}
|
||||||
|
className="flex items-center gap-1 text-xs px-2 py-0.5 rounded-full border hover:opacity-100 transition-opacity"
|
||||||
|
style={{ color: m.color, background: `${m.color}15`, borderColor: `${m.color}30` }}
|
||||||
|
>
|
||||||
|
{m.avatar} {m.tag}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={exportConv}
|
||||||
|
className="p-2 rounded-lg hover:bg-grape/20 text-apricot/30 hover:text-apricot/60 transition-colors"
|
||||||
|
title="Export conversation"
|
||||||
|
>
|
||||||
|
<Download size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => clearConversation(activeConvId)}
|
||||||
|
className="p-2 rounded-lg hover:bg-blush/10 text-apricot/30 hover:text-blush transition-colors"
|
||||||
|
title="Clear conversation"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 sm:px-8 py-6 space-y-6">
|
||||||
|
{!conv || conv.messages.length === 0 ? (
|
||||||
|
<EmptyState models={models} onCreate={createConversation} />
|
||||||
|
) : (
|
||||||
|
conv.messages.map(msg => (
|
||||||
|
<MessageItem key={msg.id} message={msg} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error banner */}
|
||||||
|
{error && (
|
||||||
|
<div className="mx-6 mb-2 bg-blush/10 border border-blush/30 rounded-xl px-4 py-2.5 text-sm text-blush animate-slide-up flex items-center justify-between">
|
||||||
|
<span>{error}</span>
|
||||||
|
<button onClick={() => setError('')} className="text-blush/60 hover:text-blush ml-3">✕</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<div className="px-4 sm:px-8 pb-6 pt-2 flex-shrink-0">
|
||||||
|
<MentionInput onSend={handleSend} sending={sending} />
|
||||||
|
<p className="text-center text-xs text-apricot/20 mt-2">
|
||||||
|
IQAI uses Replicate API · Models may make mistakes · Verify important information
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
143
frontend/src/components/MentionInput.jsx
Normal file
143
frontend/src/components/MentionInput.jsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import React, { useState, useRef } from 'react';
|
||||||
|
import { Send, Search, Loader2 } from 'lucide-react';
|
||||||
|
import { useStore } from '../store/useStore.js';
|
||||||
|
|
||||||
|
export default function MentionInput({ onSend, sending }) {
|
||||||
|
const { models, webSearchEnabled, toggleWebSearch } = useStore();
|
||||||
|
const [value, setValue] = useState('');
|
||||||
|
const [mentionState, setMentionState] = useState(null);
|
||||||
|
const textareaRef = useRef(null);
|
||||||
|
|
||||||
|
const filteredModels = mentionState
|
||||||
|
? models.filter(m => m.tag.toLowerCase().startsWith(mentionState.query.toLowerCase()))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const handleChange = (e) => {
|
||||||
|
const text = e.target.value;
|
||||||
|
setValue(text);
|
||||||
|
const pos = e.target.selectionStart;
|
||||||
|
const before = text.slice(0, pos);
|
||||||
|
const match = before.match(/@(\w*)$/);
|
||||||
|
if (match) {
|
||||||
|
setMentionState({ start: match.index, query: match[1] });
|
||||||
|
} else {
|
||||||
|
setMentionState(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const insertMention = (tag) => {
|
||||||
|
if (!mentionState) return;
|
||||||
|
const before = value.slice(0, mentionState.start);
|
||||||
|
const after = value.slice(mentionState.start + 1 + mentionState.query.length);
|
||||||
|
const newVal = `${before}@${tag} ${after}`;
|
||||||
|
setValue(newVal);
|
||||||
|
setMentionState(null);
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (mentionState && filteredModels.length > 0 && e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
insertMention(filteredModels[0].tag);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mentionState && e.key === 'Escape') { setMentionState(null); return; }
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submit(); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
const text = value.trim();
|
||||||
|
if (!text || sending) return;
|
||||||
|
setValue('');
|
||||||
|
setMentionState(null);
|
||||||
|
onSend(text);
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeTags = models.filter(m => new RegExp(`@${m.tag}\\b`, 'i').test(value));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
{/* Mention dropdown */}
|
||||||
|
{mentionState && filteredModels.length > 0 && (
|
||||||
|
<div className="absolute bottom-full mb-2 left-0 bg-surface-2 border border-grape/30 rounded-2xl shadow-2xl overflow-hidden z-50 min-w-[220px] animate-slide-up glow-grape">
|
||||||
|
<div className="px-3 py-2 text-xs text-apricot/30 border-b border-grape/15 font-medium">
|
||||||
|
Tag a model
|
||||||
|
</div>
|
||||||
|
{filteredModels.map((model) => (
|
||||||
|
<button
|
||||||
|
key={model.id}
|
||||||
|
onMouseDown={(e) => { e.preventDefault(); insertMention(model.tag); }}
|
||||||
|
className="flex items-center gap-3 w-full px-3 py-3 hover:bg-grape/15 transition-colors text-left"
|
||||||
|
>
|
||||||
|
<span className="text-xl">{model.avatar}</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm text-apricot/90 font-medium">@{model.tag}</div>
|
||||||
|
<div className="text-xs text-apricot/40">{model.displayName}</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: model.color }} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Input container */}
|
||||||
|
<div className="bg-surface-2 border border-grape/25 rounded-2xl overflow-hidden focus-within:border-grape/50 transition-all focus-within:glow-grape">
|
||||||
|
{/* Active model chips */}
|
||||||
|
{activeTags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 px-4 pt-3">
|
||||||
|
{activeTags.map(m => (
|
||||||
|
<span
|
||||||
|
key={m.id}
|
||||||
|
className="flex items-center gap-1 text-xs px-2 py-0.5 rounded-full border font-medium font-display"
|
||||||
|
style={{ color: m.color, background: `${m.color}18`, borderColor: `${m.color}35` }}
|
||||||
|
>
|
||||||
|
{m.avatar} @{m.tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Message… (type @ to mention a model · Shift+Enter for newline)"
|
||||||
|
className="w-full bg-transparent px-4 pt-3 pb-2 text-sm text-apricot/85 placeholder-apricot/20 resize-none outline-none leading-relaxed"
|
||||||
|
rows={1}
|
||||||
|
disabled={sending}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between px-3 pb-3 pt-1">
|
||||||
|
<button
|
||||||
|
onClick={toggleWebSearch}
|
||||||
|
title="Toggle web search (injects DuckDuckGo results as context)"
|
||||||
|
className={`flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-xl border transition-all ${
|
||||||
|
webSearchEnabled
|
||||||
|
? 'bg-tangerine/20 border-tangerine/40 text-tangerine'
|
||||||
|
: 'border-grape/20 text-apricot/30 hover:text-apricot/60 hover:border-grape/40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Search size={12} />
|
||||||
|
Web Search
|
||||||
|
{webSearchEnabled && <span className="w-1.5 h-1.5 rounded-full bg-tangerine animate-pulse" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={submit}
|
||||||
|
disabled={!value.trim() || sending}
|
||||||
|
className="flex items-center gap-2 text-midnight text-sm px-4 py-1.5 rounded-xl transition-all font-semibold font-display disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
style={{
|
||||||
|
background: value.trim() && !sending
|
||||||
|
? 'linear-gradient(90deg, #da627d, #9a348e)'
|
||||||
|
: '#9a348e40'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sending ? <Loader2 size={14} className="animate-spin" /> : <Send size={14} />}
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
148
frontend/src/components/MessageItem.jsx
Normal file
148
frontend/src/components/MessageItem.jsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
|
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||||
|
import { Copy, Check, User, AlertCircle, Timer } from 'lucide-react';
|
||||||
|
import { useStore } from '../store/useStore.js';
|
||||||
|
|
||||||
|
function CopyButton({ text, size = 13 }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const copy = () => {
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={copy}
|
||||||
|
className="p-1.5 rounded-lg text-apricot/30 hover:text-apricot/70 hover:bg-grape/20 transition-colors"
|
||||||
|
>
|
||||||
|
{copied ? <Check size={size} className="text-tangerine" /> : <Copy size={size} />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CodeBlock = ({ children, className }) => {
|
||||||
|
const language = className?.replace('language-', '') || 'text';
|
||||||
|
const code = String(children).replace(/\n$/, '');
|
||||||
|
return (
|
||||||
|
<div className="relative group my-3">
|
||||||
|
<div className="absolute right-2 top-2 opacity-0 group-hover:opacity-100 transition-opacity z-10">
|
||||||
|
<CopyButton text={code} />
|
||||||
|
</div>
|
||||||
|
<div className="absolute left-3 top-2 text-xs text-apricot/30 font-mono">{language}</div>
|
||||||
|
<SyntaxHighlighter
|
||||||
|
style={{
|
||||||
|
...oneDark,
|
||||||
|
'pre[class*="language-"]': { background: '#110830', margin: 0 },
|
||||||
|
'code[class*="language-"]': { background: 'none' }
|
||||||
|
}}
|
||||||
|
language={language}
|
||||||
|
PreTag="div"
|
||||||
|
customStyle={{
|
||||||
|
margin: 0,
|
||||||
|
borderRadius: '10px',
|
||||||
|
paddingTop: '2rem',
|
||||||
|
background: '#110830',
|
||||||
|
border: '1px solid #9a348e30',
|
||||||
|
fontSize: '0.84rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{code}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MessageItem({ message }) {
|
||||||
|
const { models } = useStore();
|
||||||
|
const model = message.modelId ? models.find(m => m.id === message.modelId) : null;
|
||||||
|
|
||||||
|
if (message.role === 'user') {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-3 justify-end animate-fade-in">
|
||||||
|
<div className="max-w-[78%]">
|
||||||
|
<div className="bg-grape/20 border border-grape/30 rounded-2xl rounded-tr-sm px-4 py-3">
|
||||||
|
<p className="text-apricot/90 text-sm leading-relaxed whitespace-pre-wrap">{message.content}</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-apricot/20 mt-1 text-right">
|
||||||
|
{new Date(message.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-8 h-8 rounded-full bg-blush/20 border border-blush/30 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||||
|
<User size={14} className="text-blush" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assistant message
|
||||||
|
const modelColor = model?.color || '#9a348e';
|
||||||
|
const modelAvatar = model?.avatar || '🤖';
|
||||||
|
const modelName = model?.displayName || message.modelId || 'AI';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-3 animate-slide-up">
|
||||||
|
<div
|
||||||
|
className="w-8 h-8 rounded-xl flex items-center justify-center flex-shrink-0 mt-0.5 text-base border"
|
||||||
|
style={{ background: `${modelColor}18`, borderColor: `${modelColor}35` }}
|
||||||
|
>
|
||||||
|
{modelAvatar}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{/* Model name + metrics */}
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-xs font-semibold font-display" style={{ color: modelColor }}>
|
||||||
|
{modelName}
|
||||||
|
</span>
|
||||||
|
{message.metrics?.predict_time && (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-apricot/25">
|
||||||
|
<Timer size={10} />
|
||||||
|
{message.metrics.predict_time.toFixed(1)}s
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{message.pending ? (
|
||||||
|
<div className="flex items-center gap-1.5 h-6" style={{ color: modelColor }}>
|
||||||
|
<span className="typing-dot" />
|
||||||
|
<span className="typing-dot" />
|
||||||
|
<span className="typing-dot" />
|
||||||
|
</div>
|
||||||
|
) : message.error ? (
|
||||||
|
<div className="flex items-center gap-2 text-blush bg-blush/10 border border-blush/25 rounded-xl px-3 py-2.5">
|
||||||
|
<AlertCircle size={14} />
|
||||||
|
<span className="text-sm">{message.content}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="prose-dark text-sm">
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
components={{
|
||||||
|
code({ node, inline, className, children, ...props }) {
|
||||||
|
if (inline) return <code className={className} {...props}>{children}</code>;
|
||||||
|
return <CodeBlock className={className}>{children}</CodeBlock>;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{message.content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
{!message.pending && !message.error && (
|
||||||
|
<div className="flex items-center gap-1 mt-2">
|
||||||
|
<CopyButton text={message.content} />
|
||||||
|
<span className="text-xs text-apricot/20">
|
||||||
|
{new Date(message.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
264
frontend/src/components/ModelManager.jsx
Normal file
264
frontend/src/components/ModelManager.jsx
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { X, Plus, Trash2, Edit2, Check, AlertCircle, Cpu, ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
|
import { useStore } from '../store/useStore.js';
|
||||||
|
|
||||||
|
const DEFAULT_FORM = {
|
||||||
|
tag: '', displayName: '', owner: '', name: '',
|
||||||
|
type: 'text', avatar: '🤖', color: '#9a348e',
|
||||||
|
description: '', systemPromptParam: 'system_prompt',
|
||||||
|
defaultInput: '{}'
|
||||||
|
};
|
||||||
|
|
||||||
|
function ModelCard({ model, onDelete, onEdit }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 p-3 bg-surface-3 rounded-xl border border-grape/15 group hover:border-grape/30 transition-all">
|
||||||
|
<div
|
||||||
|
className="w-10 h-10 rounded-xl flex items-center justify-center text-xl border flex-shrink-0"
|
||||||
|
style={{ background: `${model.color}18`, borderColor: `${model.color}35` }}
|
||||||
|
>
|
||||||
|
{model.avatar}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold text-apricot/90 font-display">{model.displayName}</span>
|
||||||
|
<span className="text-xs text-apricot/35 font-mono">@{model.tag}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-apricot/35 truncate">
|
||||||
|
{model.owner}/{model.name}
|
||||||
|
</div>
|
||||||
|
{model.description && (
|
||||||
|
<div className="text-xs text-apricot/25 truncate mt-0.5">{model.description}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
onClick={() => onEdit(model)}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-grape/20 text-apricot/30 hover:text-apricot/70 transition-colors"
|
||||||
|
>
|
||||||
|
<Edit2 size={13} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(model.id)}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-blush/15 text-apricot/30 hover:text-blush transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddModelForm({ onClose, editModel }) {
|
||||||
|
const { addModel, updateModel } = useStore();
|
||||||
|
const [form, setForm] = useState(editModel
|
||||||
|
? { ...editModel, defaultInput: JSON.stringify(editModel.defaultInput || {}, null, 2) }
|
||||||
|
: DEFAULT_FORM
|
||||||
|
);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
|
|
||||||
|
const setF = (key, val) => setForm(f => ({ ...f, [key]: val }));
|
||||||
|
|
||||||
|
const handleUrlParse = (url) => {
|
||||||
|
const match = url.match(/models\/([^/]+)\/([^/]+)/);
|
||||||
|
if (match) {
|
||||||
|
setF('owner', match[1]);
|
||||||
|
setF('name', match[2]);
|
||||||
|
if (!form.tag) setF('tag', match[2].split(/[-_.]/)[0].toLowerCase().replace(/[^a-z0-9]/g, ''));
|
||||||
|
if (!form.displayName) setF('displayName', `${match[1]}/${match[2]}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
let parsedInput = {};
|
||||||
|
try { parsedInput = JSON.parse(form.defaultInput || '{}'); }
|
||||||
|
catch (_) { throw new Error('Invalid JSON in Default Input field'); }
|
||||||
|
|
||||||
|
const payload = { ...form, defaultInput: parsedInput };
|
||||||
|
if (editModel) await updateModel(editModel.id, payload);
|
||||||
|
else await addModel(payload);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputClass = "w-full bg-surface-3 border border-grape/20 focus:border-grape/50 rounded-xl px-3 py-2.5 text-sm text-apricot/80 placeholder-apricot/20 focus:outline-none transition-colors";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-apricot/40 mb-1.5 font-medium">Paste Replicate URL or API endpoint</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="https://api.replicate.com/v1/models/owner/model-name/predictions"
|
||||||
|
className={inputClass}
|
||||||
|
onChange={e => handleUrlParse(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-apricot/25 mt-1">Auto-fills Owner & Model Name fields</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-apricot/40 mb-1.5 font-medium">Owner *</label>
|
||||||
|
<input required value={form.owner} onChange={e => setF('owner', e.target.value)} placeholder="anthropic" className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-apricot/40 mb-1.5 font-medium">Model Name *</label>
|
||||||
|
<input required value={form.name} onChange={e => setF('name', e.target.value)} placeholder="claude-opus-4.6" className={inputClass} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-apricot/40 mb-1.5 font-medium">Display Name *</label>
|
||||||
|
<input required value={form.displayName} onChange={e => setF('displayName', e.target.value)} placeholder="Claude Opus 4.6" className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-apricot/40 mb-1.5 font-medium">@Tag * (no spaces)</label>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={form.tag}
|
||||||
|
onChange={e => setF('tag', e.target.value.replace(/[^a-z0-9]/g, ''))}
|
||||||
|
placeholder="claude"
|
||||||
|
className={`${inputClass} font-mono`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-apricot/40 mb-1.5 font-medium">Avatar Emoji</label>
|
||||||
|
<input value={form.avatar} onChange={e => setF('avatar', e.target.value)} placeholder="🤖" className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-apricot/40 mb-1.5 font-medium">Accent Color</label>
|
||||||
|
<input type="color" value={form.color} onChange={e => setF('color', e.target.value)}
|
||||||
|
className="w-full h-[42px] bg-surface-3 border border-grape/20 rounded-xl px-1.5 focus:outline-none cursor-pointer" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-apricot/40 mb-1.5 font-medium">Type</label>
|
||||||
|
<select value={form.type} onChange={e => setF('type', e.target.value)}
|
||||||
|
className={inputClass}>
|
||||||
|
<option value="text">Text</option>
|
||||||
|
<option value="image">Image</option>
|
||||||
|
<option value="video">Video</option>
|
||||||
|
<option value="audio">Audio</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" onClick={() => setShowAdvanced(!showAdvanced)}
|
||||||
|
className="flex items-center gap-1.5 text-xs text-apricot/35 hover:text-apricot/60 transition-colors">
|
||||||
|
{showAdvanced ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||||
|
Advanced Options
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showAdvanced && (
|
||||||
|
<div className="space-y-3 border-t border-grape/15 pt-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-apricot/40 mb-1.5 font-medium">System Prompt Parameter</label>
|
||||||
|
<input value={form.systemPromptParam || ''} onChange={e => setF('systemPromptParam', e.target.value || null)}
|
||||||
|
placeholder="system_prompt (leave empty if not supported)" className={`${inputClass} font-mono`} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-apricot/40 mb-1.5 font-medium">Default Input JSON</label>
|
||||||
|
<textarea value={form.defaultInput} onChange={e => setF('defaultInput', e.target.value)}
|
||||||
|
rows={4} className={`${inputClass} font-mono resize-none`} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-apricot/40 mb-1.5 font-medium">Description</label>
|
||||||
|
<input value={form.description} onChange={e => setF('description', e.target.value)}
|
||||||
|
placeholder="Brief description" className={inputClass} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 text-blush bg-blush/10 border border-blush/25 rounded-xl px-3 py-2.5 text-sm">
|
||||||
|
<AlertCircle size={14} />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-1">
|
||||||
|
<button type="button" onClick={onClose}
|
||||||
|
className="flex-1 border border-grape/20 text-apricot/40 hover:text-apricot/70 hover:border-grape/40 rounded-xl py-2.5 text-sm transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" disabled={saving}
|
||||||
|
className="flex-1 text-midnight rounded-xl py-2.5 text-sm font-semibold font-display transition-all flex items-center justify-center gap-2 disabled:opacity-50"
|
||||||
|
style={{ background: 'linear-gradient(90deg, #da627d, #9a348e)' }}>
|
||||||
|
{saving ? 'Saving…' : <><Check size={14} /> {editModel ? 'Update Model' : 'Add Model'}</>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ModelManager() {
|
||||||
|
const { models, deleteModel, modelManagerOpen, setModelManagerOpen } = useStore();
|
||||||
|
const [showAdd, setShowAdd] = useState(false);
|
||||||
|
const [editModel, setEditModel] = useState(null);
|
||||||
|
|
||||||
|
if (!modelManagerOpen) return null;
|
||||||
|
|
||||||
|
const handleDelete = async (id) => {
|
||||||
|
if (confirm('Remove this model from the list?')) await deleteModel(id);
|
||||||
|
};
|
||||||
|
const handleEdit = (model) => { setEditModel(model); setShowAdd(true); };
|
||||||
|
const closeForm = () => { setShowAdd(false); setEditModel(null); };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-midnight/75 backdrop-blur-sm z-50 flex items-center justify-center p-4 animate-fade-in">
|
||||||
|
<div className="bg-surface-1 border border-grape/25 rounded-2xl w-full max-w-xl max-h-[85vh] flex flex-col shadow-2xl glow-grape">
|
||||||
|
<div className="flex items-center justify-between p-5 border-b border-grape/15">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="w-8 h-8 rounded-xl bg-grape/20 border border-grape/30 flex items-center justify-center">
|
||||||
|
<Cpu size={15} className="text-tangerine" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-bold text-apricot font-display">Model Manager</h2>
|
||||||
|
<p className="text-xs text-apricot/30">{models.length} model{models.length !== 1 ? 's' : ''} configured</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setModelManagerOpen(false)} className="p-1.5 rounded-lg hover:bg-grape/20 text-apricot/40 hover:text-apricot transition-colors">
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-5 space-y-3">
|
||||||
|
{showAdd ? (
|
||||||
|
<AddModelForm onClose={closeForm} editModel={editModel} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{models.length === 0 ? (
|
||||||
|
<div className="text-center py-10 text-apricot/30">
|
||||||
|
<Cpu size={36} className="mx-auto mb-3 opacity-20" />
|
||||||
|
<p className="text-sm">No models configured.</p>
|
||||||
|
<p className="text-xs mt-1 text-apricot/20">Add your first Replicate model below.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
models.map(m => <ModelCard key={m.id} model={m} onDelete={handleDelete} onEdit={handleEdit} />)
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAdd(true)}
|
||||||
|
className="flex items-center justify-center gap-2 w-full border border-dashed border-grape/20 hover:border-grape/40 hover:bg-grape/5 rounded-xl py-3.5 text-sm text-apricot/35 hover:text-tangerine transition-all"
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
Add New Model
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
158
frontend/src/components/Sidebar.jsx
Normal file
158
frontend/src/components/Sidebar.jsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Plus, MessageSquare, Trash2, Settings, Cpu, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import { useStore } from '../store/useStore.js';
|
||||||
|
import BalanceDisplay from './BalanceDisplay.jsx';
|
||||||
|
|
||||||
|
export default function Sidebar() {
|
||||||
|
const {
|
||||||
|
conversations, activeConvId, sidebarOpen,
|
||||||
|
createConversation, selectConversation, deleteConversation,
|
||||||
|
toggleSidebar, setModelManagerOpen, models
|
||||||
|
} = useStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Collapsed toggle */}
|
||||||
|
{!sidebarOpen && (
|
||||||
|
<button
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
className="fixed left-0 top-1/2 -translate-y-1/2 z-30 bg-surface-2 border border-grape/30 rounded-r-xl p-2 text-apricot/50 hover:text-apricot transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronRight size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className={`
|
||||||
|
flex flex-col bg-surface-1 border-r border-grape/20 transition-all duration-300 flex-shrink-0
|
||||||
|
${sidebarOpen ? 'w-72' : 'w-0 overflow-hidden'}
|
||||||
|
`}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-4 border-b border-grape/15">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 rounded-xl bg-brand-gradient-r flex items-center justify-center text-midnight text-base font-black font-display">
|
||||||
|
IQ
|
||||||
|
</div>
|
||||||
|
<span className="font-display font-bold text-apricot text-lg tracking-tight">IQAI</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-grape/20 text-apricot/40 hover:text-apricot/80 transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Balance */}
|
||||||
|
<div className="px-3 py-3 border-b border-grape/15">
|
||||||
|
<BalanceDisplay />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* New Chat */}
|
||||||
|
<div className="px-3 py-2">
|
||||||
|
<button
|
||||||
|
onClick={createConversation}
|
||||||
|
className="flex items-center gap-2 w-full bg-grape/20 hover:bg-grape/30 border border-grape/30 hover:border-grape/50 rounded-xl px-3 py-2.5 text-sm text-apricot/80 hover:text-apricot transition-all font-medium"
|
||||||
|
>
|
||||||
|
<Plus size={15} />
|
||||||
|
New Chat
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Conversations */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-2 pb-2 space-y-0.5">
|
||||||
|
{conversations.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-apricot/25 text-xs px-4">
|
||||||
|
<MessageSquare size={24} className="mx-auto mb-2 opacity-50" />
|
||||||
|
No conversations yet
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
conversations.map(conv => (
|
||||||
|
<ConvItem
|
||||||
|
key={conv.id}
|
||||||
|
conv={conv}
|
||||||
|
active={conv.id === activeConvId}
|
||||||
|
onSelect={selectConversation}
|
||||||
|
onDelete={deleteConversation}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom section */}
|
||||||
|
<div className="border-t border-grape/15 p-3 space-y-1">
|
||||||
|
{/* Active models chips */}
|
||||||
|
<ActiveModels />
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setModelManagerOpen(true)}
|
||||||
|
className="flex items-center gap-2 w-full px-3 py-2.5 rounded-xl text-sm text-apricot/60 hover:text-apricot hover:bg-grape/15 transition-colors"
|
||||||
|
>
|
||||||
|
<Cpu size={14} />
|
||||||
|
Manage Models
|
||||||
|
<span className="ml-auto text-xs bg-grape/30 text-apricot/60 px-1.5 py-0.5 rounded-full">
|
||||||
|
{models.length}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConvItem({ conv, active, onSelect, onDelete }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`group flex items-center gap-2 px-3 py-2.5 rounded-xl cursor-pointer transition-all ${
|
||||||
|
active
|
||||||
|
? 'bg-grape/25 border border-grape/40'
|
||||||
|
: 'hover:bg-grape/10 border border-transparent'
|
||||||
|
}`}
|
||||||
|
onClick={() => onSelect(conv.id)}
|
||||||
|
>
|
||||||
|
<MessageSquare size={13} className={active ? 'text-blush' : 'text-apricot/30'} />
|
||||||
|
<span className="flex-1 text-sm truncate text-apricot/80 group-hover:text-apricot transition-colors">
|
||||||
|
{conv.title}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onDelete(conv.id); }}
|
||||||
|
className="opacity-0 group-hover:opacity-100 p-1 rounded-lg hover:bg-blush/20 text-apricot/30 hover:text-blush transition-all"
|
||||||
|
>
|
||||||
|
<Trash2 size={11} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActiveModels() {
|
||||||
|
const { models, getActiveConv, toggleModel, activeConvId } = useStore();
|
||||||
|
const conv = getActiveConv();
|
||||||
|
if (!conv || models.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-1 pb-1">
|
||||||
|
<p className="text-xs text-apricot/30 font-medium px-2 mb-1.5">Active Models</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{models.filter(m => m.type === 'text').map(model => {
|
||||||
|
const active = conv.activeModelIds.includes(model.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={model.id}
|
||||||
|
onClick={() => toggleModel(activeConvId, model.id)}
|
||||||
|
title={model.displayName}
|
||||||
|
className={`flex items-center gap-1 text-xs px-2 py-1 rounded-lg border transition-all ${
|
||||||
|
active
|
||||||
|
? 'border-current opacity-100'
|
||||||
|
: 'border-white/10 opacity-40 hover:opacity-70'
|
||||||
|
}`}
|
||||||
|
style={active ? { color: model.color, background: `${model.color}20`, borderColor: `${model.color}40` } : {}}
|
||||||
|
>
|
||||||
|
<span>{model.avatar}</span>
|
||||||
|
<span>@{model.tag}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
frontend/src/components/SystemPromptPanel.jsx
Normal file
90
frontend/src/components/SystemPromptPanel.jsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { X, Save, BookOpen } from 'lucide-react';
|
||||||
|
import { useStore } from '../store/useStore.js';
|
||||||
|
|
||||||
|
export default function SystemPromptPanel() {
|
||||||
|
const { settingsPanelModel, setSettingsPanelModel, getActiveConv, setSystemPrompt, activeConvId } = useStore();
|
||||||
|
const conv = getActiveConv();
|
||||||
|
|
||||||
|
const model = settingsPanelModel;
|
||||||
|
if (!model) return null;
|
||||||
|
|
||||||
|
const current = conv?.systemPrompts?.[model.id] || '';
|
||||||
|
const [draft, setDraft] = useState(current);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
if (activeConvId) {
|
||||||
|
setSystemPrompt(activeConvId, model.id, draft);
|
||||||
|
setSaved(true);
|
||||||
|
setTimeout(() => setSaved(false), 1500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-midnight/70 backdrop-blur-sm z-50 flex items-center justify-center p-4 animate-fade-in">
|
||||||
|
<div className="bg-surface-1 border rounded-2xl w-full max-w-lg shadow-2xl glow-grape"
|
||||||
|
style={{ borderColor: `${model.color}30` }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-5 border-b" style={{ borderColor: `${model.color}20` }}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="w-9 h-9 rounded-xl flex items-center justify-center text-xl border"
|
||||||
|
style={{ background: `${model.color}20`, borderColor: `${model.color}40` }}
|
||||||
|
>
|
||||||
|
{model.avatar}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-apricot font-display">{model.displayName}</h3>
|
||||||
|
<p className="text-xs text-apricot/40">System Instructions</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setSettingsPanelModel(null)}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-grape/20 text-apricot/40 hover:text-apricot transition-colors"
|
||||||
|
>
|
||||||
|
<X size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5 space-y-4">
|
||||||
|
<div className="flex items-start gap-2 bg-grape/10 border border-grape/20 rounded-xl px-3 py-2.5">
|
||||||
|
<BookOpen size={13} className="text-tangerine mt-0.5 flex-shrink-0" />
|
||||||
|
<p className="text-xs text-apricot/60 leading-relaxed">
|
||||||
|
System instructions guide the model's personality, tone, and behavior. They persist across all messages in this conversation for <span style={{ color: model.color }}>@{model.tag}</span>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
value={draft}
|
||||||
|
onChange={e => setDraft(e.target.value)}
|
||||||
|
placeholder={`You are ${model.displayName}. You are helpful, harmless, and honest...`}
|
||||||
|
rows={8}
|
||||||
|
className="w-full bg-surface-2 border border-grape/20 focus:border-grape/50 rounded-xl px-4 py-3 text-sm text-apricot/80 placeholder-apricot/20 focus:outline-none resize-none leading-relaxed transition-colors"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => { setDraft(''); setSystemPrompt(activeConvId, model.id, ''); }}
|
||||||
|
className="px-4 py-2 border border-grape/20 rounded-lg text-sm text-apricot/50 hover:text-apricot hover:border-grape/40 transition-colors"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={save}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 py-2 rounded-lg text-sm font-medium transition-all"
|
||||||
|
style={{
|
||||||
|
background: saved ? '#9a348e30' : `${model.color}30`,
|
||||||
|
border: `1px solid ${model.color}50`,
|
||||||
|
color: saved ? '#9a348e' : model.color
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Save size={13} />
|
||||||
|
{saved ? 'Saved!' : 'Save Instructions'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
152
frontend/src/index.css
Normal file
152
frontend/src/index.css
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700;800&family=Poppins:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
|
||||||
|
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--soft-apricot: #f9dbbd;
|
||||||
|
--tangerine-dream: #fca17d;
|
||||||
|
--blush-rose: #da627d;
|
||||||
|
--grape-soda: #9a348e;
|
||||||
|
--midnight-violet: #0d0628;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body, #root {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--midnight-violet);
|
||||||
|
color: var(--soft-apricot);
|
||||||
|
font-family: 'Poppins', 'Inter', sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar */
|
||||||
|
::-webkit-scrollbar { width: 5px; height: 5px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: #9a348e50; border-radius: 3px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: #9a348e90; }
|
||||||
|
|
||||||
|
/* Selection */
|
||||||
|
::selection { background: #9a348e50; color: #f9dbbd; }
|
||||||
|
|
||||||
|
/* --- Prose/Markdown styles --- */
|
||||||
|
.prose-dark {
|
||||||
|
color: #e8d5c8;
|
||||||
|
line-height: 1.75;
|
||||||
|
}
|
||||||
|
.prose-dark p { margin: 0.55em 0; }
|
||||||
|
.prose-dark p:first-child { margin-top: 0; }
|
||||||
|
.prose-dark p:last-child { margin-bottom: 0; }
|
||||||
|
.prose-dark h1, .prose-dark h2, .prose-dark h3, .prose-dark h4 {
|
||||||
|
font-family: 'Montserrat', sans-serif;
|
||||||
|
color: #f9dbbd;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 1em 0 0.4em;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.prose-dark h1 { font-size: 1.4em; }
|
||||||
|
.prose-dark h2 { font-size: 1.22em; }
|
||||||
|
.prose-dark h3 { font-size: 1.08em; }
|
||||||
|
.prose-dark ul, .prose-dark ol { padding-left: 1.4em; margin: 0.5em 0; }
|
||||||
|
.prose-dark li { margin: 0.25em 0; }
|
||||||
|
.prose-dark code {
|
||||||
|
background: #1e1150;
|
||||||
|
color: #fca17d;
|
||||||
|
padding: 0.15em 0.4em;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.87em;
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
|
border: 1px solid #9a348e30;
|
||||||
|
}
|
||||||
|
.prose-dark pre {
|
||||||
|
background: #110830 !important;
|
||||||
|
border: 1px solid #9a348e30;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin: 0.75em 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.prose-dark pre code {
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
font-size: 0.86em;
|
||||||
|
}
|
||||||
|
.prose-dark blockquote {
|
||||||
|
border-left: 3px solid #9a348e;
|
||||||
|
padding-left: 1em;
|
||||||
|
color: #c4a490;
|
||||||
|
margin: 0.75em 0;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.prose-dark table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 0.75em 0;
|
||||||
|
font-size: 0.88em;
|
||||||
|
}
|
||||||
|
.prose-dark th, .prose-dark td {
|
||||||
|
border: 1px solid #9a348e30;
|
||||||
|
padding: 0.5em 0.75em;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.prose-dark th {
|
||||||
|
background: #1e1150;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #f9dbbd;
|
||||||
|
font-family: 'Montserrat', sans-serif;
|
||||||
|
}
|
||||||
|
.prose-dark tr:nth-child(even) td { background: #140a35; }
|
||||||
|
.prose-dark a { color: #da627d; text-decoration: underline; }
|
||||||
|
.prose-dark a:hover { color: #fca17d; }
|
||||||
|
.prose-dark hr { border-color: #9a348e30; margin: 1em 0; }
|
||||||
|
.prose-dark strong { color: #f9dbbd; font-weight: 600; }
|
||||||
|
|
||||||
|
/* Typing indicator */
|
||||||
|
.typing-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
animation: typingBounce 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.typing-dot:nth-child(2) { animation-delay: 0.2s; }
|
||||||
|
.typing-dot:nth-child(3) { animation-delay: 0.4s; }
|
||||||
|
@keyframes typingBounce {
|
||||||
|
0%, 60%, 100% { transform: translateY(0); opacity: 0.35; }
|
||||||
|
30% { transform: translateY(-6px); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gradient text */
|
||||||
|
.text-gradient {
|
||||||
|
background: linear-gradient(90deg, #fca17d, #da627d, #9a348e);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glow effects */
|
||||||
|
.glow-grape { box-shadow: 0 0 20px #9a348e40; }
|
||||||
|
.glow-blush { box-shadow: 0 0 20px #da627d30; }
|
||||||
|
|
||||||
|
/* Textarea auto-resize */
|
||||||
|
textarea {
|
||||||
|
field-sizing: content;
|
||||||
|
min-height: 44px;
|
||||||
|
max-height: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glass card */
|
||||||
|
.glass {
|
||||||
|
background: rgba(26, 13, 64, 0.6);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
10
frontend/src/main.jsx
Normal file
10
frontend/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App.jsx';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
287
frontend/src/store/useStore.js
Normal file
287
frontend/src/store/useStore.js
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
import { api } from '../utils/api.js';
|
||||||
|
|
||||||
|
const newConv = () => ({
|
||||||
|
id: `conv-${Date.now()}`,
|
||||||
|
title: 'New Chat',
|
||||||
|
messages: [],
|
||||||
|
activeModelIds: [],
|
||||||
|
systemPrompts: {},
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useStore = create(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
// Models
|
||||||
|
models: [],
|
||||||
|
modelsLoading: false,
|
||||||
|
|
||||||
|
// Conversations
|
||||||
|
conversations: [],
|
||||||
|
activeConvId: null,
|
||||||
|
|
||||||
|
// Account
|
||||||
|
account: null,
|
||||||
|
accountLoading: false,
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
sidebarOpen: true,
|
||||||
|
modelManagerOpen: false,
|
||||||
|
settingsPanelModel: null,
|
||||||
|
webSearchEnabled: false,
|
||||||
|
|
||||||
|
// --- Models ---
|
||||||
|
fetchModels: async () => {
|
||||||
|
set({ modelsLoading: true });
|
||||||
|
try {
|
||||||
|
const models = await api.getModels();
|
||||||
|
set({ models, modelsLoading: false });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch models:', err);
|
||||||
|
set({ modelsLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addModel: async (modelData) => {
|
||||||
|
const model = await api.addModel(modelData);
|
||||||
|
set(s => ({ models: [...s.models, model] }));
|
||||||
|
return model;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateModel: async (id, data) => {
|
||||||
|
const updated = await api.updateModel(id, data);
|
||||||
|
set(s => ({ models: s.models.map(m => m.id === id ? updated : m) }));
|
||||||
|
return updated;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteModel: async (id) => {
|
||||||
|
await api.deleteModel(id);
|
||||||
|
set(s => ({
|
||||||
|
models: s.models.filter(m => m.id !== id),
|
||||||
|
conversations: s.conversations.map(c => ({
|
||||||
|
...c,
|
||||||
|
activeModelIds: c.activeModelIds.filter(mid => mid !== id)
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Conversations ---
|
||||||
|
getActiveConv: () => {
|
||||||
|
const { conversations, activeConvId } = get();
|
||||||
|
return conversations.find(c => c.id === activeConvId) || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
createConversation: () => {
|
||||||
|
const conv = newConv();
|
||||||
|
const { models } = get();
|
||||||
|
// Default to first available text model
|
||||||
|
const firstText = models.find(m => m.type === 'text');
|
||||||
|
if (firstText) conv.activeModelIds = [firstText.id];
|
||||||
|
set(s => ({
|
||||||
|
conversations: [conv, ...s.conversations],
|
||||||
|
activeConvId: conv.id
|
||||||
|
}));
|
||||||
|
return conv;
|
||||||
|
},
|
||||||
|
|
||||||
|
selectConversation: (id) => set({ activeConvId: id }),
|
||||||
|
|
||||||
|
deleteConversation: (id) => {
|
||||||
|
set(s => {
|
||||||
|
const remaining = s.conversations.filter(c => c.id !== id);
|
||||||
|
const nextId = remaining[0]?.id || null;
|
||||||
|
return { conversations: remaining, activeConvId: nextId };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
clearConversation: (id) => {
|
||||||
|
set(s => ({
|
||||||
|
conversations: s.conversations.map(c =>
|
||||||
|
c.id === id ? { ...c, messages: [], title: 'New Chat' } : c
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleModel: (convId, modelId) => {
|
||||||
|
set(s => ({
|
||||||
|
conversations: s.conversations.map(c => {
|
||||||
|
if (c.id !== convId) return c;
|
||||||
|
const has = c.activeModelIds.includes(modelId);
|
||||||
|
return {
|
||||||
|
...c,
|
||||||
|
activeModelIds: has
|
||||||
|
? c.activeModelIds.filter(id => id !== modelId)
|
||||||
|
: [...c.activeModelIds, modelId]
|
||||||
|
};
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
setSystemPrompt: (convId, modelId, prompt) => {
|
||||||
|
set(s => ({
|
||||||
|
conversations: s.conversations.map(c =>
|
||||||
|
c.id !== convId ? c : {
|
||||||
|
...c,
|
||||||
|
systemPrompts: { ...c.systemPrompts, [modelId]: prompt }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Sending messages ---
|
||||||
|
sendMessage: async (content) => {
|
||||||
|
const { getActiveConv, models, webSearchEnabled } = get();
|
||||||
|
let conv = getActiveConv();
|
||||||
|
|
||||||
|
if (!conv) {
|
||||||
|
get().createConversation();
|
||||||
|
conv = get().getActiveConv();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!conv) return;
|
||||||
|
|
||||||
|
// Parse @mentions from message
|
||||||
|
const mentionedModelIds = parseMentions(content, models);
|
||||||
|
const targetModelIds = mentionedModelIds.length > 0
|
||||||
|
? mentionedModelIds.map(m => m.id)
|
||||||
|
: conv.activeModelIds;
|
||||||
|
|
||||||
|
if (targetModelIds.length === 0) {
|
||||||
|
throw new Error('No models selected. Choose a model from the sidebar or @mention one.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const userMsg = {
|
||||||
|
id: `msg-${Date.now()}`,
|
||||||
|
role: 'user',
|
||||||
|
content,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add user message and pending assistant messages
|
||||||
|
const pendingMsgs = targetModelIds.map(modelId => ({
|
||||||
|
id: `msg-${Date.now()}-${modelId}`,
|
||||||
|
role: 'assistant',
|
||||||
|
modelId,
|
||||||
|
content: '',
|
||||||
|
pending: true,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}));
|
||||||
|
|
||||||
|
const convId = conv.id;
|
||||||
|
|
||||||
|
set(s => ({
|
||||||
|
conversations: s.conversations.map(c =>
|
||||||
|
c.id !== convId ? c : {
|
||||||
|
...c,
|
||||||
|
title: c.title === 'New Chat' ? content.slice(0, 50) : c.title,
|
||||||
|
messages: [...c.messages, userMsg, ...pendingMsgs]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Optionally fetch search context
|
||||||
|
let searchContext = null;
|
||||||
|
if (webSearchEnabled) {
|
||||||
|
try {
|
||||||
|
const result = await api.search(content);
|
||||||
|
searchContext = result.formatted;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build conversation history (last 10 exchanges for context)
|
||||||
|
const history = conv.messages.slice(-20).filter(m => !m.pending);
|
||||||
|
|
||||||
|
// Call each model
|
||||||
|
const calls = targetModelIds.map(async (modelId) => {
|
||||||
|
const model = models.find(m => m.id === modelId);
|
||||||
|
if (!model) return;
|
||||||
|
|
||||||
|
const systemPrompt = conv.systemPrompts?.[modelId] || '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.chat({
|
||||||
|
modelId,
|
||||||
|
prompt: content,
|
||||||
|
systemPrompt,
|
||||||
|
searchContext,
|
||||||
|
history
|
||||||
|
});
|
||||||
|
|
||||||
|
set(s => ({
|
||||||
|
conversations: s.conversations.map(c =>
|
||||||
|
c.id !== convId ? c : {
|
||||||
|
...c,
|
||||||
|
messages: c.messages.map(m =>
|
||||||
|
m.pending && m.modelId === modelId
|
||||||
|
? { ...m, content: result.content, pending: false, predictionId: result.id, metrics: result.metrics }
|
||||||
|
: m
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
set(s => ({
|
||||||
|
conversations: s.conversations.map(c =>
|
||||||
|
c.id !== convId ? c : {
|
||||||
|
...c,
|
||||||
|
messages: c.messages.map(m =>
|
||||||
|
m.pending && m.modelId === modelId
|
||||||
|
? { ...m, content: `Error: ${err.message}`, pending: false, error: true }
|
||||||
|
: m
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(calls);
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Account ---
|
||||||
|
fetchAccount: async () => {
|
||||||
|
set({ accountLoading: true });
|
||||||
|
try {
|
||||||
|
const data = await api.getAccount();
|
||||||
|
set({ account: data, accountLoading: false });
|
||||||
|
} catch (_) {
|
||||||
|
set({ accountLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- UI ---
|
||||||
|
toggleSidebar: () => set(s => ({ sidebarOpen: !s.sidebarOpen })),
|
||||||
|
setModelManagerOpen: (v) => set({ modelManagerOpen: v }),
|
||||||
|
setSettingsPanelModel: (model) => set({ settingsPanelModel: model }),
|
||||||
|
toggleWebSearch: () => set(s => ({ webSearchEnabled: !s.webSearchEnabled }))
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'iqai-store',
|
||||||
|
partialize: (s) => ({
|
||||||
|
conversations: s.conversations,
|
||||||
|
activeConvId: s.activeConvId,
|
||||||
|
sidebarOpen: s.sidebarOpen,
|
||||||
|
webSearchEnabled: s.webSearchEnabled
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
function parseMentions(text, models) {
|
||||||
|
if (!models?.length) return [];
|
||||||
|
const tags = models.map(m => m.tag);
|
||||||
|
const regex = new RegExp(`@(${tags.join('|')})\\b`, 'gi');
|
||||||
|
const found = new Set();
|
||||||
|
const results = [];
|
||||||
|
let match;
|
||||||
|
while ((match = regex.exec(text)) !== null) {
|
||||||
|
const model = models.find(m => m.tag.toLowerCase() === match[1].toLowerCase());
|
||||||
|
if (model && !found.has(model.id)) {
|
||||||
|
found.add(model.id);
|
||||||
|
results.push(model);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
35
frontend/src/utils/api.js
Normal file
35
frontend/src/utils/api.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
const BASE = '/api';
|
||||||
|
|
||||||
|
async function req(method, path, body) {
|
||||||
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: body ? { 'Content-Type': 'application/json' } : {},
|
||||||
|
body: body ? JSON.stringify(body) : undefined
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
|
||||||
|
throw new Error(err.error || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
// Models
|
||||||
|
getModels: () => req('GET', '/models'),
|
||||||
|
addModel: (model) => req('POST', '/models', model),
|
||||||
|
updateModel: (id, data) => req('PUT', `/models/${id}`, data),
|
||||||
|
deleteModel: (id) => req('DELETE', `/models/${id}`),
|
||||||
|
|
||||||
|
// Chat
|
||||||
|
chat: (payload) => req('POST', '/chat', payload),
|
||||||
|
chatMulti: (payload) => req('POST', '/chat/multi', payload),
|
||||||
|
|
||||||
|
// Account
|
||||||
|
getAccount: () => req('GET', '/account'),
|
||||||
|
|
||||||
|
// Search
|
||||||
|
search: (query) => req('GET', `/search?q=${encodeURIComponent(query)}`),
|
||||||
|
|
||||||
|
// Health
|
||||||
|
health: () => req('GET', '/health')
|
||||||
|
};
|
||||||
55
frontend/tailwind.config.js
Normal file
55
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{js,jsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Poppins', 'Inter', 'system-ui', 'sans-serif'],
|
||||||
|
display: ['Montserrat', 'Poppins', 'sans-serif'],
|
||||||
|
mono: ['JetBrains Mono', 'Fira Code', 'monospace']
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
// Brand palette
|
||||||
|
apricot: '#f9dbbd',
|
||||||
|
tangerine: '#fca17d',
|
||||||
|
blush: '#da627d',
|
||||||
|
grape: '#9a348e',
|
||||||
|
midnight: '#0d0628',
|
||||||
|
// Surface shades (midnight-violet based)
|
||||||
|
surface: {
|
||||||
|
0: '#0d0628',
|
||||||
|
1: '#110830',
|
||||||
|
2: '#180d40',
|
||||||
|
3: '#1e1150',
|
||||||
|
4: '#261560'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
backgroundImage: {
|
||||||
|
'brand-gradient': 'linear-gradient(135deg, #f9dbbd, #fca17d, #da627d, #9a348e, #0d0628)',
|
||||||
|
'brand-gradient-r': 'linear-gradient(90deg, #9a348e, #da627d, #fca17d)',
|
||||||
|
'brand-gradient-v': 'linear-gradient(180deg, #9a348e 0%, #0d0628 100%)',
|
||||||
|
'glow-grape': 'radial-gradient(circle at center, #9a348e40, transparent 70%)',
|
||||||
|
'glow-blush': 'radial-gradient(circle at center, #da627d30, transparent 70%)'
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
'grape': '0 0 20px #9a348e40',
|
||||||
|
'blush': '0 0 20px #da627d30',
|
||||||
|
'tangerine': '0 0 20px #fca17d30'
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'fade-in': 'fadeIn 0.2s ease-out',
|
||||||
|
'slide-up': 'slideUp 0.25s ease-out',
|
||||||
|
'glow-pulse': 'glowPulse 3s ease-in-out infinite'
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
fadeIn: { from: { opacity: 0 }, to: { opacity: 1 } },
|
||||||
|
slideUp: { from: { opacity: 0, transform: 'translateY(10px)' }, to: { opacity: 1, transform: 'translateY(0)' } },
|
||||||
|
glowPulse: {
|
||||||
|
'0%, 100%': { boxShadow: '0 0 15px #9a348e30' },
|
||||||
|
'50%': { boxShadow: '0 0 30px #9a348e60' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: []
|
||||||
|
};
|
||||||
15
frontend/vite.config.js
Normal file
15
frontend/vite.config.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3001',
|
||||||
|
changeOrigin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
35
nginx.conf
Normal file
35
nginx.conf
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user