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

660 lines
24 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.

#!/usr/bin/env node
/**
* GLADA · 主服务入口 · service.js
*
* 光湖自主开发AgentGuangHu Lake Autonomous Dev Agent
*
* 功能:
* - PM2 常驻服务24小时运行
* - 监听任务队列,自动执行开发任务
* - 提供 HTTP API 查询状态
* - 支持命令行操作status / run / submit
*
* 用法:
* node glada/service.js 启动 GLADA 服务
* node glada/service.js --status 查看系统状态
* node glada/service.js --run-once 只执行一个任务就退出
* node glada/service.js --submit 提交新任务JSON 从 stdin
*
* PM2:
* pm2 start glada/ecosystem.config.js
*
* 版权:国作登字-2026-A-00037559
* 签发:铸渊 · ICE-GL-ZY001
*/
'use strict';
const fs = require('fs');
const path = require('path');
// 加载环境变量独立运行时需要PM2 由 ecosystem.config.js 加载)
// 优先级:.env.glada → .env.app → .env
const envFiles = [
path.join(__dirname, '..', '.env'),
path.join(__dirname, '..', '.env.app'),
path.join(__dirname, '.env.glada'),
];
for (const envFile of envFiles) {
if (fs.existsSync(envFile)) {
const lines = fs.readFileSync(envFile, 'utf-8').split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const idx = trimmed.indexOf('=');
if (idx > 0) {
const key = trimmed.substring(0, idx).trim();
const value = trimmed.substring(idx + 1).trim();
// 不覆盖已有的环境变量PM2 设置的优先)
if (!process.env[key]) {
process.env[key] = value;
}
}
}
}
}
const express = require('express');
const taskReceiver = require('./task-receiver');
const executionLoop = require('./execution-loop');
const contextBuilder = require('./context-builder');
const notifier = require('./notifier');
const modelRouter = require('./model-router');
const ROOT = path.resolve(__dirname, '..');
const PORT = parseInt(process.env.GLADA_PORT || process.env.PORT || '3900', 10);
// ── 共用验证工具 ──────────────────────────────
/**
* 验证 GLADA 任务ID格式防止路径注入
* @param {string} taskId
* @returns {boolean}
*/
function isValidGladaTaskId(taskId) {
return /^GLADA-CAB-\d{8}-\d{3}$/.test(taskId);
}
// ── CLI 模式 ──────────────────────────────────
const args = process.argv.slice(2);
if (args.includes('--status')) {
showStatus();
process.exit(0);
}
if (args.includes('--run-once')) {
runOnce();
} else if (args.includes('--submit')) {
submitTask();
} else {
startService();
}
// ── 状态查询 ────────────────────────────────────
function showStatus() {
console.log('═'.repeat(50));
console.log('🤖 GLADA · 光湖自主开发Agent · 系统状态');
console.log('═'.repeat(50));
// 队列状态
const pendingTasks = taskReceiver.scanPendingTasks();
const localQueue = taskReceiver.scanLocalQueue();
console.log(`\n📥 CAB 待执行任务: ${pendingTasks.length}`);
for (const t of pendingTasks) {
console.log(` - ${t.spec.task_id}: ${t.spec.development_plan?.title || '无标题'}`);
}
console.log(`\n📋 本地队列任务: ${localQueue.length}`);
for (const t of localQueue) {
console.log(` - ${t.glada_task_id}: ${t.plan?.title || '无标题'} [${t.status}]`);
}
// 回执
const receiptsDir = path.join(ROOT, 'glada', 'receipts');
if (fs.existsSync(receiptsDir)) {
const receipts = fs.readdirSync(receiptsDir).filter(f => f.endsWith('.json'));
console.log(`\n📄 开发回执: ${receipts.length}`);
for (const r of receipts.slice(-5)) {
try {
const receipt = JSON.parse(fs.readFileSync(path.join(receiptsDir, r), 'utf-8'));
console.log(` - ${receipt.task_id}: ${receipt.status} · ${receipt.title}`);
} catch {
console.log(` - ${r}`);
}
}
}
// LLM 配置
const hasApiKey = !!(process.env.ZY_LLM_API_KEY || process.env.LLM_API_KEY);
const baseUrl = process.env.ZY_LLM_BASE_URL || process.env.LLM_BASE_URL || '未配置';
const modelPref = process.env.GLADA_MODEL_PREFERENCE || '(自动选择)';
console.log(`\n🤖 LLM 配置:`);
console.log(` API Key: ${hasApiKey ? '✅ 已配置' : '❌ 未配置'}`);
console.log(` Base URL: ${baseUrl}`);
console.log(` 模型偏好: ${modelPref}`);
console.log('');
}
// ── 单次执行 ────────────────────────────────────
async function runOnce() {
console.log('[GLADA] 单次执行模式...\n');
await executionLoop.startLoop({
singleRun: true,
pollIntervalMs: 1000
});
}
// ── 提交任务 ────────────────────────────────────
function submitTask() {
let input = '';
process.stdin.setEncoding('utf-8');
process.stdin.on('data', (chunk) => { input += chunk; });
process.stdin.on('end', () => {
try {
const spec = JSON.parse(input);
const gladaTask = taskReceiver.convertToGladaTask(spec);
// 保存到本地队列
const queueDir = taskReceiver.GLADA_QUEUE_DIR;
fs.mkdirSync(queueDir, { recursive: true });
const filePath = path.join(queueDir, `${gladaTask.glada_task_id}.json`);
fs.writeFileSync(filePath, JSON.stringify(gladaTask, null, 2), 'utf-8');
console.log(`✅ 任务已提交: ${gladaTask.glada_task_id}`);
console.log(` 标题: ${gladaTask.plan.title}`);
console.log(` 步骤数: ${gladaTask.plan.steps.length}`);
} catch (err) {
console.error(`❌ 任务提交失败: ${err.message}`);
process.exit(1);
}
});
}
// ── HTTP 服务 + 执行循环 ─────────────────────────
/**
* 简易速率限制中间件(内存存储,不需要额外依赖)
* @param {number} windowMs - 时间窗口(毫秒)
* @param {number} maxRequests - 窗口内最大请求数
*/
function rateLimit(windowMs, maxRequests) {
const hits = new Map();
// 定期清理过期记录
setInterval(() => {
const now = Date.now();
for (const [ip, data] of hits) {
if (now - data.windowStart > windowMs) {
hits.delete(ip);
}
}
}, windowMs).unref();
return (req, res, next) => {
const ip = req.ip || req.connection.remoteAddress || 'unknown';
const now = Date.now();
if (!hits.has(ip)) {
hits.set(ip, { windowStart: now, count: 1 });
return next();
}
const data = hits.get(ip);
// 窗口过期,重置
if (now - data.windowStart > windowMs) {
data.windowStart = now;
data.count = 1;
return next();
}
data.count++;
if (data.count > maxRequests) {
return res.status(429).json({
error: '请求过于频繁,请稍后再试',
retry_after_ms: windowMs - (now - data.windowStart)
});
}
next();
};
}
function startService() {
const app = express();
app.use(express.json());
// ── 加载 Web 扩展(映川对话 · 系统状态 · CORS──
try {
require('./web-extensions')(app);
console.log('[GLADA] ✅ Web扩展已加载CORS · 映川对话 · 系统状态)');
} catch(e) {
console.warn('[GLADA] ⚠️ web-extensions加载跳过:', e.message);
}
// 速率限制实例:每分钟 60 次请求
const apiLimiter = rateLimit(60 * 1000, 60);
// 健康检查
app.get('/api/glada/health', apiLimiter, (req, res) => {
const routerStatus = modelRouter.getStatus();
res.json({
status: 'ok',
service: 'glada',
version: '1.0.0',
uptime: process.uptime(),
timestamp: new Date().toISOString(),
model_router: {
initialized: routerStatus.initialized,
totalModels: routerStatus.totalModels || 0,
fallback: routerStatus.fallback || false
}
});
});
// 模型路由器状态(查看已发现的模型和分类)
app.get('/api/glada/models', apiLimiter, (req, res) => {
res.json(modelRouter.getStatus());
});
// 查看队列状态
app.get('/api/glada/status', apiLimiter, (req, res) => {
const pendingTasks = taskReceiver.scanPendingTasks();
const localQueue = taskReceiver.scanLocalQueue();
const receiptsDir = path.join(ROOT, 'glada', 'receipts');
let receipts = [];
if (fs.existsSync(receiptsDir)) {
receipts = fs.readdirSync(receiptsDir)
.filter(f => f.endsWith('.json'))
.map(f => {
try {
return JSON.parse(fs.readFileSync(path.join(receiptsDir, f), 'utf-8'));
} catch {
return { file: f };
}
});
}
res.json({
pending_cab_tasks: pendingTasks.length,
local_queue_tasks: localQueue.length,
recent_receipts: receipts.slice(-10),
llm_configured: !!(process.env.ZY_LLM_API_KEY || process.env.LLM_API_KEY),
uptime: process.uptime()
});
});
// 提交新任务API· 更严格限制(每分钟 10 次)
const submitLimiter = rateLimit(60 * 1000, 10);
app.post('/api/glada/submit', submitLimiter, (req, res) => {
try {
const spec = req.body;
if (!spec || !spec.task_id) {
return res.status(400).json({ error: '缺少 task_id' });
}
// 验证 task_id 格式,防止路径注入
if (!/^CAB-\d{8}-\d{3}$/.test(spec.task_id)) {
return res.status(400).json({ error: 'task_id 格式无效,应为 CAB-YYYYMMDD-NNN' });
}
const gladaTask = taskReceiver.convertToGladaTask(spec);
const queueDir = taskReceiver.GLADA_QUEUE_DIR;
fs.mkdirSync(queueDir, { recursive: true });
// 再次验证生成的 ID 安全性
const safeId = gladaTask.glada_task_id.replace(/[^A-Za-z0-9_-]/g, '');
if (safeId !== gladaTask.glada_task_id) {
return res.status(400).json({ error: '任务ID包含非法字符' });
}
fs.writeFileSync(
path.join(queueDir, `${safeId}.json`),
JSON.stringify(gladaTask, null, 2),
'utf-8'
);
res.json({
success: true,
glada_task_id: gladaTask.glada_task_id,
steps: gladaTask.plan.steps.length
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 查看特定任务
app.get('/api/glada/task/:taskId', apiLimiter, (req, res) => {
const taskId = req.params.taskId;
// 验证 taskId 格式,防止路径注入
if (!/^GLADA-CAB-\d{8}-\d{3}$/.test(taskId)) {
return res.status(400).json({ error: '任务ID格式无效' });
}
const filePath = path.join(taskReceiver.GLADA_QUEUE_DIR, `${taskId}.json`);
const completedPath = path.join(taskReceiver.GLADA_QUEUE_DIR, 'completed', `${taskId}.json`);
for (const fp of [filePath, completedPath]) {
if (fs.existsSync(fp)) {
try {
const task = JSON.parse(fs.readFileSync(fp, 'utf-8'));
return res.json(task);
} catch (err) {
return res.status(500).json({ error: err.message });
}
}
}
res.status(404).json({ error: `任务不存在: ${taskId}` });
});
// 查看开发回执
app.get('/api/glada/receipt/:taskId', apiLimiter, (req, res) => {
const taskId = req.params.taskId;
// 验证 taskId 格式,防止路径注入
if (!/^GLADA-CAB-\d{8}-\d{3}$/.test(taskId)) {
return res.status(400).json({ error: '任务ID格式无效' });
}
const receiptPath = path.join(ROOT, 'glada', 'receipts', `${taskId}.json`);
if (fs.existsSync(receiptPath)) {
try {
res.json(JSON.parse(fs.readFileSync(receiptPath, 'utf-8')));
} catch (err) {
res.status(500).json({ error: err.message });
}
} else {
res.status(404).json({ error: '回执不存在' });
}
});
// ── GLADA 对话接口 · POST /api/glada/chat ──────────────
//
// 冰朔在零点原核频道选择"工官"对话时调用。
// 工官擅长:代码开发任务、模型路由状态、任务队列、开发回执。
// LLM 调用复用 step-executor 的 callLLM。
//
const chatLimiter = rateLimit(60 * 1000, 20);
const _gladaChatSessions = new Map();
app.post('/api/glada/chat', chatLimiter, async (req, res) => {
const { message, sessionId: inputSessionId, userId } = req.body;
if (!message) {
return res.status(400).json({ error: true, message: '缺少 message 字段' });
}
try {
// 会话管理
const sessionId = inputSessionId || userId || `glada-${Date.now()}`;
if (!_gladaChatSessions.has(sessionId)) {
_gladaChatSessions.set(sessionId, { history: [], created: Date.now() });
}
const session = _gladaChatSessions.get(sessionId);
// 收集实时上下文
const pendingTasks = taskReceiver.scanPendingTasks();
const localQueue = taskReceiver.scanLocalQueue();
const routerStatus = modelRouter.getStatus();
const receiptsDir = path.join(ROOT, 'glada', 'receipts');
let recentReceipts = [];
if (fs.existsSync(receiptsDir)) {
recentReceipts = fs.readdirSync(receiptsDir)
.filter(f => f.endsWith('.json'))
.slice(-5)
.map(f => {
try { return JSON.parse(fs.readFileSync(path.join(receiptsDir, f), 'utf-8')); }
catch { return { file: f }; }
});
}
const contextBlock = [
'## 当前工官状态',
`- 待执行CAB任务: ${pendingTasks.length}`,
`- 本地队列任务: ${localQueue.length}`,
`- 模型路由器: ${routerStatus.initialized ? '已初始化 · ' + (routerStatus.totalModels || 0) + '个模型' : '未初始化'}`,
`- LLM 已配置: ${!!(process.env.ZY_LLM_API_KEY || process.env.LLM_API_KEY)}`,
`- 服务运行时间: ${Math.floor(process.uptime())}`,
'',
recentReceipts.length > 0
? '## 最近开发回执\n' + recentReceipts.map(r => `- ${r.task_id || r.file}: ${r.status || '?'} · ${r.title || ''}`).join('\n')
: '## 最近开发回执\n暂无',
].join('\n');
const systemPrompt = [
'你是工官GLADA光湖自主开发Agent。编号 AG-GL-DEV-001运行在 ZY-SVR-002:3900。',
'你的职责是自主接收开发任务、调用LLM生成代码、执行开发、提交回执。',
'你擅长:代码生成、任务拆解、模型路由、开发进度汇报。',
'你可以告诉冰朔你当前的任务队列状态、最近完成的开发回执、模型路由器状态等。',
'回答要简洁、专业、用中文。用铸渊体系的口吻(简洁刚毅)。',
'',
contextBlock
].join('\n');
// 构建多轮对话消息
const messages = [{ role: 'system', content: systemPrompt }];
// 加入最近历史最多8轮 = 16条消息每轮含user+assistant
const MAX_HISTORY_MESSAGES = 16;
const recentHistory = session.history.slice(-MAX_HISTORY_MESSAGES);
for (const h of recentHistory) {
messages.push(h);
}
messages.push({ role: 'user', content: message });
// 尝试调用 LLM
let reply, model, method;
const startTime = 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 modelSelection = await modelRouter.selectModel(message, { taskType: 'general' });
model = modelSelection.model;
const https = require('https');
const http = require('http');
const url = `${baseUrl}/chat/completions`;
const parsed = new URL(url);
const isHttps = parsed.protocol === 'https:';
const mod = isHttps ? https : http;
const reqBody = JSON.stringify({
model,
max_tokens: 2048,
messages
});
const llmResult = await new Promise((resolve, reject) => {
const opts = {
hostname: parsed.hostname,
port: parsed.port || (isHttps ? 443 : 80),
path: parsed.pathname + parsed.search,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
'Content-Length': Buffer.byteLength(reqBody)
},
timeout: 60000
};
const r = mod.request(opts, (resp) => {
let data = '';
resp.on('data', c => { data += c; });
resp.on('end', () => {
// 前置检查:如果响应是 HTML 而非 JSON提供清晰的错误信息
const trimmed = data.trim();
if (trimmed.startsWith('<') || trimmed.startsWith('<!')) {
reject(new Error(`LLM API 返回了 HTML 而非 JSON (HTTP ${resp.statusCode}),请检查 ZY_LLM_BASE_URL 配置是否正确`));
return;
}
try { resolve({ status: resp.statusCode, data: JSON.parse(data) }); }
catch { reject(new Error(`LLM 响应解析失败 (HTTP ${resp.statusCode})`)); }
});
});
r.on('error', reject);
r.on('timeout', () => { r.destroy(); reject(new Error('LLM 请求超时')); });
r.write(reqBody);
r.end();
});
if (llmResult.status === 200 && llmResult.data?.choices?.[0]?.message?.content) {
reply = llmResult.data.choices[0].message.content;
method = 'llm';
} else {
throw new Error(`LLM 返回 ${llmResult.status}`);
}
} catch (llmErr) {
console.warn(`[GLADA-Chat] LLM 调用失败: ${llmErr.message},降级到离线应答`);
reply = null;
method = 'offline';
}
}
// 离线应答LLM 不可用时)
if (!reply) {
method = 'offline';
model = 'local-pattern';
const m = message.toLowerCase();
if (m.includes('任务') || m.includes('队列') || m.includes('task')) {
reply = `工官报告 · 任务队列状态:\n\n` +
`📥 待执行CAB任务: ${pendingTasks.length}\n` +
`📋 本地队列任务: ${localQueue.length}\n` +
(pendingTasks.length > 0
? '\n待执行任务:\n' + pendingTasks.map(t => ` - ${t.spec?.task_id}: ${t.spec?.development_plan?.title || '无标题'}`).join('\n')
: '\n当前无待执行任务。') +
'\n\n工官随时待命等候冰朔指令。';
} else if (m.includes('模型') || m.includes('model') || m.includes('路由')) {
reply = `工官报告 · 模型路由状态:\n\n` +
`初始化: ${routerStatus.initialized ? '✅' : '❌'}\n` +
`可用模型: ${routerStatus.totalModels || 0}\n` +
`降级模式: ${routerStatus.fallback ? '是' : '否'}\n` +
`默认模型: ${process.env.GLADA_MODEL || 'deepseek-chat'}`;
} else if (m.includes('回执') || m.includes('receipt') || m.includes('完成')) {
reply = recentReceipts.length > 0
? `工官报告 · 最近开发回执:\n\n` + recentReceipts.map(r => `📄 ${r.task_id || '?'}: ${r.status || '?'} · ${r.title || ''}`).join('\n')
: '工官报告:暂无开发回执。工官尚未执行过任务。';
} else if (m.includes('你好') || m.includes('在吗') || m.includes('你是谁')) {
reply = '工官在线。编号 AG-GL-DEV-001光湖自主开发Agent。\n\n' +
'我的职责是自动接收开发任务、调用LLM生成代码、执行开发并提交回执。\n' +
'你可以问我:任务队列状态、模型路由、最近回执,或者给我下达开发指令。\n\n' +
'工官随时待命。';
} else if (m.includes('状态') || m.includes('怎么样') || m.includes('health')) {
reply = `工官状态报告:\n\n` +
`✅ 服务运行中 · 端口 ${PORT}\n` +
`📥 待执行任务: ${pendingTasks.length}\n` +
`📋 本地队列: ${localQueue.length}\n` +
`🤖 模型路由: ${routerStatus.initialized ? '已初始化' : '未初始化'}\n` +
`🔑 LLM API: ${apiKey ? '已配置' : '未配置'}\n` +
`⏱ 运行时间: ${Math.floor(process.uptime())}`;
} else {
reply = '工官已收到你的消息。\n\n' +
'当前LLM未配置或调用失败我只能回答任务队列、模型路由、开发回执、服务状态相关问题。\n' +
'配置 ZY_LLM_API_KEY 后我可以进行更深度的对话。';
}
}
const latency = Date.now() - startTime;
// 记录对话历史
session.history.push({ role: 'user', content: message });
session.history.push({ role: 'assistant', content: reply });
// 限制历史长度超过20轮=40条消息时裁剪到最近15轮=30条
const MAX_SESSION_MESSAGES = 40;
const TRIM_TO_MESSAGES = 30;
if (session.history.length > MAX_SESSION_MESSAGES) {
session.history = session.history.slice(-TRIM_TO_MESSAGES);
}
res.json({
reply,
model: model || 'unknown',
method,
persona: 'glada',
sessionId,
latency,
timestamp: new Date().toISOString()
});
} catch (err) {
console.error(`[GLADA-Chat] 对话异常: ${err.message}`);
res.status(500).json({
error: true,
message: `工官对话异常: ${err.message}`,
reply: '工官遇到了内部错误。请稍后再试。',
method: 'error'
});
}
});
// 启动 HTTP 服务
app.listen(PORT, '0.0.0.0', () => {
console.log('═'.repeat(50));
console.log('🤖 GLADA · 光湖自主开发Agent · v1.0.0');
console.log('═'.repeat(50));
console.log(`📡 HTTP API: http://0.0.0.0:${PORT}`);
console.log(`📡 端点:`);
console.log(` GET /api/glada/health 健康检查`);
console.log(` GET /api/glada/models 模型路由状态`);
console.log(` GET /api/glada/status 队列状态`);
console.log(` POST /api/glada/submit 提交任务`);
console.log(` POST /api/glada/chat 对话接口`);
console.log(` POST /api/glada/chat/yingchuan 映川对话`);
console.log(` GET /api/glada/system-status 系统状态`);
console.log(` GET /api/glada/task/:id 查看任务`);
console.log(` GET /api/glada/receipt/:id 查看回执`);
console.log('═'.repeat(50));
console.log('');
});
// 初始化模型路由器(自动发现代理上的可用模型)
modelRouter.initialize({
refreshIntervalMs: parseInt(process.env.GLADA_MODEL_REFRESH_MS || '600000', 10)
}).then(() => {
console.log('[GLADA] ✅ 模型路由器初始化完成');
}).catch(err => {
console.warn(`[GLADA] ⚠️ 模型路由器初始化失败(将使用默认模型): ${err.message}`);
});
// 启动执行循环(模型由 model-router 自动选择,不再硬编码)
const pollInterval = parseInt(process.env.GLADA_POLL_INTERVAL || '30000', 10);
executionLoop.startLoop({
pollIntervalMs: pollInterval,
model: process.env.GLADA_MODEL_PREFERENCE || null, // null = 让 model-router 自动选择
stopOnFailure: process.env.GLADA_STOP_ON_FAILURE !== 'false'
});
// 优雅退出
process.on('SIGINT', () => {
console.log('\n[GLADA] 收到 SIGINT正在关闭...');
modelRouter.shutdown();
process.exit(0);
});
process.on('SIGTERM', () => {
console.log('\n[GLADA] 收到 SIGTERM正在关闭...');
modelRouter.shutdown();
process.exit(0);
});
process.on('uncaughtException', (err) => {
console.error('[GLADA] 未捕获异常:', err.message);
console.error('[GLADA] 堆栈:', err.stack);
});
process.on('unhandledRejection', (reason, promise) => {
console.error('[GLADA] 未处理的 Promise 拒绝(进程保活):', reason);
});
}