fix: 修复 AI 回复无法送达发送者 + 重复消息 + action角色泄露 + OS环境支持

广播逻辑重构:
- AI 回复 (stream_start/response/stream_segments/multi_message/stream_end) 改用 broadcastToUser 发送给所有客户端
- 用户消息回显保持 broadcastToUserExcept 排除发送者

消息去重与角色修复:
- CacheMessage(user) 移至回复生成后,避免本轮 LLM 调用出现重复用户消息
- action 角色消息在 DB 存储时映射为 assistant,DeepSeek 等模型不支持自定义角色
- stream_end defer 机制确保错误路径也会终止客户端思考指示器

OS 完整环境支持:
- host 包重构为 HostBackend 接口 + Direct/WSL/Docker 三种后端
- 新增 os_exec/os_file/os_system 工具供 AI 在完整 Linux 环境中自由操作

其他:
- 视觉模型注入 + 图片预处理后清空 Images 避免传给 Chat 模型
- 图片 URL 相对路径→绝对 URL 转换
- DevTools 链路追踪页面 + 重启修复
- 记忆搜索模糊匹配增强
- 后台思考定时调度支持
- 管理后台页面 (模型配置/用户管理等)
- docs/api 更新广播机制说明

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-29 12:46:17 +08:00
parent aac64ed8b7
commit 91c9ee4b2d
49 changed files with 5032 additions and 299 deletions
+267 -8
View File
@@ -225,7 +225,8 @@ app.post('/api/devtools/restart', (_req, res) => {
const child = spawn(process.execPath, [scriptPath, ...process.argv.slice(2)], {
cwd: ROOT,
detached: true,
stdio: 'inherit',
stdio: 'ignore',
windowsHide: true,
});
child.unref();
process.exit(0);
@@ -324,10 +325,12 @@ app.get('/api/memory/search', async (req, res) => {
});
app.get('/api/memory/list', async (req, res) => {
const { user_id } = req.query;
const { user_id, limit, offset } = req.query;
if (!user_id) return res.status(400).json({ error: '缺少 user_id 参数' });
const qs = new URLSearchParams({ user_id }).toString();
const result = await proxyToGateway(`/api/v1/memory?${qs}`);
const qs = new URLSearchParams({ user_id });
if (limit) qs.set('limit', limit);
if (offset) qs.set('offset', offset);
const result = await proxyToGateway(`/api/v1/memory?${qs.toString()}`);
res.status(result.status).json(result.body);
});
@@ -868,6 +871,12 @@ app.get('/api/tool-calls/stats', async (_req, res) => {
res.status(result.status).json(result.body);
});
// ---- VM 监控 (OS 环境信息) ----
app.get('/api/vm-monitor/status', async (_req, res) => {
const result = await proxyToAICore('/api/v1/system/info');
res.status(result.status).json(result.body);
});
// ---- 插件管理代理 (转发到 plugin-manager) ----
app.get('/api/plugins', async (_req, res) => {
const result = await proxyToPluginManager('/api/v1/plugins');
@@ -1124,6 +1133,251 @@ app.get('/api/llm-calls', async (req, res) => {
res.status(result.status).json(result.body);
});
// ---- 全链路追踪 ----
/**
* 从日志文件中搜索包含指定关键词的最近行
*/
function searchLogFile(serviceId, keyword, maxLines = 200) {
const filePath = logFile(serviceId);
if (!fs.existsSync(filePath)) return [];
try {
const content = fs.readFileSync(filePath, 'utf-8');
const allLines = content.split('\n').filter(Boolean);
const recent = allLines.slice(-maxLines);
const kwLower = keyword.toLowerCase();
return recent
.filter(line => line.toLowerCase().includes(kwLower))
.map(line => line.trim());
} catch {
return [];
}
}
/**
* 解析日志时间戳 (支持常见格式)
*/
function parseLogTimestamp(line) {
// 2024-01-01T12:00:00Z, 2024/01/01 12:00:00, [2024-01-01 12:00:00], 12:00:00
const match = line.match(/(\d{4}[-/]\d{2}[-/]\d{2}[T ]\d{2}:\d{2}:\d{2})/);
if (match) return new Date(match[1]).getTime();
const timeMatch = line.match(/(\d{2}:\d{2}:\d{2})/);
if (timeMatch) {
const today = new Date();
const [h, m, s] = timeMatch[1].split(':').map(Number);
today.setHours(h, m, s, 0);
return today.getTime();
}
return Date.now();
}
// GET /api/trace/recent — 最近的全链路追踪数据
app.get('/api/trace/recent', async (req, res) => {
const limit = Math.min(parseInt(req.query.limit) || 30, 100);
try {
const [llmResult, toolResult, sessionsResult] = await Promise.all([
proxyToAICore(`/api/v1/llm-calls?limit=${limit}`).catch(() => ({ status: 502, body: [] })),
proxyToAICore(`/api/v1/tools/calls?limit=${limit}`).catch(() => ({ status: 502, body: { calls: [] } })),
proxyToGateway('/api/v1/admin/sessions/active').catch(() => ({ status: 502, body: { users: {} } })),
]);
const traces = [];
// LLM 调用 → 追踪节点
const llmCalls = Array.isArray(llmResult.body) ? llmResult.body : [];
for (const call of llmCalls) {
const ts = call.time ? new Date(call.time).getTime() : Date.now();
traces.push({
id: `llm-${ts}-${Math.random().toString(36).slice(2, 6)}`,
timestamp: new Date(ts).toISOString(),
ts,
service: 'ai-core',
hop: 'llm_call',
label: `LLM 调用: ${call.model || 'unknown'}`,
status: call.success ? 'success' : 'error',
durationMs: call.duration_ms || call.Duration || 0,
detail: call.error || `${call.prompt_tokens || 0}+${call.completion_tokens || 0} tokens`,
data: call,
});
}
// 工具调用
const toolCalls = toolResult.body?.calls || (Array.isArray(toolResult.body) ? toolResult.body : []);
for (const tc of toolCalls) {
const ts = tc.time || tc.timestamp || tc.created_at;
const tsNum = ts ? new Date(ts).getTime() : Date.now();
traces.push({
id: `tool-${tsNum}-${Math.random().toString(36).slice(2, 6)}`,
timestamp: new Date(tsNum).toISOString(),
ts: tsNum,
service: 'ai-core',
hop: 'tool_call',
label: `工具调用: ${tc.tool_name || tc.name || 'unknown'}`,
status: tc.error ? 'error' : 'success',
durationMs: tc.duration_ms || tc.Duration || 0,
detail: tc.error || tc.result?.substring?.(0, 100) || '',
data: tc,
});
}
// 活跃会话
const users = sessionsResult.body?.users || {};
for (const [userID, sessions] of Object.entries(users)) {
for (const s of sessions) {
const ts = s.last_activity ? new Date(s.last_activity).getTime() : Date.now();
traces.push({
id: `session-${s.session_id || ''}`,
timestamp: new Date(ts).toISOString(),
ts,
service: 'gateway',
hop: 'session_active',
label: `会话活跃: ${userID}`,
status: 'success',
durationMs: 0,
detail: `Session: ${(s.session_id || '').substring(0, 16)}... State: ${s.state || 'idle'}`,
data: { userID, sessionId: s.session_id, state: s.state },
});
}
}
// 按时间倒序排列
traces.sort((a, b) => b.ts - a.ts);
const recent = traces.slice(0, limit);
// 统计摘要
const services = [...new Set(recent.map(t => t.service))];
const errors = recent.filter(t => t.status === 'error').length;
const totalDuration = recent.reduce((sum, t) => sum + (t.durationMs || 0), 0);
res.json({
timestamp: Date.now(),
total: traces.length,
shown: recent.length,
stats: { services, errors, totalDurationMs: Math.round(totalDuration) },
traces: recent,
});
} catch (err) {
res.status(500).json({ error: `获取链路追踪数据失败: ${err.message}` });
}
});
// GET /api/trace/session/:sessionId — 特定会话的全链路追踪
app.get('/api/trace/session/:sessionId', async (req, res) => {
const { sessionId } = req.params;
if (!sessionId) return res.status(400).json({ error: '缺少 sessionId' });
try {
// 并行获取: 会话详情、LLM 调用、工具调用、日志搜索
const [sessionResult, llmResult, toolResult] = await Promise.all([
proxyToGateway(`/api/v1/admin/sessions/${sessionId}`).catch(() => ({ status: 502, body: null })),
proxyToAICore('/api/v1/llm-calls?limit=500').catch(() => ({ status: 502, body: [] })),
proxyToAICore('/api/v1/tools/calls?limit=200').catch(() => ({ status: 502, body: { calls: [] } })),
]);
// 从日志文件中搜索 session 相关行
const gatewayLogLines = searchLogFile('gateway', sessionId, 500);
const aiCoreLogLines = searchLogFile('ai-core', sessionId, 500);
const traces = [];
const sessionData = sessionResult.body;
// Gateway 日志 → 追踪节点
for (const line of gatewayLogLines) {
const ts = parseLogTimestamp(line);
let hop = 'gateway_log';
let label = 'Gateway 日志';
if (line.includes('received') || line.includes('收到')) { hop = 'gateway_receive'; label = 'Gateway 接收消息'; }
else if (line.includes('stream') || line.includes('流式')) { hop = 'gateway_stream'; label = 'Gateway 流式处理'; }
else if (line.includes('send') || line.includes('发送') || line.includes('broadcast')) { hop = 'gateway_send'; label = 'Gateway 推送响应'; }
else if (line.includes('error') || line.includes('错误')) { hop = 'gateway_error'; label = 'Gateway 错误'; }
traces.push({
id: `gwlog-${ts}-${Math.random().toString(36).slice(2, 6)}`,
timestamp: new Date(ts).toISOString(),
ts,
service: 'gateway',
hop,
label,
status: hop === 'gateway_error' ? 'error' : 'success',
durationMs: 0,
detail: line.substring(0, 200),
data: { raw: line },
});
}
// AI-Core 日志
for (const line of aiCoreLogLines) {
const ts = parseLogTimestamp(line);
let hop = 'ai_core_log';
let label = 'AI-Core 日志';
if (line.includes('LLM') || line.includes('llm') || line.includes('chat')) { hop = 'ai_core_llm'; label = 'AI-Core LLM 处理'; }
else if (line.includes('tool') || line.includes('Tool')) { hop = 'ai_core_tool'; label = 'AI-Core 工具调用'; }
else if (line.includes('stream') || line.includes('SSE')) { hop = 'ai_core_stream'; label = 'AI-Core 流式输出'; }
else if (line.includes('error') || line.includes('Error')) { hop = 'ai_core_error'; label = 'AI-Core 错误'; }
traces.push({
id: `aclog-${ts}-${Math.random().toString(36).slice(2, 6)}`,
timestamp: new Date(ts).toISOString(),
ts,
service: 'ai-core',
hop,
label,
status: hop === 'ai_core_error' ? 'error' : 'success',
durationMs: 0,
detail: line.substring(0, 200),
data: { raw: line },
});
}
// LLM 调用记录中如果有 session 相关信息也加入
const llmCalls = Array.isArray(llmResult.body) ? llmResult.body : [];
for (const call of llmCalls) {
const ts = call.time ? new Date(call.time).getTime() : Date.now();
traces.push({
id: `llm-${ts}-${Math.random().toString(36).slice(2, 6)}`,
timestamp: new Date(ts).toISOString(),
ts,
service: 'ai-core',
hop: 'llm_call',
label: `LLM: ${call.model || 'unknown'}`,
status: call.success ? 'success' : 'error',
durationMs: call.duration_ms || call.Duration || 0,
detail: call.error || `${call.prompt_tokens || 0}${call.completion_tokens || 0} tokens`,
data: call,
});
}
// 按时间排序
traces.sort((a, b) => a.ts - b.ts);
// 计算每一跳的耗时
for (let i = 1; i < traces.length; i++) {
const gap = traces[i].ts - traces[i - 1].ts;
if (gap > 0 && !traces[i].durationMs) {
traces[i]._gapFromPrev = gap;
}
}
const totalSpan = traces.length >= 2 ? traces[traces.length - 1].ts - traces[0].ts : 0;
const errors = traces.filter(t => t.status === 'error').length;
res.json({
timestamp: Date.now(),
sessionId,
session: sessionData,
stats: {
totalHops: traces.length,
errors,
totalSpanMs: totalSpan,
services: [...new Set(traces.map(t => t.service))],
},
traces,
});
} catch (err) {
res.status(500).json({ error: `获取会话链路追踪失败: ${err.message}` });
}
});
/**
* 代理请求到 Memory-Service
* @param {string} path - Memory-Service API 路径
@@ -1196,16 +1450,17 @@ app.get('/api/v1/thinking/:id', async (req, res) => {
// ---- 记忆时间线 (合并记忆 + 思考) ----
app.get('/api/memory-timeline', async (req, res) => {
const { user_id, limit } = req.query;
const { user_id, limit, offset } = req.query;
if (!user_id) {
return res.status(400).json({ error: '缺少 user_id 参数' });
}
const maxItems = parseInt(limit) || 100;
const pageOffset = parseInt(offset) || 0;
try {
// 并行调用记忆和思考 API
const memQs = new URLSearchParams({ user_id, limit: String(maxItems) }).toString();
const thinkQs = new URLSearchParams({ user_id, limit: String(maxItems), offset: '0' }).toString();
// 并行调用记忆和思考 API (带 offset)
const memQs = new URLSearchParams({ user_id, limit: String(maxItems), offset: String(pageOffset) }).toString();
const thinkQs = new URLSearchParams({ user_id, limit: String(maxItems), offset: String(pageOffset) }).toString();
const [memResult, thinkResult] = await Promise.all([
proxyToMemoryService(`/api/v1/memories?${memQs}`),
@@ -1285,6 +1540,9 @@ app.get('/api/memory-timeline', async (req, res) => {
// 截取限制条数
const result = timeline.slice(0, maxItems);
// 是否有更多数据 (两边都还有数据表示可能还有更多)
const hasMore = memories.length >= maxItems || thinkingLogs.length >= maxItems;
// 统计摘要
const stats = {
total_memories: memories.length,
@@ -1303,6 +1561,7 @@ app.get('/api/memory-timeline', async (req, res) => {
timeline: result,
stats,
total: timeline.length,
hasMore,
user_id,
});
} catch (err) {