231 lines
7.7 KiB
JavaScript
231 lines
7.7 KiB
JavaScript
/* ZHILA · local server
|
|
serves the instrument and runs the choir: a model with tools that act in the page. */
|
|
|
|
'use strict';
|
|
|
|
const http = require('http');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { SYSTEM, TOOLS } = require('./agent-tools');
|
|
|
|
const ROOT = __dirname;
|
|
const PORT = Number(process.env.PORT || 4173);
|
|
const HOST = '127.0.0.1';
|
|
const MODEL = process.env.ZHILA_MODEL || 'deepseek-chat';
|
|
const BASE = process.env.ZHILA_BASE_URL || 'https://api.deepseek.com';
|
|
const KEY = process.env.DEEPSEEK_API_KEY || '';
|
|
|
|
const MIME = {
|
|
'.html': 'text/html; charset=utf-8',
|
|
'.css': 'text/css; charset=utf-8',
|
|
'.js': 'text/javascript; charset=utf-8',
|
|
'.json': 'application/json; charset=utf-8',
|
|
'.png': 'image/png',
|
|
'.jpg': 'image/jpeg',
|
|
'.jpeg': 'image/jpeg',
|
|
'.svg': 'image/svg+xml',
|
|
'.mp3': 'audio/mpeg',
|
|
'.wav': 'audio/wav',
|
|
'.woff2': 'font/woff2',
|
|
'.ico': 'image/x-icon',
|
|
'.md': 'text/plain; charset=utf-8'
|
|
};
|
|
|
|
function send(res, code, type, body) {
|
|
res.writeHead(code, {
|
|
'Content-Type': type,
|
|
'Cache-Control': 'no-store',
|
|
'Access-Control-Allow-Headers': 'content-type',
|
|
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS'
|
|
});
|
|
res.end(body);
|
|
}
|
|
|
|
function json(res, code, obj) {
|
|
send(res, code, 'application/json; charset=utf-8', JSON.stringify(obj));
|
|
}
|
|
|
|
function serveStatic(req, res, pathname) {
|
|
let rel = pathname === '/' ? '/index.html' : pathname;
|
|
try { rel = decodeURIComponent(rel); } catch (e) { return send(res, 400, 'text/plain', 'bad path'); }
|
|
const file = path.resolve(ROOT, '.' + rel);
|
|
if (!file.startsWith(ROOT + path.sep) || file === ROOT) return send(res, 403, 'text/plain', 'forbidden');
|
|
if (!MIME[path.extname(file).toLowerCase()]) return send(res, 404, 'text/plain', 'not found');
|
|
fs.readFile(file, function (err, data) {
|
|
if (err) return send(res, 404, 'text/plain', 'not found');
|
|
send(res, 200, MIME[path.extname(file).toLowerCase()], data);
|
|
});
|
|
}
|
|
|
|
let cachedKey = KEY;
|
|
|
|
/* The key may be exported to the launching environment; otherwise the start script injects it. */
|
|
if (!cachedKey) {
|
|
console.warn('[zhila] no DEEPSEEK_API_KEY — the agent will run in local mode');
|
|
}
|
|
|
|
function normaliseMessages(input) {
|
|
if (!Array.isArray(input)) throw new Error('messages must be an array');
|
|
if (input.length > 80) throw new Error('conversation too long');
|
|
let chars = 0;
|
|
return input.map(function (m) {
|
|
if (!m || typeof m !== 'object') throw new Error('bad message');
|
|
const role = m.role;
|
|
if (!['user', 'assistant', 'tool'].includes(role)) throw new Error('bad role');
|
|
const out = { role: role };
|
|
if (typeof m.content === 'string') {
|
|
if (m.content.length > 12000) throw new Error('message too long');
|
|
chars += m.content.length;
|
|
out.content = m.content;
|
|
} else if (m.content === null) {
|
|
out.content = null;
|
|
} else {
|
|
out.content = '';
|
|
}
|
|
if (role === 'tool') {
|
|
if (typeof m.tool_call_id !== 'string') throw new Error('tool message without id');
|
|
out.tool_call_id = m.tool_call_id;
|
|
if (m.name) out.name = String(m.name).slice(0, 80);
|
|
}
|
|
if (role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length) {
|
|
out.tool_calls = m.tool_calls.map(function (tc) {
|
|
if (!tc || tc.type !== 'function' || !tc.function || typeof tc.function.name !== 'string') {
|
|
throw new Error('bad tool call');
|
|
}
|
|
return {
|
|
id: String(tc.id || '').slice(0, 80),
|
|
type: 'function',
|
|
function: {
|
|
name: tc.function.name,
|
|
arguments: typeof tc.function.arguments === 'string' ? tc.function.arguments.slice(0, 8000) : '{}'
|
|
}
|
|
};
|
|
});
|
|
}
|
|
return out;
|
|
}).filter(function (m) {
|
|
return m.content && m.content.length || m.tool_calls && m.tool_calls.length || m.role === 'tool';
|
|
}).map(function (m) {
|
|
if (chars > 200000) throw new Error('conversation too large');
|
|
return m;
|
|
});
|
|
}
|
|
|
|
async function callModel(messages) {
|
|
if (!cachedKey) {
|
|
const err = new Error('no API key');
|
|
err.code = 'NO_KEY';
|
|
throw err;
|
|
}
|
|
const body = {
|
|
model: MODEL,
|
|
messages: [{ role: 'system', content: SYSTEM }].concat(messages),
|
|
tools: TOOLS,
|
|
tool_choice: 'auto',
|
|
temperature: 0.75,
|
|
max_tokens: 900,
|
|
stream: false
|
|
};
|
|
const res = await fetch(BASE.replace(/\/+$/, '') + '/chat/completions', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': 'Bearer ' + cachedKey,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(body),
|
|
signal: AbortSignal.timeout(90000)
|
|
});
|
|
const text = await res.text();
|
|
let data;
|
|
try { data = JSON.parse(text); } catch (e) { throw new Error('model returned non-JSON (' + res.status + ')'); }
|
|
if (!res.ok) {
|
|
const msg = data && data.error && data.error.message ? data.error.message : 'model error ' + res.status;
|
|
throw new Error(msg);
|
|
}
|
|
const choice = data.choices && data.choices[0];
|
|
if (!choice || !choice.message) throw new Error('model returned no message');
|
|
const m = choice.message;
|
|
return {
|
|
role: 'assistant',
|
|
content: typeof m.content === 'string' ? m.content : '',
|
|
tool_calls: Array.isArray(m.tool_calls) && m.tool_calls.length ? m.tool_calls : null
|
|
};
|
|
}
|
|
|
|
function readBody(req) {
|
|
return new Promise(function (resolve, reject) {
|
|
let size = 0;
|
|
const chunks = [];
|
|
req.on('data', function (c) {
|
|
size += c.length;
|
|
if (size > 400000) { reject(new Error('body too large')); req.destroy(); return; }
|
|
chunks.push(c);
|
|
});
|
|
req.on('end', function () {
|
|
try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')); }
|
|
catch (e) { reject(new Error('bad JSON')); }
|
|
});
|
|
req.on('error', reject);
|
|
});
|
|
}
|
|
|
|
function originAllowed(req) {
|
|
const o = req.headers.origin;
|
|
if (!o) return null;
|
|
if (o === 'null') return 'null';
|
|
if (/^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/.test(o)) return o;
|
|
return false;
|
|
}
|
|
|
|
const server = http.createServer(async function (req, res) {
|
|
const url = new URL(req.url, 'http://' + req.headers.host);
|
|
const origin = originAllowed(req);
|
|
if (origin) {
|
|
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
res.setHeader('Vary', 'Origin');
|
|
}
|
|
if (req.method === 'OPTIONS') return send(res, 204, 'text/plain', '');
|
|
if (url.pathname.startsWith('/api/') && origin === false) {
|
|
return json(res, 403, { error: 'origin not allowed' });
|
|
}
|
|
|
|
if (url.pathname === '/api/agent' && req.method === 'GET') {
|
|
return json(res, 200, {
|
|
live: !!cachedKey,
|
|
mode: cachedKey ? 'model' : 'local',
|
|
model: cachedKey ? MODEL : null
|
|
});
|
|
}
|
|
|
|
if (url.pathname === '/api/agent' && req.method === 'POST') {
|
|
let body;
|
|
try { body = await readBody(req); }
|
|
catch (e) { return json(res, 400, { error: e.message }); }
|
|
let messages;
|
|
try { messages = normaliseMessages(body.messages); }
|
|
catch (e) { return json(res, 400, { error: e.message }); }
|
|
try {
|
|
const message = await callModel(messages);
|
|
return json(res, 200, { message: message, model: MODEL });
|
|
} catch (e) {
|
|
if (e.code === 'NO_KEY') {
|
|
return json(res, 503, { error: 'agent offline: no API key on the server', mode: 'local' });
|
|
}
|
|
console.error('[zhila] model error:', e.message);
|
|
return json(res, 502, { error: e.message || 'model error' });
|
|
}
|
|
}
|
|
|
|
if (url.pathname.startsWith('/api/')) return json(res, 404, { error: 'no such endpoint' });
|
|
|
|
if (req.method !== 'GET' && req.method !== 'HEAD') return send(res, 405, 'text/plain', 'method not allowed');
|
|
serveStatic(req, res, url.pathname);
|
|
});
|
|
|
|
server.listen(PORT, HOST, function () {
|
|
console.log('');
|
|
console.log(' ZHILA · http://' + HOST + ':' + PORT);
|
|
console.log(' agent · ' + (cachedKey ? MODEL + ' (live)' : 'local conductor (no key)'));
|
|
console.log('');
|
|
});
|