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:
+267
-8
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user