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 · 映川对话 · 系统状态)');
|
||
};
|