279 lines
11 KiB
JavaScript
279 lines
11 KiB
JavaScript
|
|
/**
|
|||
|
|
* 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 · 映川对话 · 系统状态)');
|
|||
|
|
};
|