Files
zhila/reference/demo/server.js
T

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('');
});