OASIS_MOBILE/src/AI/ai_service.mjs
oasis-bot c332afd6ea upstream: integrar Oasis 1.3.1
Base del merge a tres bandas: git 0.9.5 de epsylon, esta rama y el src de la
APK oficial 1.3.1 (la variante movil: OASIS_MOBILE, regex sin \p{}, audio y
video de Android). Corre en el mismo nodejs-mobile 18.20.4 que ya usabamos.

Se conserva de la rama: Karvan, identidades y multicuenta, topbar y barra
inferior, borrado de EXIF sin sharp, filtro hot del foro, RTC sin STUN y los
modulos apagados en movil (wallet, banking, torrents).

Se toma de upstream lo que ya hace de serie: respuesta citada en chats, ssb-lan
en movil, configuracion en ~/.ssb/oasis (sobrevive a reinstalar el backend).
El asistente de bienvenida deja elegir workflow tambien en movil.
2026-10-18 02:52:17 +02:00

168 lines
6.3 KiB
JavaScript

import path from 'path';
import fs from 'fs';
import os from 'os';
import http from 'http';
import { fileURLToPath } from 'url';
import { getLlama, LlamaChatSession } from './node_modules/node-llama-cpp/dist/index.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const PORT = Number(process.env.OASIS_AI_PORT) || 4001;
const TOKEN = String(process.env.OASIS_AI_TOKEN || '');
const PARENT_PID = Number(process.env.OASIS_AI_PARENT_PID) || 0;
const MODEL_FILE = 'oasis-42-1-chat.Q4_K_M.gguf';
const MODEL_PATH = process.env.OASIS_AI_MODEL || path.join(__dirname, MODEL_FILE);
const GENERATION_TIMEOUT_MS = 120000;
const MAX_TOKENS_DEFAULT = 400;
const MAX_INPUT_CHARS = 5000;
const MAX_CONTEXT_CHARS = 6000;
const state = { loading: false, ready: false, error: null, model: null, contextSize: 0, threads: 0 };
let llama, model, context;
let queue = Promise.resolve();
let loadingPromise = null;
const pickThreads = () => Math.max(1, Math.min(os.cpus().length - 1, 6));
const pickContextSize = () => {
const gb = os.totalmem() / (1024 ** 3);
if (gb < 6) return 2048;
if (gb < 12) return 4096;
return 8192;
};
async function initModel() {
if (state.ready) return;
if (loadingPromise) return loadingPromise;
state.loading = true;
loadingPromise = loadModel().finally(() => { loadingPromise = null; });
return loadingPromise;
}
const DEBUG = process.env.OASIS_DEBUG === '1' || process.env.OASIS_DEBUG === 'true';
const log = (msg) => { if (!DEBUG) return; try { process.stderr.write(`[ai-service] ${msg}\n`); } catch (_) {} };
async function loadModel() {
const t0 = Date.now();
try {
if (!fs.existsSync(MODEL_PATH)) throw new Error(`model_missing:${MODEL_PATH}`);
log(`loading ${path.basename(MODEL_PATH)}`);
llama = await getLlama({ gpu: false });
log(`llama ready (${Date.now() - t0} ms)`);
model = await llama.loadModel({ modelPath: MODEL_PATH });
log(`model loaded (${Date.now() - t0} ms)`);
state.threads = pickThreads();
state.contextSize = pickContextSize();
context = await model.createContext({ contextSize: state.contextSize, threads: state.threads });
log(`context ready: ${state.contextSize} tokens, ${state.threads} threads (${Date.now() - t0} ms)`);
state.model = path.basename(MODEL_PATH);
state.ready = true;
state.error = null;
} catch (e) {
state.error = String((e && e.message) || e);
log(`load failed: ${state.error}`);
} finally {
state.loading = false;
}
}
const clip = (s, n) => String(s == null ? '' : s).slice(0, n);
const buildHistory = ({ system, history, context: ctxLines }) => {
const items = [];
const sys = [clip(system, 2000)];
const lines = Array.isArray(ctxLines) ? ctxLines.map(x => clip(x, 800)).filter(Boolean) : [];
if (lines.length) {
let block = '';
for (const l of lines) { if ((block + l).length > MAX_CONTEXT_CHARS) break; block += `- ${l}\n`; }
sys.push(`Knowledge from the Oasis network (use it when relevant, never invent beyond it):\n${block}`);
}
items.push({ type: 'system', text: sys.join('\n\n') });
for (const turn of (Array.isArray(history) ? history : []).slice(-8)) {
if (!turn || typeof turn.text !== 'string' || !turn.text.trim()) continue;
if (turn.type === 'user') items.push({ type: 'user', text: clip(turn.text, MAX_INPUT_CHARS) });
else if (turn.type === 'model') items.push({ type: 'model', response: [clip(turn.text, MAX_INPUT_CHARS)] });
}
return items;
};
async function answer(body) {
await initModel();
if (!state.ready) throw new Error(state.error || 'not_ready');
const input = clip(body.input, MAX_INPUT_CHARS).trim();
if (!input) throw new Error('empty_input');
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), GENERATION_TIMEOUT_MS);
const sequence = context.getSequence();
const session = new LlamaChatSession({ contextSequence: sequence });
try {
session.setChatHistory(buildHistory(body));
const text = await session.prompt(input, {
maxTokens: Math.max(32, Math.min(1024, Number(body.maxTokens) || MAX_TOKENS_DEFAULT)),
temperature: 0.6,
signal: controller.signal,
stopOnAbortSignal: true
});
return String(text || '').trim();
} finally {
clearTimeout(timer);
try { session.dispose(); } catch (_) {}
try { sequence.dispose(); } catch (_) {}
}
}
const readJson = (req) => new Promise((resolve, reject) => {
let raw = '';
req.on('data', (c) => { raw += c; if (raw.length > 200000) { reject(new Error('body_too_large')); req.destroy(); } });
req.on('end', () => { try { resolve(raw ? JSON.parse(raw) : {}); } catch (e) { reject(e); } });
req.on('error', reject);
});
const send = (res, status, obj) => {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(obj));
};
const server = http.createServer(async (req, res) => {
if (TOKEN && req.headers['x-oasis-ai-token'] !== TOKEN) return send(res, 403, { error: 'forbidden' });
if (req.method === 'GET' && req.url === '/status') {
if (!state.ready && !state.loading && !state.error) initModel();
return send(res, 200, { ...state });
}
if (req.method === 'POST' && req.url === '/ai') {
let body;
try { body = await readJson(req); } catch (e) { return send(res, 400, { error: 'bad_request' }); }
const job = queue.then(() => answer(body));
queue = job.catch(() => {});
try {
const text = await job;
return send(res, 200, { answer: text });
} catch (e) {
const msg = String((e && e.message) || e);
const status = msg.startsWith('model_missing') ? 503 : (msg === 'not_ready' ? 503 : 500);
log(`answer failed: ${msg}`);
return send(res, status, { error: msg, ...state });
}
}
send(res, 404, { error: 'not_found' });
});
let listenAttempts = 0;
server.on('error', (e) => {
if (e && e.code === 'EADDRINUSE' && listenAttempts < 20) {
listenAttempts += 1;
setTimeout(() => server.listen(PORT, '127.0.0.1'), 1000);
return;
}
process.exit(1);
});
server.listen(PORT, '127.0.0.1', () => { initModel(); });
const shutdown = () => { try { server.close(); } catch (_) {} process.exit(0); };
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
if (PARENT_PID > 0) {
setInterval(() => {
try { process.kill(PARENT_PID, 0); } catch (_) { shutdown(); }
}, 5000).unref();
}