// @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); }