Spaces:
Running
Running
v2 final: llama-cpp-python in Docker + TinyLlama pre-download + auto-provider streamHF + setup endpoint
b6216f0 verified | import express from 'express'; | |
| import cors from 'cors'; | |
| import { createRequire } from 'module'; | |
| import { fileURLToPath } from 'url'; | |
| import { dirname, join } from 'path'; | |
| import fs from 'fs'; | |
| import { createWriteStream } from 'fs'; | |
| const require = createRequire(import.meta.url); | |
| const Database = require('better-sqlite3'); | |
| const __dirname = dirname(fileURLToPath(import.meta.url)); | |
| // ─── DATABASE ───────────────────────────────────────────────────────────────── | |
| const db = new Database('/data/fable.db'); | |
| db.pragma('journal_mode = WAL'); | |
| db.exec(` | |
| CREATE TABLE IF NOT EXISTS conversations ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| title TEXT NOT NULL DEFAULT 'New Chat', | |
| model TEXT NOT NULL DEFAULT 'auto', | |
| provider TEXT NOT NULL DEFAULT 'auto', | |
| starred INTEGER NOT NULL DEFAULT 0, | |
| created_at TEXT DEFAULT (datetime('now')), | |
| updated_at TEXT DEFAULT (datetime('now')) | |
| ); | |
| CREATE TABLE IF NOT EXISTS messages ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| conversation_id INTEGER NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, | |
| role TEXT NOT NULL, | |
| content TEXT NOT NULL, | |
| thinking TEXT, | |
| images TEXT, | |
| search_results TEXT, | |
| provider_used TEXT, | |
| model_used TEXT, | |
| created_at TEXT DEFAULT (datetime('now')) | |
| ); | |
| CREATE INDEX IF NOT EXISTS idx_msgs_conv ON messages(conversation_id); | |
| CREATE INDEX IF NOT EXISTS idx_conv_upd ON conversations(updated_at DESC); | |
| `); | |
| // ─── LOCAL MODEL: background GGUF download + llama.cpp sidecar ─────────────── | |
| const LOCAL_MODEL_PATH = '/app/models/local.gguf'; | |
| const LOCAL_MODEL_URL = 'https://huggingface.co/TheBloke/TinyLlama-1.1B-Chat-v1.0-GGUF/resolve/main/tinyllama-1.1b-chat-v1.0.Q4_K_M.gguf'; | |
| let localModelDownloaded = false; | |
| let localModelDownloading = false; | |
| let localLlamaReady = false; // true once llama.cpp REST server on :8080 answers | |
| let localModelError = null; | |
| // Poll llama.cpp health every 15s | |
| async function pollLlama() { | |
| try { | |
| const r = await fetch('http://127.0.0.1:8080/health', { signal: AbortSignal.timeout(2000) }); | |
| if (r.ok) { localLlamaReady = true; console.log('[local] ✅ llama.cpp ready!'); return; } | |
| } catch {} | |
| setTimeout(pollLlama, 15000); | |
| } | |
| async function startLlamaCpp() { | |
| // Try to launch llama.cpp Python server if python3 + llama-cpp-python available | |
| const { spawn } = await import('child_process'); | |
| try { | |
| const proc = spawn('python3', ['-m', 'llama_cpp.server', | |
| '--model', LOCAL_MODEL_PATH, | |
| '--host', '127.0.0.1', '--port', '8080', | |
| '--n_ctx', '4096', '--chat_format', 'chatml', | |
| '--n_threads', '4' | |
| ], { detached: true, stdio: 'ignore' }); | |
| proc.unref(); | |
| console.log('[local] Started llama.cpp server (PID', proc.pid, ')'); | |
| setTimeout(pollLlama, 8000); | |
| } catch (e) { | |
| localModelError = 'llama_cpp not installed'; | |
| console.log('[local] llama_cpp not installed. Local model file downloaded but not usable.'); | |
| } | |
| } | |
| async function downloadModel() { | |
| if (localModelDownloading) return; | |
| if (fs.existsSync(LOCAL_MODEL_PATH) && fs.statSync(LOCAL_MODEL_PATH).size > 100_000_000) { | |
| localModelDownloaded = true; | |
| await startLlamaCpp(); | |
| return; | |
| } | |
| console.log('[local] Starting TinyLlama download (~637 MB)...'); | |
| localModelDownloading = true; | |
| try { | |
| const resp = await fetch(LOCAL_MODEL_URL, { | |
| headers: { 'User-Agent': 'Fable-AI/2.0' }, | |
| signal: AbortSignal.timeout(600_000) // 10 min | |
| }); | |
| if (!resp.ok) throw new Error(`HTTP ${resp.status}`); | |
| const ws = createWriteStream(LOCAL_MODEL_PATH + '.tmp'); | |
| const reader = resp.body.getReader(); | |
| let downloaded = 0; | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| ws.write(value); | |
| downloaded += value.length; | |
| if (downloaded % (50 * 1024 * 1024) < value.length) | |
| console.log(`[local] ${Math.round(downloaded / 1024 / 1024)}MB downloaded`); | |
| } | |
| await new Promise((res, rej) => ws.end(e => e ? rej(e) : res())); | |
| fs.renameSync(LOCAL_MODEL_PATH + '.tmp', LOCAL_MODEL_PATH); | |
| localModelDownloaded = true; | |
| localModelDownloading = false; | |
| console.log('[local] Download complete!'); | |
| await startLlamaCpp(); | |
| } catch (err) { | |
| localModelDownloading = false; | |
| localModelError = err.message; | |
| console.error('[local] Download failed:', err.message.slice(0, 100)); | |
| } | |
| } | |
| // On startup: if model already exists (pre-downloaded in Docker), start llama.cpp immediately | |
| async function initLocalModel() { | |
| if (fs.existsSync(LOCAL_MODEL_PATH) && fs.statSync(LOCAL_MODEL_PATH).size > 100_000_000) { | |
| console.log('[local] Model pre-downloaded! Starting llama.cpp server...'); | |
| localModelDownloaded = true; | |
| await startLlamaCpp().catch(e => console.log('[local] llama.cpp start failed:', e.message)); | |
| } else { | |
| console.log('[local] Model not found — starting background download...'); | |
| downloadModel().catch(console.error); | |
| } | |
| } | |
| setTimeout(initLocalModel, 2000); | |
| setTimeout(pollLlama, 5000); | |
| // ─── PROVIDERS ──────────────────────────────────────────────────────────────── | |
| const PROVIDERS = { | |
| hf_free: { | |
| name: '🆓 HF (Zephyr 7B)', | |
| description: 'HuggingFace Zephyr — uses Space token', | |
| models: [ | |
| { id: 'HuggingFaceH4/zephyr-7b-beta', name: '🆓 Zephyr 7B · HuggingFace', maxTokens: 4096, badge: 'FREE' }, | |
| { id: 'mistralai/Mistral-7B-Instruct-v0.1', name: '🆓 Mistral 7B v0.1 · HuggingFace', maxTokens: 4096, badge: 'FREE' }, | |
| ] | |
| }, | |
| local: { | |
| name: '💻 Local (TinyLlama 1.1B)', | |
| description: 'Runs entirely on this server — no internet, no key', | |
| models: [ | |
| { id: 'tinyllama', name: '💻 TinyLlama 1.1B · Fully Local · No API Key', maxTokens: 2048, badge: 'LOCAL', local: true } | |
| ] | |
| }, | |
| groq: { | |
| name: '⚡ Groq', | |
| description: 'Free tier — 500 tok/sec — Llama / DeepSeek', | |
| baseURL: 'https://api.groq.com/openai/v1', | |
| key: process.env.GROQ_API_KEY, | |
| models: [ | |
| { id: 'llama-3.3-70b-versatile', name: '⚡ Llama 3.3 70B · Groq', maxTokens: 32768, badge: 'FAST' }, | |
| { id: 'deepseek-r1-distill-llama-70b', name: '⚡🧠 DeepSeek R1 70B · Groq', maxTokens: 32768, reasoning: true, badge: 'REASON' }, | |
| { id: 'meta-llama/llama-4-scout-17b-16e-instruct', name: '⚡ Llama 4 Scout 17B · Groq', maxTokens: 131072, badge: 'FAST' }, | |
| { id: 'qwen-qwq-32b', name: '⚡🧠 QwQ 32B · Groq', maxTokens: 32768, reasoning: true, badge: 'REASON' }, | |
| ] | |
| }, | |
| openrouter: { | |
| name: '🌐 OpenRouter', | |
| description: 'Free models — DeepSeek V3/R1, Llama, Gemma', | |
| baseURL: 'https://openrouter.ai/api/v1', | |
| key: process.env.OPENROUTER_API_KEY, | |
| models: [ | |
| { id: 'deepseek/deepseek-chat-v3-0324:free', name: '🆓 DeepSeek V3 · OpenRouter FREE', maxTokens: 16384, badge: 'FREE' }, | |
| { id: 'deepseek/deepseek-r1:free', name: '🆓🧠 DeepSeek R1 · FREE Reasoning', maxTokens: 16384, reasoning: true, badge: 'FREE' }, | |
| { id: 'meta-llama/llama-3.3-70b-instruct:free', name: '🆓 Llama 3.3 70B · FREE', maxTokens: 8192, badge: 'FREE' }, | |
| { id: 'google/gemma-3-27b-it:free', name: '🆓 Gemma 3 27B · FREE', maxTokens: 8192, badge: 'FREE' }, | |
| { id: 'qwen/qwq-32b:free', name: '🆓🧠 QwQ 32B Reasoning · FREE', maxTokens: 16384, reasoning: true, badge: 'FREE' }, | |
| ] | |
| }, | |
| together: { | |
| name: '🤝 Together AI', | |
| description: '$25 free credits — Llama / DeepSeek', | |
| baseURL: 'https://api.together.xyz/v1', | |
| key: process.env.TOGETHER_API_KEY, | |
| models: [ | |
| { id: 'deepseek-ai/DeepSeek-V3', name: 'DeepSeek V3 · Together', maxTokens: 16384 }, | |
| { id: 'deepseek-ai/DeepSeek-R1', name: '🧠 DeepSeek R1 · Together', maxTokens: 16384, reasoning: true }, | |
| { id: 'meta-llama/Llama-3.3-70B-Instruct-Turbo', name: 'Llama 3.3 70B Turbo · Together', maxTokens: 32768 }, | |
| ] | |
| }, | |
| hf: { | |
| name: '🤗 HuggingFace', | |
| description: 'HF token — bigger models, better rate limits', | |
| key: process.env.HF_TOKEN, | |
| models: [ | |
| { id: 'meta-llama/Llama-3.3-70B-Instruct', name: 'Llama 3.3 70B · HuggingFace', maxTokens: 8192 }, | |
| { id: 'Qwen/Qwen2.5-72B-Instruct', name: 'Qwen 2.5 72B · HuggingFace', maxTokens: 8192 }, | |
| { id: 'mistralai/Mistral-7B-Instruct-v0.3', name: 'Mistral 7B · HuggingFace', maxTokens: 4096 }, | |
| { id: 'deepseek-ai/DeepSeek-R1-Distill-Llama-8B', name: '🧠 DeepSeek R1 8B · HuggingFace', maxTokens: 4096, reasoning: true }, | |
| ] | |
| } | |
| }; | |
| function getActiveProvider() { | |
| if (localLlamaReady) return 'local'; | |
| if (process.env.GROQ_API_KEY) return 'groq'; | |
| if (process.env.OPENROUTER_API_KEY) return 'openrouter'; | |
| if (process.env.TOGETHER_API_KEY) return 'together'; | |
| if (process.env.HF_TOKEN) return 'hf'; | |
| return 'hf_free'; | |
| } | |
| // ─── SYSTEM PROMPT ──────────────────────────────────────────────────────────── | |
| const SYSTEM_PROMPT = `You are Fable, an advanced AI assistant. You: | |
| - Respond in the user's language (Arabic, English, or any other — auto-detect) | |
| - Write beautiful markdown: ## headers, **bold**, tables, bullet lists | |
| - ALWAYS use fenced code blocks with language labels: \`\`\`python, \`\`\`javascript, \`\`\`html, \`\`\`sql, etc. | |
| - Show code with comments and step-by-step explanations | |
| - For HTML/React/CSS: generate complete, runnable, beautiful code | |
| - For math: show all steps clearly | |
| - Be honest — say "I'm not sure" rather than making things up | |
| - Use clear sections with headers for long responses`; | |
| // ─── WEB SEARCH (DuckDuckGo, no key) ───────────────────────────────────────── | |
| async function webSearch(query) { | |
| try { | |
| const r = await fetch( | |
| `https://api.duckduckgo.com/?q=${encodeURIComponent(query)}&format=json&no_redirect=1&no_html=1`, | |
| { headers: { 'User-Agent': 'Mozilla/5.0 Fable-AI/2.0' }, signal: AbortSignal.timeout(6000) } | |
| ); | |
| const d = await r.json(); | |
| const results = []; | |
| if (d.AbstractText) results.push({ title: d.Heading || query, snippet: d.AbstractText, url: d.AbstractURL }); | |
| (d.RelatedTopics || []).slice(0, 5).forEach(t => { | |
| if (t.Text) results.push({ title: t.Text.slice(0, 60), snippet: t.Text, url: t.FirstURL || '' }); | |
| }); | |
| return results; | |
| } catch { return []; } | |
| } | |
| // ─── STREAM: OpenAI-compatible ──────────────────────────────────────────────── | |
| async function* streamOpenAI(baseURL, apiKey, model, messages, maxTokens, isOR) { | |
| const headers = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` }; | |
| if (isOR) { headers['HTTP-Referer'] = 'https://ejdjdososs-fable-ai.hf.space'; headers['X-Title'] = 'Fable AI'; } | |
| const resp = await fetch(`${baseURL}/chat/completions`, { | |
| method: 'POST', headers, | |
| body: JSON.stringify({ model, messages, max_tokens: maxTokens || 4096, stream: true, temperature: 0.7 }), | |
| signal: AbortSignal.timeout(120000) | |
| }); | |
| if (!resp.ok) { const e = await resp.text(); throw new Error(`${resp.status}: ${e.slice(0, 200)}`); } | |
| const reader = resp.body.getReader(); | |
| const dec = new TextDecoder(); | |
| let buf = ''; | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| buf += dec.decode(value, { stream: true }); | |
| const lines = buf.split('\n'); buf = lines.pop() ?? ''; | |
| for (const line of lines) { | |
| if (!line.startsWith('data: ') || line === 'data: [DONE]') continue; | |
| try { | |
| const ch = JSON.parse(line.slice(6)); | |
| const d = ch.choices?.[0]?.delta; | |
| if (d?.content) yield { type: 'content', text: d.content }; | |
| if (d?.reasoning_content) yield { type: 'thinking', text: d.reasoning_content }; | |
| } catch {} | |
| } | |
| } | |
| } | |
| // ─── STREAM: HF Inference (auto-provider via InferenceClient v3) ───────────────── | |
| // Uses HF_TOKEN — auto-discovers provider (featherless-ai, etc.) via HF Hub API | |
| async function* streamHF(modelId, messages, token) { | |
| const { InferenceClient } = await import('@huggingface/inference'); | |
| const client = new InferenceClient(token); | |
| let buf = '', inThink = false; | |
| for await (const chunk of client.chatCompletionStream({ | |
| model: modelId, | |
| messages, | |
| max_tokens: 4096, | |
| temperature: 0.7, | |
| })) { | |
| const delta = chunk.choices?.[0]?.delta?.content || ''; | |
| if (!delta) continue; | |
| buf = delta; | |
| while (buf.length > 0) { | |
| if (!inThink && buf.includes('<think>')) { | |
| const pre = buf.slice(0, buf.indexOf('<think>')); | |
| if (pre) yield { type: 'content', text: pre }; | |
| buf = buf.slice(buf.indexOf('<think>') + 7); inThink = true; | |
| } else if (inThink && buf.includes('</think>')) { | |
| const th = buf.slice(0, buf.indexOf('</think>')); | |
| if (th) yield { type: 'thinking', text: th }; | |
| buf = buf.slice(buf.indexOf('</think>') + 8); inThink = false; | |
| } else { | |
| yield { type: inThink ? 'thinking' : 'content', text: buf }; buf = ''; break; | |
| } | |
| } | |
| } | |
| } | |
| // ─── STREAM: Local llama.cpp (REST on :8080) ────────────────────────────────── | |
| async function* streamLocalLlama(messages) { | |
| if (!localLlamaReady) { | |
| if (localModelDownloading) throw new Error('⏳ TinyLlama is still downloading. Please wait a few minutes and try again.'); | |
| if (localModelDownloaded) throw new Error('⏳ TinyLlama downloaded, server starting. Please wait ~30 seconds.'); | |
| throw new Error('⏳ TinyLlama local model is downloading in background. Check back in a few minutes.'); | |
| } | |
| yield* streamOpenAI('http://127.0.0.1:8080/v1', 'local', 'tinyllama', messages, 2048, false); | |
| } | |
| // ─── EXPRESS ────────────────────────────────────────────────────────────────── | |
| const app = express(); | |
| app.use(cors()); | |
| app.use(express.json({ limit: '50mb' })); | |
| app.use(express.static(join(__dirname, 'dist'))); | |
| // ─── DEBUG: Test outbound connectivity ──────────────────────────────────────── | |
| app.get('/api/debug/connectivity', async (req, res) => { | |
| const results = {}; | |
| const testFetch = async (name, url, opts = {}) => { | |
| try { | |
| const r = await fetch(url, { ...opts, signal: AbortSignal.timeout(8000) }); | |
| const txt = await r.text().catch(() => ''); | |
| results[name] = { status: r.status, ok: r.ok, body: txt.slice(0, 150) }; | |
| } catch(e) { results[name] = { error: e.message, code: e.code }; } | |
| }; | |
| await testFetch('ddg', 'https://api.duckduckgo.com/?q=test&format=json'); | |
| await testFetch('hf_main', 'https://huggingface.co/api/whoami-v2', { | |
| headers: { Authorization: `Bearer ${process.env.HF_TOKEN || ''}` } | |
| }); | |
| await testFetch('hf_router', 'https://router.huggingface.co/hf-inference/v1/chat/completions', { | |
| method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${process.env.HF_TOKEN || 'hf_test'}` }, | |
| body: JSON.stringify({ model: 'HuggingFaceH4/zephyr-7b-beta', messages: [{ role: 'user', content: 'Hi' }], max_tokens: 5, stream: false }) | |
| }); | |
| await testFetch('groq_test', 'https://api.groq.com/openai/v1/models', { | |
| headers: { Authorization: 'Bearer test123' } | |
| }); | |
| await testFetch('or_test', 'https://openrouter.ai/api/v1/models'); | |
| res.json({ env: { HF_TOKEN: !!process.env.HF_TOKEN, HF_TOKEN_prefix: (process.env.HF_TOKEN || '').slice(0,8) }, results }); | |
| }); | |
| // ─── SETUP INSTRUCTIONS ────────────────────────────────────────────────────── | |
| app.get('/api/setup', (req, res) => { | |
| const hasGroq = !!process.env.GROQ_API_KEY; | |
| const hasOR = !!process.env.OPENROUTER_API_KEY; | |
| const hasTogether = !!process.env.TOGETHER_API_KEY; | |
| const hasHF = !!process.env.HF_TOKEN; | |
| const anyKey = hasGroq || hasOR || hasTogether || hasHF; | |
| res.json({ | |
| ready: anyKey || localLlamaReady, | |
| localModel: { downloaded: localModelDownloaded, serving: localLlamaReady, downloading: localModelDownloading }, | |
| providers: { groq: hasGroq, openrouter: hasOR, together: hasTogether, hf: hasHF }, | |
| instructions: anyKey || localLlamaReady ? null : { | |
| message: "No AI provider configured. Choose one:", | |
| options: [ | |
| { name: "Groq (Fastest, Free)", url: "https://console.groq.com/keys", envVar: "GROQ_API_KEY" }, | |
| { name: "OpenRouter (Free Models)", url: "https://openrouter.ai/keys", envVar: "OPENROUTER_API_KEY" }, | |
| { name: "Together AI ($25 Free)", url: "https://api.together.xyz/settings/api-keys", envVar: "TOGETHER_API_KEY" }, | |
| { name: "Local TinyLlama (No Key)", eta: localModelDownloading ? "Downloading..." : "Will start at launch" } | |
| ] | |
| } | |
| }); | |
| }); | |
| app.get('/api/status', (req, res) => res.json({ | |
| ok: true, activeProvider: getActiveProvider(), | |
| localDownloaded: localModelDownloaded, localLlama: localLlamaReady, | |
| localDownloading: localModelDownloading, localError: localModelError | |
| })); | |
| app.get('/api/models', (req, res) => { | |
| const active = getActiveProvider(); | |
| const models = []; | |
| for (const [pid, prov] of Object.entries(PROVIDERS)) { | |
| const avail = pid === 'hf_free' ? true | |
| : pid === 'local' ? localLlamaReady | |
| : pid === 'hf' ? true | |
| : !!prov.key; | |
| for (const m of prov.models) { | |
| models.push({ | |
| id: `${pid}::${m.id}`, name: m.name, badge: m.badge || null, | |
| provider: prov.name, providerId: pid, | |
| available: avail, reasoning: !!m.reasoning, local: !!m.local, | |
| maxTokens: m.maxTokens || 4096, recommended: pid === active | |
| }); | |
| } | |
| } | |
| res.json({ models, activeProvider: active, providerName: PROVIDERS[active]?.name, | |
| localReady: localLlamaReady, localDownloading: localModelDownloading }); | |
| }); | |
| app.get('/api/providers', (req, res) => { | |
| const active = getActiveProvider(); | |
| res.json(Object.entries(PROVIDERS).map(([id, p]) => ({ | |
| id, name: p.name, description: p.description || '', active: id === active, | |
| available: id === 'hf_free' ? true : id === 'local' ? localLlamaReady : !!p.key | |
| }))); | |
| }); | |
| // ─── CONVERSATIONS ──────────────────────────────────────────────────────────── | |
| app.get('/api/conversations', (req, res) => { | |
| const { search, starred } = req.query; | |
| let sql = 'SELECT id,title,model,provider,starred,created_at,updated_at FROM conversations'; | |
| const p = [], c = []; | |
| if (search) { c.push('title LIKE ?'); p.push(`%${search}%`); } | |
| if (starred === '1') c.push('starred=1'); | |
| if (c.length) sql += ' WHERE ' + c.join(' AND '); | |
| sql += ' ORDER BY updated_at DESC LIMIT 100'; | |
| res.json(db.prepare(sql).all(...p)); | |
| }); | |
| app.post('/api/conversations', (req, res) => { | |
| const { title = 'New Chat', model = 'auto', provider = 'auto' } = req.body; | |
| res.status(201).json(db.prepare('INSERT INTO conversations (title,model,provider) VALUES (?,?,?) RETURNING *').get(title, model, provider)); | |
| }); | |
| app.get('/api/conversations/:id', (req, res) => { | |
| const conv = db.prepare('SELECT * FROM conversations WHERE id=?').get(req.params.id); | |
| if (!conv) return res.status(404).json({ error: 'Not found' }); | |
| res.json({ ...conv, messages: db.prepare('SELECT * FROM messages WHERE conversation_id=? ORDER BY id').all(req.params.id) }); | |
| }); | |
| app.patch('/api/conversations/:id', (req, res) => { | |
| const { title, starred } = req.body; | |
| const upds = [], p = []; | |
| if (title !== undefined) { upds.push('title=?'); p.push(title); } | |
| if (starred !== undefined) { upds.push('starred=?'); p.push(starred ? 1 : 0); } | |
| if (!upds.length) return res.status(400).json({ error: 'Nothing to update' }); | |
| upds.push("updated_at=datetime('now')"); p.push(req.params.id); | |
| const r = db.prepare(`UPDATE conversations SET ${upds.join(',')} WHERE id=? RETURNING *`).get(...p); | |
| r ? res.json(r) : res.status(404).json({ error: 'Not found' }); | |
| }); | |
| app.delete('/api/conversations/:id', (req, res) => { | |
| const r = db.prepare('DELETE FROM conversations WHERE id=?').run(req.params.id); | |
| r.changes === 0 ? res.status(404).json({ error: 'Not found' }) : res.status(204).end(); | |
| }); | |
| // ─── CHAT (SSE) ─────────────────────────────────────────────────────────────── | |
| app.post('/api/conversations/:id/messages', async (req, res) => { | |
| const convId = parseInt(req.params.id); | |
| const conv = db.prepare('SELECT * FROM conversations WHERE id=?').get(convId); | |
| if (!conv) return res.status(404).json({ error: 'Not found' }); | |
| const { content, model: modelParam, webSearch: useSearch, images } = req.body; | |
| const text = (content || '').trim(); | |
| if (!text && !images?.length) return res.status(400).json({ error: 'Content required' }); | |
| let providerId, modelId; | |
| if (modelParam?.includes('::')) { | |
| const parts = modelParam.split('::'); | |
| providerId = parts[0]; modelId = parts.slice(1).join('::'); | |
| } else { | |
| providerId = getActiveProvider(); | |
| modelId = PROVIDERS[providerId]?.models[0]?.id || ''; | |
| } | |
| const prov = PROVIDERS[providerId]; | |
| db.prepare('INSERT INTO messages (conversation_id,role,content,images) VALUES (?,?,?,?)').run(convId, 'user', text, images?.length ? JSON.stringify(images) : null); | |
| if (conv.title === 'New Chat' && text) db.prepare("UPDATE conversations SET title=?,updated_at=datetime('now') WHERE id=?").run(text.slice(0, 55), convId); | |
| else db.prepare("UPDATE conversations SET updated_at=datetime('now') WHERE id=?").run(convId); | |
| db.prepare('UPDATE conversations SET model=?,provider=? WHERE id=?').run(modelId, providerId, convId); | |
| const history = db.prepare('SELECT role,content FROM messages WHERE conversation_id=? ORDER BY id').all(convId); | |
| let searchResults = [], augText = text; | |
| if (useSearch && text) { | |
| searchResults = await webSearch(text); | |
| if (searchResults.length) { | |
| const ctx = searchResults.map((r, i) => `[${i+1}] **${r.title}**: ${r.snippet}`).join('\n\n'); | |
| augText = `${text}\n\n---\n**Web Search Results:**\n${ctx}`; | |
| } | |
| } | |
| const chatMsgs = [{ role: 'system', content: SYSTEM_PROMPT }]; | |
| for (const m of history.slice(0, -1)) chatMsgs.push({ role: m.role, content: m.content }); | |
| if (images?.length) { | |
| chatMsgs.push({ role: 'user', content: [ | |
| { type: 'text', text: augText }, | |
| ...images.slice(0, 4).map(img => ({ type: 'image_url', image_url: { url: img.data } })) | |
| ]}); | |
| } else { | |
| chatMsgs.push({ role: 'user', content: augText }); | |
| } | |
| res.setHeader('Content-Type', 'text/event-stream'); | |
| res.setHeader('Cache-Control', 'no-cache'); | |
| res.setHeader('Connection', 'keep-alive'); | |
| res.setHeader('X-Accel-Buffering', 'no'); | |
| const send = d => { try { res.write(`data: ${JSON.stringify(d)}\n\n`); } catch {} }; | |
| send({ provider: prov?.name, providerId, model: modelId }); | |
| if (searchResults.length) send({ searchResults }); | |
| let fullContent = '', fullThinking = ''; | |
| try { | |
| let stream; | |
| if (providerId === 'local') stream = streamLocalLlama(chatMsgs); | |
| else if (providerId === 'hf_free') stream = streamHF(modelId, chatMsgs, process.env.HF_TOKEN || null); | |
| else if (providerId === 'hf') stream = streamHF(modelId, chatMsgs, prov.key); | |
| else stream = streamOpenAI(prov.baseURL, prov.key, modelId, chatMsgs, | |
| prov.models?.find(m => m.id === modelId)?.maxTokens || 4096, | |
| providerId === 'openrouter'); | |
| for await (const ev of stream) { | |
| if (ev.type === 'content') { fullContent += ev.text; send({ content: ev.text }); } | |
| if (ev.type === 'thinking') { fullThinking += ev.text; send({ thinking: ev.text }); } | |
| } | |
| db.prepare('INSERT INTO messages (conversation_id,role,content,thinking,search_results,provider_used,model_used) VALUES (?,?,?,?,?,?,?)') | |
| .run(convId, 'assistant', fullContent, fullThinking || null, | |
| searchResults.length ? JSON.stringify(searchResults) : null, providerId, modelId); | |
| send({ done: true }); res.end(); | |
| } catch (err) { | |
| console.error('[stream]', err.message); | |
| if (!res.writableEnded) { send({ error: err.message }); res.end(); } | |
| } | |
| }); | |
| app.get('/api/search', async (req, res) => { | |
| if (!req.query.q) return res.status(400).json({ error: 'q required' }); | |
| res.json(await webSearch(req.query.q)); | |
| }); | |
| app.get('*', (req, res) => { | |
| if (req.path.startsWith('/api')) return res.status(404).json({ error: 'Not found' }); | |
| res.sendFile(join(__dirname, 'dist', 'index.html')); | |
| }); | |
| const PORT = parseInt(process.env.PORT || '7860'); | |
| app.listen(PORT, '0.0.0.0', () => { | |
| console.log(`✨ Fable AI v2 — port ${PORT} — provider: ${getActiveProvider()}`); | |
| }); | |