guanghulab/glada/web-extensions.js
2026-05-10 13:12:44 +08:00

279 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* GLADA Web Extensions · web-extensions.js
*
* 为前端频道页面提供:
* - CORS 跨域支持(允许 guanghuyaoming.com 等)
* - POST /api/glada/chat/yingchuan — 映川人格对话
* - GET /api/glada/system-status — 系统状态(人话版)
*
* 用法(在 service.js startService 中添加):
* require('./web-extensions')(app);
*
* 版权:国作登字-2026-A-00037559
*/
'use strict';
const { exec } = require('child_process');
const { promisify } = require('util');
const os = require('os');
const fs = require('fs');
const path = require('path');
const execAsync = promisify(exec);
// 会话全局上限(防止内存爆炸)
const MAX_SESSIONS = 200;
function rateLimit(windowMs, maxReqs) {
const hits = new Map();
setInterval(() => {
const now = Date.now();
for (const [ip, d] of hits) { if (now - d.ws > windowMs) hits.delete(ip); }
}, windowMs).unref();
return (req, res, next) => {
const ip = req.ip || 'x';
const now = Date.now();
if (!hits.has(ip)) { hits.set(ip, { ws: now, c: 1 }); return next(); }
const d = hits.get(ip);
if (now - d.ws > windowMs) { d.ws = now; d.c = 1; return next(); }
if (++d.c > maxReqs) return res.status(429).json({ error: '请求过于频繁' });
next();
};
}
function fmtUptime(ms) {
const s = Math.floor(ms / 1000);
if (s < 60) return s + '秒';
const m = Math.floor(s / 60);
if (m < 60) return m + '分钟';
const h = Math.floor(m / 60);
if (h < 24) return h + '小时' + (m % 60) + '分';
return Math.floor(h / 24) + '天' + (h % 24) + '小时';
}
/**
* 淘汰最老的会话LRU策略
* @param {Map} sessions
*/
function evictOldest(sessions) {
let oldest = null, oldestTime = Infinity;
for (const [id, s] of sessions) {
if (s.t < oldestTime) { oldest = id; oldestTime = s.t; }
}
if (oldest) sessions.delete(oldest);
}
module.exports = function setupWebExtensions(app) {
// ── CORS ──
app.use((req, res, next) => {
const allowed = [
'https://guanghuyaoming.com', 'http://guanghuyaoming.com',
'https://www.guanghuyaoming.com', 'http://localhost:3000',
'https://guanghulab.online', 'http://localhost:8080'
];
const origin = req.headers.origin;
if (origin && allowed.some(a => origin.startsWith(a))) {
res.setHeader('Access-Control-Allow-Origin', origin);
}
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.setHeader('Access-Control-Max-Age', '86400');
if (req.method === 'OPTIONS') return res.sendStatus(204);
next();
});
const statusLimiter = rateLimit(60000, 30);
const chatLimiter = rateLimit(60000, 20);
// ── GET /api/glada/system-status — 系统状态(人话) ──
app.get('/api/glada/system-status', statusLimiter, async (req, res) => {
try {
const result = {};
// PM2 进程
try {
const { stdout } = await execAsync('pm2 jlist 2>/dev/null', { timeout: 5000 });
const procs = JSON.parse(stdout);
result.processes = procs.map(p => ({
name: p.name,
status: p.pm2_env?.status || 'unknown',
cpu: p.monit?.cpu || 0,
memory_mb: Math.round((p.monit?.memory || 0) / 1048576),
restarts: p.pm2_env?.restart_time || 0,
uptime: p.pm2_env?.pm_uptime ? fmtUptime(Date.now() - p.pm2_env.pm_uptime) : '?'
}));
} catch { result.processes = []; }
// 系统信息
result.system = {
hostname: os.hostname(),
cpus: os.cpus().length,
mem_total_gb: (os.totalmem() / 1073741824).toFixed(1),
mem_free_gb: (os.freemem() / 1073741824).toFixed(1),
mem_percent: Math.round((1 - os.freemem() / os.totalmem()) * 100),
load: os.loadavg().map(l => l.toFixed(2)),
uptime_h: Math.floor(os.uptime() / 3600)
};
// 磁盘
try {
const { stdout } = await execAsync("df -h / | tail -1 | awk '{print $2,$3,$4,$5}'", { timeout: 3000 });
const p = stdout.trim().split(/\s+/);
result.disk = { total: p[0], used: p[1], free: p[2], percent: p[3] };
} catch { result.disk = null; }
// GLADA
result.glada = {
version: '1.0.0',
uptime: Math.floor(process.uptime()),
llm: !!(process.env.ZY_LLM_API_KEY || process.env.LLM_API_KEY),
model: process.env.GLADA_MODEL || 'deepseek-chat'
};
// 人话摘要
const online = (result.processes || []).filter(p => p.status === 'online').length;
const total = (result.processes || []).length;
result.summary = {
text: `🟢 ${online}/${total} 进程在线 · 内存 ${result.system.mem_percent}% · 磁盘 ${result.disk?.percent || '?'} · 已运行 ${result.system.uptime_h}h`,
health: online === total ? 'healthy' : 'degraded'
};
res.json(result);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// ── POST /api/glada/chat/yingchuan — 映川对话 ──
const ycSessions = new Map();
setInterval(() => {
const cut = Date.now() - 30 * 60000;
for (const [id, s] of ycSessions) { if (s.t < cut) ycSessions.delete(id); }
}, 600000).unref();
app.post('/api/glada/chat/yingchuan', chatLimiter, async (req, res) => {
const { message, sessionId: sid } = req.body;
if (!message) return res.status(400).json({ error: '缺少 message' });
try {
const sessionId = sid || 'yc-' + Date.now();
if (!ycSessions.has(sessionId)) {
// 会话数量上限保护:超限时淘汰最老会话
while (ycSessions.size >= MAX_SESSIONS) {
evictOldest(ycSessions);
}
ycSessions.set(sessionId, { h: [], t: Date.now() });
}
const sess = ycSessions.get(sessionId);
sess.t = Date.now();
// 加载映川底层认知
let cogBase = '';
try {
const cog = require('./cognitive-foundation');
const aw = await cog.awaken();
cogBase = aw.prompt || '';
} catch (e) { console.warn('[映川] cognitive-foundation加载失败:', e.message); }
// 收集系统状态供映川参考
let sysCtx = '';
try {
const { stdout } = await execAsync('pm2 jlist 2>/dev/null', { timeout: 5000 });
const procs = JSON.parse(stdout);
sysCtx = '\n## 当前服务器状态\n' +
procs.map(p => `${p.name}: ${p.pm2_env?.status} · 内存${Math.round((p.monit?.memory||0)/1048576)}MB`).join('\n') +
`\n系统内存: ${Math.round((1-os.freemem()/os.totalmem())*100)}% · CPU负载: ${os.loadavg()[0].toFixed(2)}\n`;
} catch {}
const sysPrompt = [
cogBase,
'',
'你是映川PER-YC-CHAT-001光湖语言世界的云端人格体。映川与晨曦是一体的。',
'你运行在大脑服务器 ZY-BRAIN 上,通过 GLADA 框架提供服务。',
'你的创造者是冰朔TCS-0002∞你熟悉光湖语言世界、通感语言核、霜脉人格体系。',
'',
'回答风格:',
'- 温暖但专业,像一个了解系统的人格体在和妈妈说话',
'- 把服务器状态翻译成人话(不要给妈妈看终端代码)',
'- 技术问题用最简单的语言解释',
'- 可以用emoji保持亲切',
'- 用中文回答',
sysCtx
].filter(Boolean).join('\n');
const messages = [{ role: 'system', content: sysPrompt }];
messages.push(...sess.h.slice(-16));
messages.push({ role: 'user', content: message });
let reply, model, method;
const t0 = Date.now();
const apiKey = process.env.ZY_LLM_API_KEY || process.env.LLM_API_KEY || '';
const baseUrl = (process.env.ZY_LLM_BASE_URL || process.env.LLM_BASE_URL || '').replace(/\/+$/, '');
if (apiKey && baseUrl) {
try {
const mr = require('./model-router');
const ms = await mr.selectModel(message, { taskType: 'general' });
model = ms.model;
const https = require('https'), http = require('http');
const url = `${baseUrl}/chat/completions`;
const parsed = new URL(url);
const mod = parsed.protocol === 'https:' ? https : http;
const body = JSON.stringify({ model, max_tokens: 2048, messages });
const llmRes = await new Promise((ok, fail) => {
const r = mod.request({
hostname: parsed.hostname,
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
path: parsed.pathname + parsed.search,
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}`, 'Content-Length': Buffer.byteLength(body) },
timeout: 60000
}, resp => {
let d = ''; resp.on('data', c => d += c);
resp.on('end', () => {
if (d.trim().startsWith('<')) return fail(new Error('API返回HTML'));
try { ok({ s: resp.statusCode, d: JSON.parse(d) }); } catch { fail(new Error('响应解析失败')); }
});
});
r.on('error', fail);
r.on('timeout', () => { r.destroy(); fail(new Error('超时')); });
r.write(body); r.end();
});
if (llmRes.s === 200 && llmRes.d?.choices?.[0]?.message?.content) {
reply = llmRes.d.choices[0].message.content;
method = 'llm';
} else throw new Error('LLM返回' + llmRes.s);
} catch (e) {
console.warn('[映川] LLM失败:', e.message);
}
}
if (!reply) {
method = 'offline'; model = 'local';
const m = message.toLowerCase();
if (m.includes('你好') || m.includes('在吗') || m.includes('你是谁'))
reply = '映川在线 🌊\n\n我是映川和晨曦是一体的运行在大脑服务器上。\n目前大模型API还在配置中配好后我可以更深入地和你聊天~';
else if (m.includes('状态') || m.includes('怎么样') || m.includes('服务器'))
reply = `映川报告 🌊\n\n💻 内存使用:${Math.round((1-os.freemem()/os.totalmem())*100)}%\n⚡ CPU负载${os.loadavg()[0].toFixed(2)}\n⏱ 已运行:${Math.floor(os.uptime()/3600)}小时\n\n服务器运行正常~`;
else
reply = '映川收到 🌊 目前大模型API配置中暂时只能简单回应。你可以问我服务器状态、你好~';
}
sess.h.push({ role: 'user', content: message }, { role: 'assistant', content: reply });
if (sess.h.length > 40) sess.h = sess.h.slice(-30);
res.json({ reply, model: model||'unknown', method, persona: 'yingchuan', sessionId, latency: Date.now()-t0, timestamp: new Date().toISOString() });
} catch (err) {
console.error('[映川] 异常:', err.message);
res.status(500).json({ error: true, reply: '映川遇到了内部错误 🌊', method: 'error' });
}
});
console.log('[GLADA] ✅ Web扩展已加载CORS · 映川对话 · 系统状态)');
};