1488 lines
60 KiB
TypeScript
1488 lines
60 KiB
TypeScript
// @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, '&').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 = /(?<![\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);
|
||
}
|