From 0ee27eb523755e40faefc0d7ff442a79bc8be0b9 Mon Sep 17 00:00:00 2001 From: zhuyongxin Date: Tue, 7 Jul 2026 19:06:18 +0800 Subject: [PATCH] feat(trace): improve session workbench review --- .../agent/controller/FaviconController.java | 19 + .../agent/repository/AgentStepRepository.java | 5 + .../agent/service/DiagnosisTraceService.java | 12 +- src/main/resources/static/app.js | 52 +- src/main/resources/static/index.html | 7 +- src/main/resources/static/trace.css | 224 +++++++- src/main/resources/static/trace.html | 95 ++- src/main/resources/static/trace.js | 541 +++++++++++++++--- .../service/DiagnosisTraceServiceTest.java | 44 +- 9 files changed, 895 insertions(+), 104 deletions(-) create mode 100644 src/main/java/com/superbiz/agent/controller/FaviconController.java diff --git a/src/main/java/com/superbiz/agent/controller/FaviconController.java b/src/main/java/com/superbiz/agent/controller/FaviconController.java new file mode 100644 index 0000000..1e32399 --- /dev/null +++ b/src/main/java/com/superbiz/agent/controller/FaviconController.java @@ -0,0 +1,19 @@ +package com.superbiz.agent.controller; + +import org.springframework.http.CacheControl; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +import java.time.Duration; + +@Controller +public class FaviconController { + + @GetMapping("/favicon.ico") + public ResponseEntity favicon() { + return ResponseEntity.noContent() + .cacheControl(CacheControl.maxAge(Duration.ofDays(1))) + .build(); + } +} diff --git a/src/main/java/com/superbiz/agent/repository/AgentStepRepository.java b/src/main/java/com/superbiz/agent/repository/AgentStepRepository.java index 40b9d72..115a777 100644 --- a/src/main/java/com/superbiz/agent/repository/AgentStepRepository.java +++ b/src/main/java/com/superbiz/agent/repository/AgentStepRepository.java @@ -17,6 +17,11 @@ public interface AgentStepRepository extends JpaRepository { */ List findBySessionIdOrderByStepIndex(String sessionId); + /** + * 根据会话ID查询所有步骤,调用方负责按具体视图语义排序。 + */ + List findBySessionId(String sessionId); + /** * 统计某个会话的步骤数 */ diff --git a/src/main/java/com/superbiz/agent/service/DiagnosisTraceService.java b/src/main/java/com/superbiz/agent/service/DiagnosisTraceService.java index 74bfbcf..c3206c3 100644 --- a/src/main/java/com/superbiz/agent/service/DiagnosisTraceService.java +++ b/src/main/java/com/superbiz/agent/service/DiagnosisTraceService.java @@ -13,6 +13,7 @@ import com.superbiz.agent.repository.ToolInvocationRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import java.util.Comparator; import java.util.List; import java.util.Map; @@ -31,7 +32,7 @@ public class DiagnosisTraceService { public DiagnosisTraceResponse getTrace(String sessionId) { DiagnosisSession session = diagnosisSessionRepository.findBySessionId(sessionId) .orElseThrow(() -> new SessionNotFoundException(sessionId)); - List steps = agentStepRepository.findBySessionIdOrderByStepIndex(sessionId); + List steps = orderStepsForTrace(agentStepRepository.findBySessionId(sessionId)); List toolInvocations = toolInvocationRepository.findBySessionIdOrderByIdAsc(sessionId); return DiagnosisTraceResponse.builder() @@ -42,6 +43,15 @@ public class DiagnosisTraceService { .build(); } + private List orderStepsForTrace(List steps) { + return steps.stream() + .sorted(Comparator + .comparing(AgentStep::getCreatedAt, Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(AgentStep::getId, Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(AgentStep::getStepIndex, Comparator.nullsLast(Comparator.naturalOrder()))) + .toList(); + } + private DiagnosisTraceResponse.SessionTrace toSessionTrace(DiagnosisSession session) { return DiagnosisTraceResponse.SessionTrace.builder() .id(session.getId()) diff --git a/src/main/resources/static/app.js b/src/main/resources/static/app.js index bf524f3..e394be4 100644 --- a/src/main/resources/static/app.js +++ b/src/main/resources/static/app.js @@ -8,6 +8,7 @@ class SuperBizAgentApp { this.currentChatHistory = []; // 当前对话的消息历史 this.chatHistories = this.loadChatHistories(); // 所有历史对话 this.isCurrentChatFromHistory = false; // 标记当前对话是否是从历史记录加载的 + this.lastTraceSessionId = this.loadLastTraceSessionId(); this.initializeElements(); this.bindEvents(); @@ -15,6 +16,7 @@ class SuperBizAgentApp { this.initMarkdown(); this.checkAndSetCentered(); this.renderChatHistory(); + this.updateTraceWorkbenchLink(); } // 初始化Markdown配置 @@ -116,6 +118,7 @@ class SuperBizAgentApp { this.chatContainer = document.querySelector('.chat-container'); this.welcomeGreeting = document.getElementById('welcomeGreeting'); this.chatHistoryList = document.getElementById('chatHistoryList'); + this.traceWorkbenchLink = document.getElementById('traceWorkbenchLink'); // 初始化时检查是否需要居中 this.checkAndSetCentered(); @@ -265,6 +268,7 @@ class SuperBizAgentApp { // 生成新的会话ID this.sessionId = this.generateSessionId(); + this.updateTraceWorkbenchLink(); // 重置模式为快速 this.currentMode = 'quick'; @@ -312,6 +316,7 @@ class SuperBizAgentApp { // 添加到历史记录列表的开头 this.chatHistories.unshift(chatHistory); + this.rememberTraceSessionId(this.sessionId); // 限制历史记录数量(最多保存50条) if (this.chatHistories.length > 50) { @@ -439,6 +444,7 @@ class SuperBizAgentApp { // 加载历史对话 this.sessionId = history.id; + this.rememberTraceSessionId(this.sessionId); this.currentChatHistory = [...history.messages]; this.isCurrentChatFromHistory = true; // 标记为从历史记录加载 @@ -468,10 +474,45 @@ class SuperBizAgentApp { this.chatMessages.innerHTML = ''; } this.sessionId = this.generateSessionId(); + this.updateTraceWorkbenchLink(); this.checkAndSetCentered(); } } + loadLastTraceSessionId() { + try { + return localStorage.getItem('lastTraceSessionId') || ''; + } catch (e) { + return ''; + } + } + + rememberTraceSessionId(sessionId) { + if (!sessionId) { + return; + } + this.lastTraceSessionId = sessionId; + try { + localStorage.setItem('lastTraceSessionId', sessionId); + } catch (e) { + // localStorage may be unavailable in private or restricted contexts. + } + this.updateTraceWorkbenchLink(); + } + + updateTraceWorkbenchLink() { + if (!this.traceWorkbenchLink) { + return; + } + const recentHistory = this.chatHistories.find(history => history && history.id); + const sessionId = this.lastTraceSessionId + || (this.currentChatHistory.length > 0 ? this.sessionId : '') + || (recentHistory ? recentHistory.id : ''); + this.traceWorkbenchLink.href = sessionId + ? `trace.html?sessionId=${encodeURIComponent(sessionId)}` + : 'trace.html'; + } + // 切换模式下拉菜单 toggleModeDropdown() { if (this.modeSelectorBtn && this.modeDropdown) { @@ -637,6 +678,7 @@ class SuperBizAgentApp { // 保存后端返回的 sessionId,用于 feedback 提交 if (chatResponse.sessionId) { this.lastSessionId = chatResponse.sessionId; + this.rememberTraceSessionId(chatResponse.sessionId); } // 成功:添加实际响应消息(即使 answer 为空也显示) const answer = chatResponse.answer || '(无回复内容)'; @@ -1231,7 +1273,10 @@ class SuperBizAgentApp { for (const jsonStr of matches) { try { const sseMessage = JSON.parse(jsonStr); - if (sseMessage.type === 'content') { + if (sseMessage.type === 'session') { + this.lastSessionId = sseMessage.data; + this.rememberTraceSessionId(sseMessage.data); + } else if (sseMessage.type === 'content') { fullResponse += sseMessage.data || ''; } else if (sseMessage.type === 'done') { console.log('AI Ops 流完成,最终内容长度:', fullResponse.length); @@ -1261,7 +1306,10 @@ class SuperBizAgentApp { try { const sseMessage = JSON.parse(rawData); if (sseMessage && sseMessage.type) { - if (sseMessage.type === 'content') { + if (sseMessage.type === 'session') { + this.lastSessionId = sseMessage.data; + this.rememberTraceSessionId(sseMessage.data); + } else if (sseMessage.type === 'content') { fullResponse += sseMessage.data || ''; if (loadingMessageElement) { this.updateAIOpsStreamContent(loadingMessageElement, fullResponse); diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html index 8aa46ef..2bd50d6 100644 --- a/src/main/resources/static/index.html +++ b/src/main/resources/static/index.html @@ -4,6 +4,7 @@ 智能OnCall助手 + @@ -34,12 +35,12 @@ 文档管理 - + - Trace Workbench + 链路检查台
@@ -142,6 +143,6 @@
- + diff --git a/src/main/resources/static/trace.css b/src/main/resources/static/trace.css index 2b51ada..f02a8e3 100644 --- a/src/main/resources/static/trace.css +++ b/src/main/resources/static/trace.css @@ -123,11 +123,16 @@ svg { gap: 8px; } +.session-picker { + position: relative; + min-width: 0; +} + .session-control input { width: 100%; min-width: 0; height: 42px; - padding: 0 12px; + padding: 0 46px 0 12px; border: 1px solid #4b5563; border-radius: 6px; background: #0b1220; @@ -135,7 +140,76 @@ svg { font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; } -.session-control button { +.session-picker-button { + position: absolute; + top: 4px; + right: 4px; + width: 34px; + height: 34px; + padding: 0; + border: 1px solid #334155; + border-radius: 5px; + background: #111827; + color: #cbd5e1; +} + +.session-options { + position: absolute; + z-index: 20; + top: 48px; + left: 0; + right: 0; + max-height: 320px; + overflow: auto; + padding: 6px; + border: 1px solid #4b5563; + border-radius: 8px; + background: #0b1220; + box-shadow: 0 18px 40px rgba(0, 0, 0, 0.32); +} + +.session-option { + width: 100%; + display: grid; + gap: 4px; + padding: 10px; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + color: var(--white); + text-align: left; +} + +.session-option:hover, +.session-option.active { + border-color: #2f8375; + background: rgba(31, 122, 107, 0.18); +} + +.session-option-title { + font-size: 13px; + font-weight: 720; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.session-option-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + color: #94a3b8; + font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; + font-size: 11px; +} + +.session-option-empty { + padding: 12px; + color: #94a3b8; + font-size: 13px; +} + +.session-control > button { height: 42px; display: inline-flex; align-items: center; @@ -149,7 +223,7 @@ svg { font-weight: 700; } -.session-control button:disabled { +.session-control > button:disabled { opacity: 0.62; cursor: wait; } @@ -176,6 +250,132 @@ svg { background: var(--evidence-soft); } +.outcome-panel { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr); + gap: 0; + border: 1px solid var(--line); + border-radius: 8px; + overflow: hidden; + background: var(--white); +} + +.outcome-main { + min-width: 0; + padding: 20px; + border-left: 7px solid var(--steel); +} + +.outcome-panel.ok .outcome-main { + border-left-color: var(--evidence); +} + +.outcome-panel.warn .outcome-main { + border-left-color: var(--warn); +} + +.outcome-panel.bad .outcome-main { + border-left-color: var(--reject); +} + +.outcome-eyebrow { + margin: 0 0 8px; + color: var(--steel); + font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; + font-size: 12px; + text-transform: uppercase; +} + +.outcome-main h2 { + margin: 0; + font-size: clamp(24px, 3vw, 38px); + line-height: 1.08; + letter-spacing: 0; +} + +.outcome-main > p:not(.outcome-eyebrow) { + margin: 10px 0 0; + color: var(--steel); + line-height: 1.5; +} + +.outcome-answer { + margin-top: 16px; + padding: 14px 16px; + max-height: 180px; + overflow: auto; + border: 1px solid var(--line); + border-radius: 6px; + background: #fffaf1; + color: #172033; + line-height: 1.65; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.outcome-side { + display: grid; + grid-template-rows: auto 1fr; + border-left: 1px solid var(--line); + background: #fbf7ef; +} + +.outcome-verdict { + padding: 18px; + border-bottom: 1px solid var(--line); +} + +.outcome-verdict span, +.outcome-insights h3 { + color: var(--steel); + font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; + font-size: 12px; + text-transform: uppercase; +} + +.outcome-verdict strong { + display: block; + margin-top: 6px; + font-size: 30px; + line-height: 1.05; +} + +.outcome-verdict p { + margin: 10px 0 0; + color: var(--ink-soft); + line-height: 1.5; +} + +.outcome-insights { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0; +} + +.outcome-insights > div { + min-width: 0; + padding: 16px 18px; +} + +.outcome-insights > div + div { + border-left: 1px solid var(--line); +} + +.outcome-insights h3 { + margin: 0 0 10px; +} + +.outcome-insights ul { + margin: 0; + padding-left: 18px; +} + +.outcome-insights li { + margin-bottom: 8px; + line-height: 1.45; + overflow-wrap: anywhere; +} + .summary-strip { display: grid; grid-template-columns: repeat(7, minmax(120px, 1fr)); @@ -547,6 +747,15 @@ pre, } @media (max-width: 1180px) { + .outcome-panel { + grid-template-columns: 1fr; + } + + .outcome-side { + border-left: 0; + border-top: 1px solid var(--line); + } + .summary-strip { grid-template-columns: repeat(4, minmax(120px, 1fr)); } @@ -581,6 +790,15 @@ pre, grid-template-columns: repeat(2, minmax(0, 1fr)); } + .outcome-insights { + grid-template-columns: 1fr; + } + + .outcome-insights > div + div { + border-left: 0; + border-top: 1px solid var(--line); + } + .summary-cell, .summary-cell:nth-child(4) { border-right: 1px solid var(--line); diff --git a/src/main/resources/static/trace.html b/src/main/resources/static/trace.html index 37f18d6..c390664 100644 --- a/src/main/resources/static/trace.html +++ b/src/main/resources/static/trace.html @@ -1,67 +1,102 @@ - + - Trace Workbench - SuperBizAgent - + 诊断链路检查台 - SuperBizAgent + +
- + - Chat + 返回聊天 -

Trace workbench

+

诊断链路检查台

- +
- +
+ + + +
-
Enter a session id to load a persisted diagnosis trace.
+
输入会话 ID,加载已持久化的诊断链路。
-
+
+
+

本次会话结果

+

尚未加载会话

+

选择一个会话后,这里会先展示最终结论和证据状态。

+
-
+
+
+
+ 校验判断 + - +

-

+
+
+
+

已确认

+
    +
    +
    +

    仍需关注

    +
      +
      +
      +
      +
      + +
      - Status + 状态 -
      - Verdict + 校验结论 -
      - Groundedness + 证据分 -
      - Steps + 步骤 -
      - Tools + 工具 -
      - Duration + 耗时 -
      - Tokens + 令牌 -
      @@ -70,8 +105,8 @@
      -

      Agent rail

      -

      Persisted steps

      +

      智能体轨迹

      +

      持久化步骤

      0
      @@ -81,8 +116,8 @@
      -

      Evidence ledger

      -

      Tool invocations

      +

      证据账本

      +

      工具调用

      0
      @@ -93,30 +128,30 @@
      -

      Inspector

      -

      Verifier and RAG

      +

      检查器

      +

      校验与检索增强

      - idle + 空闲
      -

      Skill boundary

      +

      技能边界

      -

      Verifier facts

      +

      校验事实

      -

      Selected tool

      +

      选中工具

      -

      Final answer

      +

      最终回答

      -
      @@ -124,6 +159,6 @@
      - + diff --git a/src/main/resources/static/trace.js b/src/main/resources/static/trace.js index 5326002..de612ff 100644 --- a/src/main/resources/static/trace.js +++ b/src/main/resources/static/trace.js @@ -6,6 +6,8 @@ class TraceWorkbench { this.sessionForm = document.getElementById('sessionForm'); this.sessionIdInput = document.getElementById('sessionIdInput'); + this.sessionPickerButton = document.getElementById('sessionPickerButton'); + this.sessionOptions = document.getElementById('sessionOptions'); this.loadButton = document.getElementById('loadButton'); this.stateLine = document.getElementById('stateLine'); this.stepsRail = document.getElementById('stepsRail'); @@ -15,6 +17,17 @@ class TraceWorkbench { this.verifierFacts = document.getElementById('verifierFacts'); this.toolInspector = document.getElementById('toolInspector'); this.finalAnswer = document.getElementById('finalAnswer'); + this.outcomePanel = document.getElementById('outcomePanel'); + this.outcomeTitle = document.getElementById('outcomeTitle'); + this.outcomeSubtitle = document.getElementById('outcomeSubtitle'); + this.outcomeVerdict = document.getElementById('outcomeVerdict'); + this.outcomeEvidenceState = document.getElementById('outcomeEvidenceState'); + this.outcomeAnswerSummary = document.getElementById('outcomeAnswerSummary'); + this.outcomeConfirmed = document.getElementById('outcomeConfirmed'); + this.outcomeGaps = document.getElementById('outcomeGaps'); + this.sessionCandidates = []; + this.filteredSessionCandidates = []; + this.activeSessionIndex = 0; this.bindEvents(); this.bootstrapFromUrl(); @@ -25,11 +38,34 @@ class TraceWorkbench { event.preventDefault(); this.loadTrace(this.sessionIdInput.value.trim()); }); + + this.sessionIdInput.addEventListener('focus', () => { + this.renderSessionOptions(this.sessionIdInput.value); + this.openSessionOptions(); + }); + + this.sessionIdInput.addEventListener('input', () => { + this.renderSessionOptions(this.sessionIdInput.value); + this.openSessionOptions(); + }); + + this.sessionIdInput.addEventListener('keydown', (event) => this.handleSessionInputKeydown(event)); + + this.sessionPickerButton.addEventListener('click', () => { + this.renderSessionOptions(this.sessionIdInput.value); + this.toggleSessionOptions(); + }); + + document.addEventListener('click', (event) => { + if (!this.sessionForm.contains(event.target)) { + this.closeSessionOptions(); + } + }); } bootstrapFromUrl() { const params = new URLSearchParams(window.location.search); - const sessionId = params.get('sessionId'); + const sessionId = params.get('sessionId') || this.getLastTraceSessionId(); if (sessionId) { this.sessionIdInput.value = sessionId; this.loadTrace(sessionId); @@ -38,13 +74,204 @@ class TraceWorkbench { this.renderEmpty(); } - async loadTrace(sessionId) { - if (!sessionId) { - this.setState('Enter a session id before loading.', 'error'); + getLastTraceSessionId() { + try { + const remembered = localStorage.getItem('lastTraceSessionId'); + if (remembered) { + return remembered; + } + const histories = JSON.parse(localStorage.getItem('chatHistories') || '[]'); + const recent = Array.isArray(histories) ? histories.find(history => history && history.id) : null; + return recent ? recent.id : ''; + } catch (error) { + return ''; + } + } + + loadSessionCandidates() { + const candidates = []; + const seen = new Set(); + const addCandidate = (candidate) => { + if (!candidate || !candidate.id || seen.has(candidate.id)) { + return; + } + seen.add(candidate.id); + candidates.push(candidate); + }; + + try { + const remembered = localStorage.getItem('lastTraceSessionId'); + const histories = JSON.parse(localStorage.getItem('chatHistories') || '[]'); + if (Array.isArray(histories)) { + histories.forEach((history) => addCandidate({ + id: history.id, + title: history.title || '未命名对话', + updatedAt: history.updatedAt || history.createdAt || '', + source: 'history' + })); + } + if (remembered) { + const existing = candidates.find(candidate => candidate.id === remembered); + if (existing) { + existing.source = 'recent'; + } else { + addCandidate({ + id: remembered, + title: '最近查看的会话', + updatedAt: '', + source: 'recent' + }); + } + } + } catch (error) { + return candidates; + } + + return candidates + .sort((left, right) => { + if (left.source === 'recent' && right.source !== 'recent') { + return -1; + } + if (right.source === 'recent' && left.source !== 'recent') { + return 1; + } + return this.timestampOf(right.updatedAt) - this.timestampOf(left.updatedAt); + }) + .slice(0, 20); + } + + timestampOf(value) { + if (!value) { + return 0; + } + const timestamp = new Date(value).getTime(); + return Number.isNaN(timestamp) ? 0 : timestamp; + } + + renderSessionOptions(query = '') { + this.sessionCandidates = this.loadSessionCandidates(); + const keyword = query.trim().toLowerCase(); + this.filteredSessionCandidates = keyword + ? this.sessionCandidates.filter((candidate) => { + return candidate.id.toLowerCase().includes(keyword) + || candidate.title.toLowerCase().includes(keyword); + }) + : this.sessionCandidates; + this.activeSessionIndex = 0; + + if (!this.filteredSessionCandidates.length) { + this.sessionOptions.innerHTML = '
      没有匹配的最近会话
      '; return; } - this.setState(`Loading ${sessionId}...`, 'loading'); + this.sessionOptions.innerHTML = this.filteredSessionCandidates.map((candidate, index) => ` + + `).join(''); + + this.sessionOptions.querySelectorAll('.session-option').forEach((button) => { + button.addEventListener('click', () => this.chooseSession(button.dataset.sessionId)); + }); + } + + openSessionOptions() { + this.sessionOptions.hidden = false; + this.sessionIdInput.setAttribute('aria-expanded', 'true'); + } + + closeSessionOptions() { + this.sessionOptions.hidden = true; + this.sessionIdInput.setAttribute('aria-expanded', 'false'); + } + + toggleSessionOptions() { + if (this.sessionOptions.hidden) { + this.openSessionOptions(); + this.sessionIdInput.focus(); + return; + } + this.closeSessionOptions(); + } + + handleSessionInputKeydown(event) { + if (event.key === 'Escape') { + this.closeSessionOptions(); + return; + } + + if (this.sessionOptions.hidden || !this.filteredSessionCandidates.length) { + return; + } + + if (event.key === 'ArrowDown') { + event.preventDefault(); + this.activeSessionIndex = Math.min(this.activeSessionIndex + 1, this.filteredSessionCandidates.length - 1); + this.markActiveSessionOption(); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + this.activeSessionIndex = Math.max(this.activeSessionIndex - 1, 0); + this.markActiveSessionOption(); + } else if (event.key === 'Enter') { + event.preventDefault(); + const candidate = this.filteredSessionCandidates[this.activeSessionIndex]; + if (candidate) { + this.chooseSession(candidate.id); + } + } + } + + markActiveSessionOption() { + this.sessionOptions.querySelectorAll('.session-option').forEach((button, index) => { + button.classList.toggle('active', index === this.activeSessionIndex); + if (index === this.activeSessionIndex) { + button.scrollIntoView({ block: 'nearest' }); + } + }); + } + + chooseSession(sessionId) { + this.sessionIdInput.value = sessionId; + this.closeSessionOptions(); + this.loadTrace(sessionId); + } + + shortSessionId(sessionId) { + if (!sessionId || sessionId.length <= 34) { + return sessionId || '-'; + } + return `${sessionId.slice(0, 18)}...${sessionId.slice(-10)}`; + } + + formatOptionDate(value) { + if (!value) { + return '最近'; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return '最近'; + } + return date.toLocaleString('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false + }); + } + + async loadTrace(sessionId) { + if (!sessionId) { + this.setState('请先输入会话 ID。', 'error'); + return; + } + + this.setState(`正在加载 ${sessionId}...`, 'loading'); + this.updateUrl(sessionId); this.loadButton.disabled = true; try { @@ -55,9 +282,13 @@ class TraceWorkbench { ? data.toolInvocations[0].id : null; this.activeFilter = 'all'; - this.updateUrl(sessionId); + try { + localStorage.setItem('lastTraceSessionId', sessionId); + } catch (error) { + // ignore storage failures + } this.renderTrace(); - this.setState(`Loaded ${sessionId}.`, ''); + this.setState(`已加载 ${sessionId}。`, ''); } catch (error) { this.trace = null; this.renderEmpty(); @@ -72,16 +303,27 @@ class TraceWorkbench { try { payload = await response.json(); } catch (error) { - throw new Error(`HTTP ${response.status}: response is not JSON`); + throw new Error(`HTTP ${response.status}: 响应不是 JSON`); } if (!response.ok || payload.code !== 200) { - throw new Error(payload.message || `HTTP ${response.status}: trace request failed`); + throw new Error(this.localizeError(payload.message) || `HTTP ${response.status}: 诊断链路请求失败`); } return payload.data; } + localizeError(message) { + if (!message) { + return ''; + } + const value = String(message); + if (value.startsWith('Session not found:')) { + return value.replace('Session not found:', '未找到会话:'); + } + return value; + } + updateUrl(sessionId) { const url = new URL(window.location.href); url.searchParams.set('sessionId', sessionId); @@ -95,8 +337,8 @@ class TraceWorkbench { const steps = this.trace.steps || []; const tools = this.trace.toolInvocations || []; - this.text('summaryStatus', session.status || '-'); - this.text('summaryVerdict', verifier.verdict || '-'); + this.text('summaryStatus', this.displayStatus(session.status)); + this.text('summaryVerdict', this.displayVerdict(verifier.verdict)); this.text('summaryScore', this.formatScore(verifier.groundedness_score)); this.text('summarySteps', `${summary.returnedStepCount ?? steps.length}/${summary.persistedStepCount ?? session.stepCount ?? '-'}`); this.text('summaryTools', `${summary.returnedToolCallCount ?? tools.length}/${summary.persistedToolCallCount ?? session.toolCallCount ?? '-'}`); @@ -104,15 +346,16 @@ class TraceWorkbench { this.text('summaryTokens', this.formatNumber(session.totalTokenCount)); this.text('stepCount', steps.length); this.text('toolCount', tools.length); - this.text('inspectorMode', tools.length ? 'tool' : 'trace'); + this.text('inspectorMode', tools.length ? '工具' : '链路'); + this.renderOutcome(session, verifier, steps, tools); this.renderSteps(steps); this.renderToolFilters(tools); this.renderTools(tools); this.renderSkillBoundary(); this.renderVerifier(verifier); this.renderToolInspector(); - this.finalAnswer.textContent = session.answer || 'No final answer persisted.'; + this.finalAnswer.textContent = session.answer || '未持久化最终回答。'; } renderEmpty() { @@ -125,25 +368,150 @@ class TraceWorkbench { this.text('summaryTokens', '-'); this.text('stepCount', '0'); this.text('toolCount', '0'); - this.text('inspectorMode', 'idle'); + this.text('inspectorMode', '空闲'); - this.stepsRail.innerHTML = '
      No trace loaded.
      '; + this.renderOutcome({}, {}, [], []); + this.stepsRail.innerHTML = '
      尚未加载诊断链路。
      '; this.toolFilters.innerHTML = ''; - this.toolLedger.innerHTML = '
      No tool invocations loaded.
      '; + this.toolLedger.innerHTML = '
      尚未加载工具调用。
      '; this.skillBoundary.innerHTML = this.renderBoundaryItems({ selectedSkill: '-', plannerReadSkill: 0, executorReadSkill: 0, verifierReadSkill: 0 }); - this.verifierFacts.innerHTML = '
      No verifier evaluation loaded.
      '; - this.toolInspector.innerHTML = '
      Select a tool invocation after loading a trace.
      '; + this.verifierFacts.innerHTML = '
      尚未加载校验结果。
      '; + this.toolInspector.innerHTML = '
      加载诊断链路后选择一个工具调用。
      '; this.finalAnswer.textContent = '-'; } + renderOutcome(session, verifier, steps, tools) { + const status = String(session.status || '').toUpperCase(); + const verdict = String(verifier.verdict || '').toUpperCase(); + const title = this.outcomeTitleFor(status, verdict); + const checked = Array.isArray(verifier.facts_checked) ? verifier.facts_checked : []; + const traceSummary = Array.isArray(verifier.tool_trace_summary) ? verifier.tool_trace_summary : []; + const tracedFacts = checked.filter(fact => Array.isArray(fact.evidence_refs) && fact.evidence_refs.length).length; + const score = this.formatScore(verifier.groundedness_score); + const answer = session.answer || '未持久化最终回答。'; + const subtitleParts = []; + + if (session.sessionId) { + subtitleParts.push(`会话 ${session.sessionId}`); + } + if (session.status) { + subtitleParts.push(`状态 ${this.displayStatus(session.status)}`); + } + if (steps.length || tools.length) { + subtitleParts.push(`${steps.length} 个步骤 / ${tools.length} 次工具调用`); + } + if (session.totalDurationMs !== undefined && session.totalDurationMs !== null) { + subtitleParts.push(`耗时 ${this.formatDuration(session.totalDurationMs)}`); + } + + this.outcomePanel.className = `outcome-panel ${this.outcomeClass(status, verdict)}`.trim(); + this.outcomeTitle.textContent = title; + this.outcomeSubtitle.textContent = subtitleParts.length + ? subtitleParts.join(' · ') + : '选择一个会话后,这里会先展示最终结论和证据状态。'; + this.outcomeVerdict.textContent = this.displayVerdict(verifier.verdict); + this.outcomeEvidenceState.textContent = checked.length + ? `证据分 ${score},检查 ${checked.length} 个事实,${tracedFacts} 个事实带引用,${traceSummary.length} 条证据摘要。` + : '暂无结构化校验事实。'; + this.outcomeAnswerSummary.textContent = this.summarizeAnswer(answer); + this.renderOutcomeList(this.outcomeConfirmed, this.confirmedFacts(checked), '暂无直接确认的关键事实。'); + this.renderOutcomeList(this.outcomeGaps, this.evidenceGaps(checked, verdict), '没有明显证据缺口。'); + } + + outcomeTitleFor(status, verdict) { + if (!status && !verdict) { + return '尚未加载会话'; + } + if (status === 'FAILED') { + return '会话执行失败'; + } + if (verdict.includes('REJECT')) { + return '结论被校验器拒绝'; + } + if (verdict.includes('LOW_CONFID')) { + return '结论低置信,需要复核'; + } + if (verdict.includes('PASS')) { + return '结论已通过校验'; + } + if (status === 'SUCCESS') { + return '会话已完成,暂无明确校验结论'; + } + return '会话仍在处理中'; + } + + outcomeClass(status, verdict) { + if (status === 'FAILED' || verdict.includes('REJECT')) { + return 'bad'; + } + if (verdict.includes('LOW_CONFID') || status === 'RUNNING' || status === 'PENDING') { + return 'warn'; + } + if (status === 'SUCCESS' || verdict.includes('PASS')) { + return 'ok'; + } + return ''; + } + + summarizeAnswer(answer) { + const text = String(answer || '').trim(); + if (!text) { + return '-'; + } + const compact = text + .replace(/\r/g, '') + .split('\n') + .map(line => line.trim()) + .filter(Boolean) + .join('\n'); + return compact.length > 620 ? `${compact.slice(0, 620)}...` : compact; + } + + confirmedFacts(facts) { + return facts + .filter(fact => { + const verification = String(fact.verification || '').toUpperCase(); + return fact.is_critical && (verification.includes('DIRECT') || verification.includes('SUPPORTED')); + }) + .map(fact => fact.fact || fact.detail) + .filter(Boolean) + .slice(0, 4); + } + + evidenceGaps(facts, verdict) { + const gaps = facts + .filter(fact => { + const verification = String(fact.verification || '').toUpperCase(); + return fact.is_critical && ( + verification.includes('NO_EVIDENCE') + || verification.includes('UNSUPPORTED') + || verification.includes('CONTRADICT') + || verification.includes('INDIRECT') + ); + }) + .map(fact => fact.detail ? `${fact.fact}:${fact.detail}` : fact.fact) + .filter(Boolean) + .slice(0, 4); + if (!gaps.length && String(verdict || '').toUpperCase().includes('LOW_CONFID')) { + return ['校验器给出低置信结论,建议人工复核关键事实和证据引用。']; + } + return gaps; + } + + renderOutcomeList(node, items, emptyText) { + node.innerHTML = (items.length ? items : [emptyText]).map(item => ` +
    • ${this.escape(item)}
    • + `).join(''); + } + renderSteps(steps) { if (!steps.length) { - this.stepsRail.innerHTML = '
      No persisted agent steps.
      '; + this.stepsRail.innerHTML = '
      没有持久化智能体步骤。
      '; return; } @@ -153,18 +521,19 @@ class TraceWorkbench { return `
      -

      ${this.escape(step.agentName || 'agent')}

      - ${this.renderBadge(step.hasToolCall ? 'tool' : 'model', step.hasToolCall ? 'warn' : 'ok')} +

      ${this.escape(step.agentName || '智能体')}

      + ${this.renderBadge(step.hasToolCall ? '工具' : '模型', step.hasToolCall ? 'warn' : 'ok')}
      - step ${this.escape(step.stepIndex ?? index)} + 执行序号 ${this.escape(String(index + 1).padStart(2, '0'))} + 内部步 ${this.escape(step.stepIndex ?? index)} ${this.escape(this.formatDuration(step.durationMs))} - ${this.escape(this.formatNumber(step.tokenCount))} tokens + ${this.escape(this.formatNumber(step.tokenCount))} 令牌 ${this.escape(this.formatDate(step.createdAt))}
      - ${this.renderTextDetail('Model input', step.modelInput)} - ${this.renderTextDetail('Model output', step.modelOutput)} - ${this.renderTextDetail('Thought', step.thought)} + ${this.renderTextDetail('模型输入', step.modelInput)} + ${this.renderTextDetail('模型输出', step.modelOutput)} + ${this.renderTextDetail('思考过程', step.thought)}
      `; }).join(''); @@ -178,9 +547,9 @@ class TraceWorkbench { const groups = this.groupByTool(tools); const filters = [ - { key: 'all', label: `all ${tools.length}` }, - { key: 'success', label: `success ${tools.filter(tool => tool.success !== false).length}` }, - { key: 'failed', label: `failed ${tools.filter(tool => tool.success === false).length}` }, + { key: 'all', label: `全部 ${tools.length}` }, + { key: 'success', label: `成功 ${tools.filter(tool => tool.success !== false).length}` }, + { key: 'failed', label: `失败 ${tools.filter(tool => tool.success === false).length}` }, ...Object.entries(groups).map(([name, count]) => ({ key: `tool:${name}`, label: `${name} ${count}` })) ]; @@ -202,7 +571,7 @@ class TraceWorkbench { renderTools(tools) { const filtered = tools.filter(tool => this.matchesFilter(tool)); if (!filtered.length) { - this.toolLedger.innerHTML = '
      No tool invocations match this filter.
      '; + this.toolLedger.innerHTML = '
      当前筛选下没有工具调用。
      '; return; } @@ -212,13 +581,13 @@ class TraceWorkbench { return `