Agent/core/agent-loop.ts
2026-08-06 22:17:39 +08:00

1488 lines
60 KiB
TypeScript
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.

// @ts-nocheck
import { CONFIG, loadModelSource, loadContextThreshold, loadThinkingEnabled, getCurrentModelInfo, modelThinkingCapability } from '../utils/config.js';
import * as mcp from '../utils/mcp-client.js';
import * as http from '../ui/http-client.js';
import { enrichToolArguments, buildRecentDialogText } from '../utils/tool-args-enrich.js';
import * as sessionContext from './session-context.js';
import { validateCandidateSelection, formatValidationFailure } from '../utils/masking-validator.js';
import { chat as ollamaChat } from '../clients/ollama-client.js';
import { chat as anthropicChat } from '../clients/anthropic-client.js';
import { chat as omlxChat } from '../clients/omlx-client.js';
import { chat as sparkleChat } from '../clients/sparkle-client.js';
import { createToolCard } from '../utils/tool-renderer.js';
import { render as renderMarkdown } from '../reports/markdown.js';
import * as pidmapStore from '../utils/pidmap-store.js';
import { mountExportBar } from '../ui/export-report.js';
import { stripReportMarkers, stripReportHints } from '../reports/report-clean.js';
import { log as auditLog } from '../utils/audit-log.js';
import { compressIfNeeded } from './context-compress.js';
import { composeDirectRenderReport } from '../reports/report-direct-render.js';
import { setProfileUser } from '../ui/export-report.js';
let _toolsCache = null;
let _abortCtrl = null;
let _lastCandidates = null;
const META_TOOL_NAMES = new Set(['metro_search_tools', 'metro_get_tool_detail']);
const META_TOOLS = [
{
name: 'metro_search_tools',
description: '【仅当工具列表只有元工具时调】按关键词搜索可用工具。系统常会预发现并自动注入业务工具,有业务工具时请直接调用。',
inputSchema: {
type: 'object',
properties: {
query: { type: 'string', description: '搜索关键词(中英文),如"加载运营图""轮乘""交接""司机段"' },
},
additionalProperties: false,
},
},
{
name: 'metro_get_tool_detail',
description: '按需获取单个工具完整 inputSchema。预发现已注入的业务工具可直接调用,不必先 detail。',
inputSchema: {
type: 'object',
properties: {
name: { type: 'string', description: '工具名,如 metro_load_timetable / metro_setup_rest_rules' },
},
required: ['name'],
additionalProperties: false,
},
},
];
export async function loadTools(mode) {
if (mode === 'http') {
_toolsCache = await http.listTools();
} else {
_toolsCache = await mcp.listTools();
}
return _toolsCache;
}
export function abort() {
if (_abortCtrl && !_abortCtrl.signal.aborted) {
_abortCtrl.abort();
}
}
export function isRunning() {
return !!(_abortCtrl && !_abortCtrl.signal.aborted);
}
function isAborted() {
return !_abortCtrl || _abortCtrl.signal.aborted;
}
export async function run({ userInput, mode, history, hooks, parentEl, enabledTools }) {
const messages = [...history];
messages.push({ role: 'user', content: userInput });
const traceId = `trace_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
auditLog('user', userInput.length > 60 ? userInput.slice(0, 60) + '...' : userInput, { full: userInput, mode, traceId });
if (!_toolsCache || _toolsCache.length === 0) {
try {
await loadTools(mode);
} catch (e) {
hooks.onError?.(`加载工具列表失败: ${e.message}`);
return;
}
}
const chatOnly = isChatOnlyIntent(userInput);
let toolsToSend;
const enabledSet = new Set((enabledTools || []).map(t => t.name));
const assignmentOnline = mode === 'http' ? true : mcp.isSubsystemOnline('assignment');
const useDiscovery = !chatOnly && assignmentOnline && (!enabledTools || enabledTools.length >= 20);
if (chatOnly) {
toolsToSend = [];
auditLog('system', '纯对话意图:本轮不暴露工具', { traceId });
} else if (!assignmentOnline) {
toolsToSend = [...(enabledTools || [])];
auditLog('system', `排班系统离线:跳过元工具搜索,直接给 ${toolsToSend.length} 个已启用工具`, {
traceId,
tools: toolsToSend.map(t => t.name).join(', '),
});
} else if (useDiscovery) {
toolsToSend = [...META_TOOLS];
auditLog('system', `工具发现模式,初始 ${toolsToSend.length} 个元工具`, { traceId });
try {
const injected = await discoverAndInjectTools({
query: userInput,
userInput,
toolsToSend,
mode,
traceId,
enabledSet: null,
stripMeta: true,
});
if (injected > 0) {
auditLog('system', `预发现注入 ${injected} 个业务工具(模型直接可见,不需 search)`, {
traceId,
tools: toolsToSend.map(t => t.name).join(', '),
});
} else {
const fallbackNames = resolveIntentToolNames(userInput);
if (fallbackNames.length > 0) {
await injectToolsByNames({
toolNames: fallbackNames,
toolsToSend,
mode,
traceId,
enabledSet: null,
});
if (toolsToSend.length > META_TOOLS.length) {
stripMetaToolsFromSend(toolsToSend);
auditLog('system', `意图回退注入 ${toolsToSend.length} 个业务工具`, {
traceId,
tools: toolsToSend.map(t => t.name).join(', '),
});
}
}
}
} catch (e) {
console.warn('[agent-loop] 预发现失败,回退模型自己 search:', e.message);
}
} else {
toolsToSend = [...META_TOOLS, ...enabledTools];
}
const source = loadModelSource();
const chatFn = source === 'anthropic' ? anthropicChat
: source === 'omlx' ? omlxChat
: source === 'sparkle' ? sparkleChat
: ollamaChat;
const sourceLabel = source === 'anthropic' ? '云端' : '本地';
_abortCtrl = new AbortController();
let errorCount = 0;
let lastToolName = null;
let emptyOutputRetried = false;
let reportMode = shouldUseMultiTurnReport(userInput, history);
const reportSections = [];
let reportRound = 0;
let reportUi = null;
let reportContinuing = false;
let reportDataReady = false;
let pendingReportMd = null;
let pendingReportPidMap = {};
let pendingReportMainPid = null;
let reportToolNudgeCount = 0;
let employeePickRequired = false;
let fuzzyRosterMd = '';
let maxTurns = reportMode
? Math.max(CONFIG.MAX_TURNS, CONFIG.MAX_TURNS + (CONFIG.MAX_REPORT_ROUNDS || 10))
: CONFIG.MAX_TURNS;
const enableReportMode = (reason) => {
if (reportMode) return;
reportMode = true;
maxTurns = Math.max(maxTurns, CONFIG.MAX_TURNS + (CONFIG.MAX_REPORT_ROUNDS || 10));
auditLog('system', `进入详细报告模式(${reason})`, { traceId, maxTurns });
};
if (reportMode) {
auditLog('system', '进入详细报告模式(待 MD 就绪后直渲表格+逐模块分析)', { traceId, maxTurns });
}
const finalizeAbort = (partialText = '') => {
const ui = reportUi || liveUi;
const clean = stripReportMarkers(partialText);
if (ui?.answerPanel) {
ui.answerPanel.classList.remove('cursor');
ui.answerPanel.querySelector('.thinking-indicator')?.remove();
}
if (reportMode) {
if (clean && (looksLikeReportChunk(clean) || reportSections.length > 0)) {
reportSections.push(clean);
}
const fullMd = reportSections.join('\n\n');
if (fullMd && ui?.answerPanel) {
ui.answerPanel.innerHTML = renderMarkdown(fullMd);
const note = document.createElement('div');
note.className = 'abort-note';
note.textContent = '(已停止生成)';
ui.answerPanel.appendChild(note);
collapseReportMessages(messages, fullMd);
mountExportBar(ui.textWrap, fullMd, { messages });
hooks.onComplete?.(messages);
} else if (ui?.answerPanel) {
const note = document.createElement('div');
note.className = 'abort-note';
note.textContent = '(已停止生成)';
ui.answerPanel.appendChild(note);
}
} else if (ui?.answerPanel) {
if (clean) {
const last = messages[messages.length - 1];
if (!(last && last.role === 'assistant' && last.content === clean && !last.toolCalls)) {
messages.push({ role: 'assistant', content: clean });
}
mountExportBar(ui.textWrap, clean, { messages });
hooks.onComplete?.(messages);
}
if (!ui.answerPanel.querySelector('.abort-note')) {
const note = document.createElement('div');
note.className = 'abort-note';
note.textContent = '(已停止生成)';
ui.answerPanel.appendChild(note);
}
}
auditLog('system', '用户终止输出', { traceId, hadText: !!clean });
hooks.onAbort?.('已停止生成');
};
let liveUi = null;
try {
for (let turn = 1; turn <= maxTurns; turn++) {
if (isAborted()) {
finalizeAbort();
return;
}
let assistantEl;
let answerPanel;
let thinkPanel;
let thinkBody;
if (reportContinuing && reportUi) {
assistantEl = reportUi.el;
answerPanel = reportUi.answerPanel;
thinkPanel = assistantEl.querySelector('.think-panel');
thinkBody = assistantEl.querySelector('.think-panel-body');
reportUi.textWrap?.querySelector('.export-actions')?.remove();
} else {
assistantEl = createAssistantBubble(parentEl);
answerPanel = assistantEl.querySelector('.answer-panel');
thinkPanel = assistantEl.querySelector('.think-panel');
thinkBody = assistantEl.querySelector('.think-panel-body');
}
const textWrap0 = answerPanel.closest('.assistant-text') || answerPanel.parentElement;
liveUi = { el: assistantEl, answerPanel, textWrap: textWrap0 };
if (reportMode && !reportUi) reportUi = liveUi;
const thinkOn = loadThinkingEnabled();
const waiting = document.createElement('div');
waiting.className = 'thinking-indicator';
waiting.innerHTML = `<div class="dot-pulse"><span></span><span></span><span></span></div><span>${sourceLabel}${thinkOn ? '思考中' : '推理中'}${reportContinuing ? '(续写报告)' : ''}...</span>`;
if (reportContinuing && reportSections.length) {
answerPanel.appendChild(waiting);
} else {
answerPanel.appendChild(waiting);
}
let textBuf = '';
let thinkBuf = '';
let r;
try {
const compressed = compressIfNeeded(messages, {
threshold: loadContextThreshold(source),
keepRecent: reportMode ? 12 : 6,
toolsCount: toolsToSend.length,
});
if (compressed.compressed) {
auditLog('system', `上下文压缩:${compressed.originalTokens}${compressed.finalTokens} tokens(隐藏 ${compressed.hiddenCount} 条中间历史)`, {
originalTokens: compressed.originalTokens,
finalTokens: compressed.finalTokens,
hiddenCount: compressed.hiddenCount,
});
}
const messagesToSend = compressed.messages;
const chatOpts = {
messages: messagesToSend,
tools: toolsToSend,
traceId,
signal: _abortCtrl.signal,
...(reportMode && CONFIG.REPORT_MAX_TOKENS
? { maxTokens: CONFIG.REPORT_MAX_TOKENS }
: {}),
onThinkingDelta: (delta) => {
if (!thinkOn || !delta) return;
waiting.remove();
thinkPanel.hidden = false;
setThinkPanelCollapsed(thinkPanel, false);
const st = thinkPanel.querySelector('.think-panel-status');
if (st) st.textContent = '思考中';
thinkBuf += delta;
thinkBody.textContent = thinkBuf;
thinkBody.scrollTop = thinkBody.scrollHeight;
hooks.onAssistantTextDelta?.();
},
onTextDelta: (delta) => {
waiting.remove();
if (thinkBuf) collapseThinkPanelDone(thinkPanel);
if (!textBuf) {
if (!(reportContinuing && reportSections.length)) {
answerPanel.innerHTML = '';
}
answerPanel.classList.add('cursor');
}
textBuf += delta;
const liveClean = stripReportMarkers(textBuf);
if (reportMode && reportSections.length) {
const liveFull = [...reportSections, liveClean].filter(Boolean).join('\n\n');
answerPanel.innerHTML = renderMarkdown(liveFull);
} else {
answerPanel.innerHTML = renderMarkdown(liveClean);
}
hooks.onAssistantTextDelta?.();
},
};
r = await chatFn(chatOpts);
} catch (e) {
if (e.name === 'AbortError' || isAborted()) {
waiting.remove();
finalizeAbort(textBuf || '');
return;
}
waiting.remove();
answerPanel.innerHTML = `<span style="color:var(--danger-color);">${sourceLabel}调用失败: ${escapeHtml(e.message)}</span>`;
auditLog('error', `${sourceLabel} 调用失败: ${e.message}`, { error: e.message });
hooks.onError?.(`${sourceLabel}调用失败: ${e.message}`);
return;
}
waiting.remove();
answerPanel.classList.remove('cursor');
const activeModel = getCurrentModelInfo()?.model || '';
const thinkCap = modelThinkingCapability(activeModel);
if (thinkBuf) {
collapseThinkPanelDone(thinkPanel);
} else if (thinkOn && thinkCap === 'no') {
thinkPanel.hidden = false;
const hdr = thinkPanel.querySelector('.think-panel-header');
if (hdr) hdr.querySelector('.think-panel-status').textContent = '当前模型无思考输出';
thinkBody.textContent = `当前模型「${activeModel}」是 Instruct-2507 非思考版,官方不支持输出思考过程。\n请在 oMLX 换成同系列 Thinking-2507或早期可混合思考的 Qwen3再打开「想」。`;
}
let toolCallsWithId = (r.toolCalls || []).map((tc, i) => ({
id: tc.id || `toolu_local_${Date.now()}_${i}`,
name: tc.name,
arguments: tc.arguments,
}));
if (toolsToSend.length === 0 && toolCallsWithId.length > 0) { auditLog('system', `丢弃幻觉工具调用: ${toolCallsWithId.map(t => t.name).join(', ')}`, { traceId });
const discarded = toolCallsWithId;
toolCallsWithId = [];
if (!r.text) {
messages.push({
role: 'assistant',
content: `(已忽略无效工具调用: ${discarded.map(t => t.name).join(', ')}`,
});
messages.push({
role: 'user',
content: '本轮禁止调用任何工具。请直接用简体中文完整回复刚才的要求介绍自己、能做什么、解决什么问题不少于800字。不要输出 tool_call、不要假装已调用工具。',
});
answerPanel.innerHTML = `<span style="color:var(--text-tertiary);">检测到无效工具调用已忽略,正在重新生成介绍...</span>`;
continue;
}
}
if (r.text || toolCallsWithId.length > 0) {
const thinkingText = (thinkBuf || r.thinking || '').trim();
const displayText = toolCallsWithId.length > 0
? stripReportMarkers(String(r.text || '').trim())
: r.text;
messages.push({
role: 'assistant',
content: displayText || (toolCallsWithId.length > 0 ? '' : r.text),
thinking: thinkingText || undefined,
toolCalls: toolCallsWithId.length > 0 ? toolCallsWithId : undefined,
});
answerPanel.classList.remove('cursor');
if (toolCallsWithId.length > 0) {
answerPanel.innerHTML = displayText
? renderMarkdown(displayText)
: '';
if (displayText) {
auditLog('assistant', displayText.length > 80 ? displayText.slice(0, 80) + '...' : displayText, {
full: displayText,
toolCalls: toolCallsWithId.length,
evalCount: r.evalCount,
source: sourceLabel,
thinkingLen: thinkingText.length || 0,
});
}
}
}
if (toolCallsWithId.length === 0) {
if (!r.text) {
const hasToolResults = messages.some(m => m.role === 'tool');
if (hasToolResults && !emptyOutputRetried && turn < maxTurns) {
emptyOutputRetried = true;
messages.push({
role: 'user',
content: '工具调用已完成并返回结果。请根据已有工具结果,用简体中文直接回答用户刚才的问题。禁止再调用任何工具。',
});
answerPanel.innerHTML = `<span style="color:var(--text-tertiary);">工具已返回,正在生成回复...</span>`;
auditLog('system', '空输出兜底:要求根据工具结果直接回复(禁止再调工具)', { traceId, turn });
reportContinuing = false;
continue;
}
answerPanel.innerHTML = `<span style="color:var(--text-tertiary);">(无输出)</span>`;
hooks.onComplete?.(messages);
return;
}
{
const peek = parseReportMarkers(r.text);
if (!reportMode && peek.continue && reportDataReady) {
enableReportMode('正文含[CONTINUE]');
}
}
if (reportMode) {
const markers = parseReportMarkers(r.text);
if (!reportDataReady && isAskingUser(markers.clean)) {
answerPanel.classList.remove('cursor');
answerPanel.innerHTML = renderMarkdown(markers.clean || r.text);
auditLog('assistant', (markers.clean || r.text).slice(0, 80), {
full: r.text,
reportDataReady: false,
asking: true,
traceId,
});
reportContinuing = false;
hooks.onComplete?.(messages);
return;
}
if (!reportDataReady && employeePickRequired) {
const askMd = buildEmployeePickAsk(fuzzyRosterMd, markers.clean || r.text);
answerPanel.classList.remove('cursor');
answerPanel.innerHTML = renderMarkdown(askMd);
const last = messages[messages.length - 1];
if (!(last && last.role === 'assistant' && last.content === askMd)) {
messages.push({ role: 'assistant', content: askMd });
}
auditLog('system', '报告模式暂停:等待用户从模糊候选确认人选', { traceId });
reportContinuing = false;
hooks.onComplete?.(messages);
return;
}
if (!reportDataReady) {
if (reportToolNudgeCount < 2 && turn < maxTurns) {
reportToolNudgeCount++;
const hint = buildReportToolNudge(messages, userInput);
messages.push({ role: 'user', content: hint });
answerPanel.classList.remove('cursor');
answerPanel.innerHTML = `<span style="color:var(--text-tertiary);">正在拉取报告数据...</span>`;
auditLog('system', `报告数据未就绪,催促调工具(第 ${reportToolNudgeCount} 次)`, {
traceId,
preview: (markers.clean || r.text || '').slice(0, 80),
});
reportContinuing = false;
continue;
}
answerPanel.classList.remove('cursor');
answerPanel.innerHTML = renderMarkdown(markers.clean || r.text);
auditLog('assistant', (markers.clean || r.text).slice(0, 80), {
full: r.text,
reportDataReady: false,
asking: false,
nudgedOut: true,
traceId,
});
reportContinuing = false;
hooks.onComplete?.(messages);
return;
}
const chunkOk = looksLikeReportChunk(markers.clean)
|| markers.continue
|| markers.done
|| reportSections.length > 0;
if (markers.clean && chunkOk) reportSections.push(markers.clean);
const fullMd = reportSections.join('\n\n');
answerPanel.innerHTML = renderMarkdown(fullMd || markers.clean || r.text);
const textWrap = answerPanel.closest('.assistant-text') || answerPanel.parentElement;
if (!reportUi) {
reportUi = { el: assistantEl, answerPanel, textWrap };
} else {
reportUi.answerPanel = answerPanel;
reportUi.textWrap = textWrap;
}
auditLog('assistant', `报告章节 ${reportSections.length}: ${(markers.clean || '').slice(0, 60)}...`, {
reportRound,
continue: markers.continue,
done: markers.done,
reportDataReady,
traceId,
});
const underCap = reportRound + 1 < (CONFIG.MAX_REPORT_ROUNDS || 10)
&& turn < maxTurns;
let canContinue = false;
let continueReason = '';
if (markers.done) {
canContinue = false;
continueReason = 'done';
} else if (!underCap) {
canContinue = false;
continueReason = 'cap';
} else if (markers.continue) {
canContinue = true;
continueReason = 'continue';
} else {
canContinue = false;
continueReason = 'no-marker';
}
if (canContinue) {
reportRound++;
reportContinuing = true;
messages.push({
role: 'user',
content: '继续输出报告正文下一个模块。不要写「上一轮/接着写/然后进入」等说明,直接输出正文;末尾 [CONTINUE] 或 [DONE]。',
});
auditLog('system', `报告自动续写第 ${reportRound} 轮(${continueReason})`, { traceId });
parentEl.scrollTop = parentEl.scrollHeight;
continue;
}
if (reportSections.length > 0) {
collapseReportMessages(messages, fullMd);
mountExportBar(textWrap, fullMd, { messages });
} else {
mountExportBar(textWrap, fullMd || markers.clean || r.text, { messages });
}
reportContinuing = false;
hooks.onComplete?.(messages);
return;
}
answerPanel.classList.remove('cursor');
answerPanel.innerHTML = renderMarkdown(stripReportMarkers(r.text));
auditLog('assistant', r.text.length > 80 ? r.text.slice(0, 80) + '...' : r.text, {
full: r.text,
toolCalls: 0,
evalCount: r.evalCount,
source: sourceLabel,
});
const textWrap = answerPanel.closest('.assistant-text') || answerPanel.parentElement;
mountExportBar(textWrap, stripReportMarkers(r.text), { messages });
hooks.onComplete?.(messages);
return;
}
reportContinuing = false;
for (let i = 0; i < toolCallsWithId.length; i++) {
const tc = toolCallsWithId[i];
if (isAborted()) {
finalizeAbort();
return;
}
const toolContainer = assistantEl.querySelector('.tool-cards-container');
const rawArgs = tc.arguments || {};
const recentDialog = buildRecentDialogText(messages);
const toolArgs = enrichToolArguments(tc.name, rawArgs, userInput, { recentDialog, messages });
if (_lastCandidates && Array.isArray(_lastCandidates) && _lastCandidates.length > 0) {
const selectionText = String(toolArgs.employeeId || toolArgs.employeeName || userInput || '');
const v = validateCandidateSelection(selectionText, _lastCandidates);
if (v.valid) {
toolArgs.employeeId = v.employeeId;
_lastCandidates = null;
} else {
const failureHint = formatValidationFailure(v);
auditLog('system', `多候选校验失败(${v.reason}): ${failureHint}`);
const failContent = JSON.stringify({
ok: false,
reason: failureHint,
assistantGuidance: failureHint,
});
messages.push({
role: 'tool',
toolCallId: tc.id,
content: failContent,
});
const failCard = createToolCard(toolContainer || parentEl, {
name: tc.name, args: toolArgs, protocol: mode,
});
failCard.setStatus('error', '候选校验失败');
failCard.setResult({ ok: false, reason: failureHint }, true);
auditLog('tool:end', `${tc.name} 候选校验拦截`, {
name: tc.name, ok: false, reason: v.reason,
});
continue;
}
}
const toolCard = createToolCard(toolContainer || parentEl, {
name: tc.name,
args: toolArgs,
protocol: mode,
});
auditLog('tool:start', `${tc.name}(${mode})`, { name: tc.name, args: toolArgs, protocol: mode, traceId });
let result;
const yearInventedBlocked = String(tc.name).startsWith('profile_')
&& rawArgs.year != null && rawArgs.year !== ''
&& (toolArgs.year == null || toolArgs.year === '');
const pickBlocked = employeePickRequired
&& (tc.name === 'profile_get_person_report' || tc.name === 'profile_get_team_report');
try {
if (yearInventedBlocked) {
result = {
ok: false,
error: {
code: 'YEAR_NOT_EXPLICIT',
message: `用户未明确说过年份 ${rawArgs.year},已拦截调用。请先用中文追问要查哪一年,得到明确回答后再调工具。`,
assistantGuidance: '请用中文追问用户要查哪一年,不要自行假设年份,不要再次用猜测的年份调用工具。',
},
durationMs: 0,
protocol: mode,
};
auditLog('system', `拦截虚构 year=${rawArgs.year} @ ${tc.name}`, { traceId });
} else if (pickBlocked) {
result = {
ok: false,
error: {
code: 'EMPLOYEE_PICK_REQUIRED',
message: '花名册未精确匹配,须先请用户从候选中确认工号后再生成报告。',
assistantGuidance: '禁止再调 profile_get_person_report。请用中文列出候选(工号+姓名+班组),请用户确认后再继续。',
},
durationMs: 0,
protocol: mode,
};
auditLog('system', `拦截未确认人选 @ ${tc.name}`, { traceId });
} else {
const callOpts = {
onProgress: (progress) => toolCard.updateProgress(progress),
traceId,
signal: _abortCtrl?.signal,
};
if (mode === 'http') {
result = await http.callTool(tc.name, toolArgs, callOpts);
} else {
result = await mcp.callTool(tc.name, toolArgs, callOpts);
}
}
} catch (e) {
if (e.name === 'AbortError' || isAborted()) {
finalizeAbort();
return;
}
result = {
ok: false,
error: { code: 'CALL_FAILED', message: e.message },
durationMs: 0,
protocol: mode,
};
}
const mdError = typeof result.result === 'string'
&& /^#\s*错误/m.test(String(result.result).trim());
const workflowFailed = (result.ok && result.result && typeof result.result === 'object'
&& result.result.ok === false) || mdError;
const toolResultStr = formatToolResultForModel(
result.ok && !workflowFailed,
workflowFailed ? result.result : (result.ok ? result.result : result.error),
{ name: tc.name, arguments: rawArgs },
);
if (result.ok && !workflowFailed) {
toolCard.setStatus('success', `成功 ${(result.durationMs / 1000).toFixed(1)}s`);
if (result.result !== undefined) {
const display = typeof result.result === 'string'
? stripReportHints(result.result)
: result.result;
toolCard.setResult(display, false);
}
if (REPORT_DATA_TOOLS.has(tc.name) && isReportDataPayloadReady(result.result)) {
reportDataReady = true;
if (typeof result.result === 'string') {
pendingReportMd = result.result;
pendingReportPidMap = result.meta?.pidMap || {};
pendingReportMainPid = result.meta?.mainPid || null;
if (looksLikeFullReportMd(result.result)) {
enableReportMode(`${tc.name} 返回完整报告 MD`);
}
}
auditLog('system', `报告数据已就绪(${tc.name})`, { traceId, reportMode });
}
if (tc.name === 'profile_list_employees' && typeof result.result === 'string') {
if (isFuzzyOnlyEmployeeRoster(result.result)) {
employeePickRequired = true;
fuzzyRosterMd = result.result;
auditLog('system', '花名册仅模糊/拼音匹配,等待用户确认人选', { traceId });
}
}
if (tc.name === 'metro_get_tool_detail') {
const detail = coerceToolDetail(result.result);
if (detail?.name && addToolToSend(toolsToSend, detail)) {
stripMetaToolsFromSend(toolsToSend);
auditLog('system', `工具发现:加 "${detail.name}" 到可用工具集(当前 ${toolsToSend.length} 个)`, {
tools: toolsToSend.map(t => t.name).join(', '),
});
}
} else if (tc.name === 'metro_search_tools') {
const injected = await discoverAndInjectTools({
query: (tc.arguments && tc.arguments.query) || userInput,
userInput,
toolsToSend,
mode,
traceId,
enabledSet: useDiscovery ? null : enabledSet,
stripMeta: true,
});
if (injected > 0) {
auditLog('system', `search 命中后注入 ${injected} 个业务工具(待发 ${toolsToSend.length} 个)`, {
names: toolsToSend.map(t => t.name).join(', '),
});
}
}
if (tc.name === 'metro_load_timetable_full' && result.result?.debugTrainsToDelete?.length) {
await injectToolsByNames({
toolNames: ['metro_confirm_debug_train_delete'],
toolsToSend,
mode,
traceId,
enabledSet: useDiscovery ? null : enabledSet,
});
}
messages.push({
role: 'tool',
toolCallId: tc.id,
content: toolResultStr,
});
try {
const mainPid = result.meta?.mainPid;
if (mainPid) {
const pidMap = result.meta?.pidMap || {};
const entry = pidMap[mainPid] || {};
sessionContext.updateFromToolResult({
currentEntity: {
type: 'employee',
id: mainPid,
name: entry.name || `[NAME:${mainPid}]`,
},
lastQueryType: result.meta?.lastQueryType,
});
auditLog('system', `会话状态更新(占位符 v3): mainPid=${mainPid}`);
} else {
const updated = sessionContext.updateFromToolResult(result.result);
if (updated) {
auditLog('system', `会话状态更新: ${updated.type}=${updated.name || updated.id}`);
}
}
const cands = result.meta?.candidates;
if (Array.isArray(cands) && cands.length > 0) {
_lastCandidates = cands.map(c => typeof c === 'string' ? { employeeId: c } : c);
auditLog('system', `检测到多候选(${cands.length} 个),下次调用前校验`, {
candidates: cands,
});
} else {
_lastCandidates = null;
}
try {
const toolPidMap = result.meta?.pidMap;
if (toolPidMap && Object.keys(toolPidMap).length > 0) {
pidmapStore.persist(toolPidMap, {
expiresAt: result.meta?.expiresAt,
});
}
if (result.meta?.user) {
setProfileUser(result.meta.user);
}
} catch (e) {}
} catch (e) {}
auditLog('tool:end', `${tc.name} 成功 ${(result.durationMs / 1000).toFixed(2)}s`, {
name: tc.name,
ok: true,
durationMs: result.durationMs,
resultSummary: summarizeResult(result.result),
rawContent: parseRawContent(result.rawContent),
});
if (tc.name.startsWith('profile_')) {
const piiHits = scanPII(result.rawContent);
if (piiHits.length) {
auditLog('error', `[masking] ${tc.name} 疑似未脱敏 PII`, {
name: tc.name,
piiHits,
hint: '检查 profile MD 生成层,确认这些字段已 token 化',
});
}
}
errorCount = 0;
} else {
const err = workflowFailed ? result.result?.error : result.error;
toolCard.setStatus('error', `失败 ${err?.code || ''}`);
toolCard.setResult(workflowFailed ? result.result : { error: result.error, meta: result.meta }, true);
messages.push({
role: 'tool',
toolCallId: tc.id,
content: toolResultStr,
});
auditLog('tool:end', `${tc.name} 失败 ${result.error?.code || ''}`, {
name: tc.name,
ok: false,
error: result.error,
rawContent: parseRawContent(result.rawContent),
});
if (tc.name === lastToolName) {
errorCount++;
if (errorCount >= CONFIG.MAX_SAME_ERROR) {
auditLog('error', `工具 ${tc.name} 连续失败 ${errorCount} 次,终止`);
hooks.onError?.(`工具 ${tc.name} 连续失败 ${errorCount} 次,终止`);
return;
}
} else {
errorCount = 1;
lastToolName = tc.name;
}
}
}
if (employeePickRequired && !reportDataReady) {
const askMd = buildEmployeePickAsk(fuzzyRosterMd, '');
answerPanel.classList.remove('cursor');
answerPanel.innerHTML = renderMarkdown(askMd);
messages.push({ role: 'assistant', content: askMd });
auditLog('system', '花名册模糊匹配,硬停等待用户确认', { traceId });
hooks.onComplete?.(messages);
return;
}
if (reportMode && pendingReportMd) {
const mdSource = pendingReportMd;
const mdPidMap = pendingReportPidMap;
const mdMainPid = pendingReportMainPid;
pendingReportMd = null;
pendingReportPidMap = {};
pendingReportMainPid = null;
const textWrap = answerPanel.closest('.assistant-text') || answerPanel.parentElement;
if (!reportUi) reportUi = { el: assistantEl, answerPanel, textWrap };
else {
reportUi.answerPanel = answerPanel;
reportUi.textWrap = textWrap;
}
waiting.remove();
answerPanel.classList.remove('cursor');
auditLog('system', '开始直渲报告表格并逐模块生成分析', { traceId });
try {
await composeDirectRenderReport({
md: mdSource,
answerPanel,
textWrap,
messages,
chatFn,
signal: _abortCtrl?.signal,
traceId,
hooks,
onAssistantTextDelta: () => hooks.onAssistantTextDelta?.(),
isAborted,
pidMap: mdPidMap,
mainPid: mdMainPid,
});
} catch (e) {
if (e?.name === 'AbortError' || isAborted()) {
finalizeAbort();
return;
}
answerPanel.innerHTML = `<span style="color:var(--danger-color);">报告组装失败: ${escapeHtml(e.message || e)}</span>`;
hooks.onError?.(e.message || '报告组装失败');
}
return;
}
}
hooks.onError?.(`超出最大轮次 ${CONFIG.MAX_TURNS},强制结束`);
} finally {
_abortCtrl = null;
}
}
function createAssistantBubble(parentEl) {
const msg = document.createElement('div');
msg.className = 'msg assistant';
msg.innerHTML = `
<div class="msg-avatar">A</div>
<div class="msg-body">
<div class="assistant-text">
<div class="think-panel" hidden>
<div class="think-panel-header" title="点击折叠/展开">
<span><span class="think-panel-status">思考中</span></span>
<span class="think-panel-chevron">▼</span>
</div>
<pre class="think-panel-body"></pre>
</div>
<div class="answer-panel"></div>
</div>
<div class="tool-cards-container"></div>
</div>
`;
const panel = msg.querySelector('.think-panel');
const header = msg.querySelector('.think-panel-header');
header?.addEventListener('click', () => {
setThinkPanelCollapsed(panel, !panel.classList.contains('collapsed'));
});
parentEl.appendChild(msg);
parentEl.scrollTop = parentEl.scrollHeight;
return msg;
}
function setThinkPanelCollapsed(panel, collapsed) {
if (!panel) return;
panel.classList.toggle('collapsed', !!collapsed);
const chev = panel.querySelector('.think-panel-chevron');
if (chev) chev.textContent = collapsed ? '▶' : '▼';
}
function collapseThinkPanelDone(panel) {
if (!panel || panel.hidden) return;
const st = panel.querySelector('.think-panel-status');
if (st) st.textContent = '思考过程';
setThinkPanelCollapsed(panel, true);
}
function escapeHtml(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function formatToolResultForModel(apiOk, payload, callMeta = null) {
const call = callMeta ? {
name: callMeta.name,
arguments: sanitizeCallArgsForModel(callMeta.arguments),
} : null;
if (!apiOk) {
if (typeof payload === 'string') {
const head = call?.name ? `[工具 ${call.name} 失败]\n` : '';
return head + payload;
}
const out = { ok: false, error: payload };
if (call) out._call = call;
if (payload?.assistantGuidance) out.guidance = payload.assistantGuidance;
return JSON.stringify(out);
}
if (typeof payload === 'string') {
const head = call?.name ? `[工具 ${call.name}]\n` : '';
return head + payload;
}
const result = payload;
if (result && result.ok === false) {
const out = { ok: false, error: result.error, result };
if (call) out._call = call;
if (result.assistantGuidance) out.guidance = result.assistantGuidance;
return JSON.stringify(out);
}
const out = { ok: true, result };
if (call) out._call = call;
if (result?.assistantGuidance) out.guidance = result.assistantGuidance;
return JSON.stringify(out);
}
function isReportDataPayloadReady(payload) {
if (typeof payload === 'string') {
const t = payload.trim();
if (!t || /^#\s*错误/m.test(t)) return false;
return true;
}
return !!(payload && typeof payload === 'object' && payload.ok !== false);
}
function looksLikeFullReportMd(md) {
const t = String(md || '');
if (!t.trim() || /^#\s*错误/m.test(t.trim())) return false;
return /^#\s+/m.test(t) && /^##\s+/m.test(t);
}
function isFuzzyOnlyEmployeeRoster(md) {
const s = String(md || '');
if (!s.trim()) return false;
if (/^#\s*未找到/m.test(s.trim())) return false;
const hasExact = /\|[^\n|]*精确[^\n|]*\|/.test(s);
if (hasExact) return false;
if (/含拼音\/模糊候选人|必须展示给用户确认|禁止自动选定/.test(s)) return true;
if (/匹配方式[:]\s*(模糊|拼音)/.test(s)) return true;
if (/\|[^\n|]*(模糊|拼音)[^\n|]*\|/.test(s)) return true;
return false;
}
function buildEmployeePickAsk(rosterMd, modelText) {
const table = stripReportHints(rosterMd || '').trim();
const model = String(modelText || '').trim();
if (model && isAskingUser(model)) {
if (table && !model.includes('|')) {
return `${model}\n\n${table}`;
}
return model;
}
const parts = [
'系统中没有精确匹配到该姓名,以下为模糊/拼音候选人。',
'请回复**工号**或「工号 姓名」(例如 `04245 王钧伟`),确认后再生成报告;确认前不会继续拉报告数据。',
];
if (table) parts.push('', table);
return parts.join('\n');
}
function sanitizeCallArgsForModel(args) {
if (!args || typeof args !== 'object') return {};
const out = {};
for (const [k, v] of Object.entries(args)) {
if (k.startsWith('_')) continue;
if (v == null || v === '') continue;
if (typeof v === 'object') continue;
out[k] = v;
}
return out;
}
function addToolToSend(toolsToSend, detail) {
if (!detail?.name) return false;
if (toolsToSend.find(t => t.name === detail.name)) return false;
toolsToSend.push({
name: detail.name,
description: detail.description || '',
inputSchema: detail.inputSchema || { type: 'object', properties: {} },
});
return true;
}
function coerceToolDetail(payload) {
if (!payload || typeof payload !== 'object') return null;
if (payload.name) return payload;
if (payload.result && payload.result.name) return payload.result;
return null;
}
const MAX_SEARCH_INJECT = 8;
function stripMetaToolsFromSend(toolsToSend) {
for (let i = toolsToSend.length - 1; i >= 0; i--) {
if (META_TOOL_NAMES.has(toolsToSend[i].name)) {
toolsToSend.splice(i, 1);
}
}
}
async function discoverAndInjectTools({ query, userInput, toolsToSend, mode, traceId, enabledSet, stripMeta = true }) {
const q = String(query || userInput || '').trim();
if (!q) return 0;
let searchResult = null;
try {
const callOpts = { traceId };
const r = mode === 'http'
? await http.callTool('metro_search_tools', { query: q }, callOpts)
: await mcp.callTool('metro_search_tools', { query: q }, callOpts);
if (r.ok && r.result) searchResult = r.result;
} catch (e) {
auditLog('system', `发现 search 调用失败: ${e.message}`, { query: q.slice(0, 40) });
}
let injected = 0;
let via = 'none';
if (searchResult?.tools?.length) {
injected = await injectToolsFromSearch({
searchResult,
toolsToSend,
mode,
traceId,
enabledSet,
userInput: userInput || q,
});
if (injected > 0) via = 'search';
}
if (injected === 0) {
const intentNames = resolveIntentToolNames(userInput || q);
injected = await injectToolsByNames({
toolNames: intentNames,
toolsToSend,
mode,
traceId,
enabledSet,
});
if (injected > 0) {
via = 'intent';
auditLog('system', `意图回退注入 ${injected} 个工具`, { query: q.slice(0, 40) });
} } else { }
if (injected > 0 && stripMeta) {
stripMetaToolsFromSend(toolsToSend);
}
return injected;
}
function sortSearchCandidates(tools) {
const score = (name) => {
if (name.endsWith('_full')) return 0;
if (name.startsWith('metro_setup_')) return 1;
if (name.includes('timetable') || name.includes('load')) return 2;
return 3;
};
return [...tools].sort((a, b) => score(a.name) - score(b.name));
}
async function injectToolsFromSearch({ searchResult, toolsToSend, mode, traceId, enabledSet, userInput }) {
const names = pickInjectionNames(searchResult, userInput);
return injectToolsByNames({ toolNames: names, toolsToSend, mode, traceId, enabledSet });
}
async function injectToolsByNames({ toolNames, toolsToSend, mode, traceId, enabledSet }) {
let injected = 0;
for (const name of toolNames) {
if (!name || toolsToSend.find(x => x.name === name)) continue;
if (enabledSet && !enabledSet.has(name)) continue;
let detailResult;
try {
const callOpts = { traceId };
detailResult = mode === 'http'
? await http.callTool('metro_get_tool_detail', { name }, callOpts)
: await mcp.callTool('metro_get_tool_detail', { name }, callOpts);
} catch {
continue;
}
if (detailResult.ok && addToolToSend(toolsToSend, detailResult.result)) {
injected++;
}
}
return injected;
}
function resolveIntentToolNames(text) {
const q = String(text || '');
if (/加载|打开/.test(q) && /运营图|时刻表/.test(q)) {
return ['metro_load_timetable_full'];
}
if (/轮乘|吃饭|培训/.test(q)) {
return ['metro_setup_rest_rules'];
}
if (/交接/.test(q)) {
return ['metro_setup_handover_rules'];
}
return [];
}
function isChatOnlyIntent(text) {
const q = String(text || '');
if (/加载|打开运营图|打开时刻表|加轮乘|加交接|设置轮乘|设置交接|校验|未分配|导出|备份|查询司机|应用规则/.test(q)) {
return false;
}
if (shouldUseMultiTurnReport(q)) return false;
if (/介绍一下自己|介绍自己|自我介绍|向台下|向评委|评审老师介绍|介绍一下你|你是谁|不少于\s*\d+\s*字/.test(q)) {
return true;
}
if (/^(你好|您好|嗨|hi\b|hello\b)[\s,。.!]*$/i.test(q.trim())) {
return true;
}
return false;
}
function isMereConfirmation(text) {
const q = String(text || '').trim();
return /^(好的?|好的呢|可以|行|确认|是的?|对|嗯|要|查这个|就这个|继续|ok|okay)[。.!]?$/i.test(q);
}
function shouldUseMultiTurnReport(text, history = []) {
if (isReportRequestText(text) && !isMereConfirmation(text)) return true;
return isReportFollowUp(text, history);
}
function isReportRequestText(text) {
const q = String(text || '');
if (/(简单|简要|概要|摘要|短版|精简|只看|只要).{0,8}(报告|分析|评估)/.test(q)) return false;
if (/(报告|分析|评估).{0,8}(简单|简要|概要|摘要|短版|精简)/.test(q)) return false;
if (/(详细评估报告|详细分析报告|详细报告|完整报告|全面分析|专业分析报告|详细分析)/.test(q)) return true;
if (/(评估报告|分析报告)/.test(q) && /(生成|写|出具|输出|做一份|出一份|详细|完整|全面|专业)/.test(q)) {
return true;
}
if (/(报告)/.test(q) && /(详细|完整|全面|专业)/.test(q) && /(生成|写|出具|输出|做一份|出一份)/.test(q)) {
return true;
}
return false;
}
function isReportFollowUp(text, history) {
if (!Array.isArray(history) || history.length === 0) return false;
const userMsgs = history.filter(m => m.role === 'user' && !isReportContinueUser(m)
&& !isReportToolNudgeMessage(m.content));
const askedReport = userMsgs.some(m => isReportRequestText(m.content));
if (!askedReport) return false;
const q = String(text || '').trim();
if (!q || q.length > 80) return false;
if (/^(19|20)\d{2}\s*年?$/.test(q)) return true;
if (/(19|20)\d{2}/.test(q)) return true;
if (isMereConfirmation(q)) return true;
if (/请?生成|开始(吧|写|生成)|继续生成|赶紧|快点/.test(q)) return true;
if (/^\d{4,6}\b/.test(q)) return true;
if (/^\d{4,6}\s*[\u4e00-\u9fff]{2,4}/.test(q)) return true;
if (q.length <= 24) return true;
return false;
}
function isReportToolNudgeMessage(content) {
const t = String(content || '');
return /必须立刻真正调用工具/.test(t)
&& /profile_get_(?:person|team)_report/.test(t);
}
function buildReportToolNudge(messages, userInput) {
const blob = [
...(Array.isArray(messages) ? messages : []).filter(m => m.role === 'user').map(m => m.content),
userInput,
].join('\n');
const years = (blob.match(/(?:19|20)\d{2}/g) || []).map(Number);
const year = years.length ? years[years.length - 1] : null;
const idHit = blob.match(/\b(\d{5})\b/) || String(userInput || '').match(/\b(\d{5})\b/);
const employeeId = idHit ? idHit[1] : '';
const nameHit = blob.match(/([一-龥·]{2,4})的(?:数据|画像|报告|评估|考试)/)
|| blob.match(/(?:查看|查一下|查询|帮我看)\s*([一-龥·]{2,4})/)
|| String(userInput || '').match(/^(\d{4,6}\s*)?([一-龥·]{2,4})\s*$/);
const person = employeeId ? '' : (nameHit ? (nameHit[2] || nameHit[1]) : '');
const teamHit = blob.match(/(乘务[一二三四五六七八九十\d]+组|列车队)/);
const team = teamHit ? teamHit[1] : '';
const parts = [
'不要只描述「我将调用工具」,必须立刻真正调用工具。',
team
? `请调用 profile_get_team_report,arguments 含 team="${team}"${year ? `, year=${year}` : ''}`
: employeeId
? `请调用 profile_get_person_report,arguments 含 employeeId="${employeeId}"${year ? `, year=${year}` : ''}`
: `请调用 profile_get_person_report,arguments 含 ${person ? `employeeName="${person}"` : 'employeeName(对话中的员工)'}${year ? `, year=${year}` : ''}`,
year ? `年份已确认为 ${year},禁止再问年份。` : '若对话里还没有年份,用中文追问一次即可,不要空转。',
'调用成功后不要自己写报告正文,系统会直渲表格并逐模块请你写分析;本轮只需输出 tool_call。',
];
return parts.join('');
}
const REPORT_DATA_TOOLS = new Set([
'profile_get_person_report',
'profile_get_team_report',
]);
function parseReportMarkers(text) {
const raw = String(text || '');
const continueMark = /\[CONTINUE\]/i.test(raw);
const doneMark = /\[DONE\]/i.test(raw);
return {
clean: stripReportMarkers(raw),
continue: continueMark && !doneMark,
done: doneMark,
};
}
function isAskingUser(content) {
const t = String(content || '').trim();
if (!t) return false;
if (t.length < 400 && (
/[?]/.test(t)
|| /请(确认|补充|告知|提供|选择|告诉)/.test(t)
|| /哪一[年月位]|需要.*年份|缺少.*年/.test(t)
|| /模糊匹配|拼音|候选人|您要找的是哪位/.test(t)
)) {
return true;
}
return false;
}
function looksLikeReportChunk(text) {
const t = String(text || '').trim();
if (!t) return false;
if (isAskingUser(t)) return false;
if (/^#{1,3}\s/m.test(t)) return true;
if (/\|[^\n]+\|[\r\n]+\|[-:\s|]+\|/.test(t) || t.includes('| ---')) return true;
if (/(基本信息|六维|在线考试|实操|公里|故障|绩效|事故|综合评价|能力画像)/.test(t)
&& t.replace(/\s+/g, '').length >= 80) return true;
if (t.replace(/\s+/g, '').length >= 200 && !/无法开始|需要您补充|年份缺失/.test(t)) return true;
return false;
}
function isReportContinueUser(m) {
if (!m || m.role !== 'user') return false;
const c = String(m.content || '');
return c === '继续下一个模块'
|| c.startsWith('继续下一个模块')
|| c.startsWith('继续输出报告正文');
}
function collapseReportMessages(messages, fullMd) {
if (!Array.isArray(messages) || !fullMd) return;
let cut = messages.length;
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i];
if (isReportContinueUser(m)) {
cut = i;
continue;
}
if (m.role === 'assistant' && !m.toolCalls) {
cut = i;
continue;
}
break;
}
while (cut > 0 && isReportContinueUser(messages[cut])) {
cut--;
}
if (cut < messages.length && messages[cut]?.role === 'assistant' && !messages[cut]?.toolCalls) {
messages.splice(cut, messages.length - cut, {
role: 'assistant',
content: fullMd,
});
}
}
function pickInjectionNames(searchResult, userInput) {
const sorted = sortSearchCandidates(searchResult.tools || [])
.filter(t => t.name && !META_TOOL_NAMES.has(t.name));
const names = sorted.map(t => t.name);
const wf = names.find(n => n.endsWith('_full'));
if (wf) {
const out = [wf];
for (const n of names) {
if (n !== wf && out.length < MAX_SEARCH_INJECT) out.push(n);
}
return out;
}
return names.slice(0, MAX_SEARCH_INJECT);
}
function parseRawContent(s) {
if (!s) return undefined;
try { return JSON.parse(s); } catch (_) { return s; }
}
function scanPII(rawContent) {
if (!rawContent) return [];
const text = typeof rawContent === 'string' ? rawContent : JSON.stringify(rawContent);
const hits = [];
const idRegex = /(?<![\d-:])(\d{5})(?![\d-:])/g;
const idMatches = [];
let m;
while ((m = idRegex.exec(text)) !== null) {
const num = m[1];
if (/^20\d{2}$/.test(num)) continue;
idMatches.push(num);
}
if (idMatches.length >= 3) {
const samples = [...new Set(idMatches)].slice(0, 5);
hits.push({
name: '疑似工号(5 位数字)',
count: idMatches.length,
samples,
});
}
return hits;
}
function summarizeResult(result) {
if (result == null) return '(null)';
if (typeof result === 'string') return result.slice(0, 100);
if (Array.isArray(result)) return `[${result.length} 项]`;
if (typeof result === 'object') {
const keys = Object.keys(result).slice(0, 5);
return `{${keys.join(', ')}${Object.keys(result).length > 5 ? ', ...' : ''}}`;
}
return String(result).slice(0, 100);
}