fable-ai / server.js
Ejdjdososs's picture
v2 final: llama-cpp-python in Docker + TinyLlama pre-download + auto-provider streamHF + setup endpoint
b6216f0 verified
Raw
History Blame Contribute Delete
25.4 kB
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()}`);
});