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('')) { const pre = buf.slice(0, buf.indexOf('')); if (pre) yield { type: 'content', text: pre }; buf = buf.slice(buf.indexOf('') + 7); inThink = true; } else if (inThink && buf.includes('')) { const th = buf.slice(0, buf.indexOf('')); if (th) yield { type: 'thinking', text: th }; buf = buf.slice(buf.indexOf('') + 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()}`); });