// @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 = `
${sourceLabel}${thinkOn ? '思考中' : '推理中'}${reportContinuing ? '(续写报告)' : ''}...`;
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 = `${sourceLabel}调用失败: ${escapeHtml(e.message)}`;
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 = `检测到无效工具调用已忽略,正在重新生成介绍...`;
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 = `工具已返回,正在生成回复...`;
auditLog('system', '空输出兜底:要求根据工具结果直接回复(禁止再调工具)', { traceId, turn });
reportContinuing = false;
continue;
}
answerPanel.innerHTML = `(无输出)`;
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 = `正在拉取报告数据...`;
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 = `报告组装失败: ${escapeHtml(e.message || e)}`;
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 = `
A
`;
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, '&').replace(//g, '>');
}
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 = /(?= 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);
}