refactor(chat): render rectification activity through the shared step timeline
The rectification surface had its own activity pipeline: a trace panel with a 20px canvas orb clipped inside a 14px marker, a list that never collapsed, a second receipt disclosure under every reply, an inline failure banner and a staged label while regenerating. Its trace and receipt are now projected onto ConsultationTimelineRow so both surfaces render one ConsultationRunTimeline with one live marker and one settled summary; receipt methods become source chips on the last completed row. The unreachable sections-report/step-tree path, the trace panel, the receipt component and the thinking-orbs dependency are removed. The server-side drop of rectification thinking deltas is untouched. BUG-476 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
This commit is contained in:
@@ -7314,3 +7314,19 @@
|
||||
- 相关记录:BUG-474
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-476 | 生时校正会话的 Agent 活动 UI 与普通会话是两套,live 标记被 14px 格子裁切
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-01
|
||||
- 最近更新:2026-09-01
|
||||
- 影响面:`rectification-agentic-chat.tsx`、`agent-activity-status.tsx`、`chat-message-row.tsx`、`globals.css`、`package.json`(`thinking-orbs`)
|
||||
- 用户现象:校正会话里步骤标记是一个 20px 的 canvas 小球,被压在 14px 的格子里边缘裁切;结算后步骤列表永远全展开,下面还多挂一个「本轮完成 · N 个步骤」折叠块;失败时消息上方多一行红字;重新生成时先出现一个假的「正在组织回答…」;普通会话则是行内 spinner、结算收成「已完成 N 步」一行。两边看起来不像一个产品。
|
||||
- 触发条件:任何一次校正回合与任何一次咨询回合并排对比。
|
||||
- 根因:校正走 `activityTrace` + `AgentActivityStatus` 的 trace 分支,普通会话走 `ConsultationRunTimeline`;`chat-message-row.tsx` 里并存三条思考渲染路径(timeline、`ConsultationThinkingReport`+`ThinkingStepTree`、trace panel),其中 sections report 已无任何调用方可达;`.conversation.is-rectification .agent-thinking-marker { 14px }` 覆写与 `ThinkingOrb size={20}` 冲突。
|
||||
- 修复:新增 `lib/rectification-timeline-adapter.ts`,把 trace/receipt/activity 映射成 `ConsultationTimelineRow`(tool → calculate 行;失败 tool 标签加「未完成」;回执方法作为最后一条完成行的 sources chips,去重 ≤8;无 live 行时把「正在…」类活动文案作为 live 行,answer-composition 归 write)。校正消息由此走同一个 `ConsultationRunTimeline`。删除 `consultation-thinking-report.tsx`、`thinking-step-tree.tsx`、`completed-activity-receipt.tsx` 及其 CSS;`AgentActivityStatus` 只保留无 timeline 的兜底,live 标记统一 `InlineSpinner` 12px,移除 `thinking-orbs` 依赖;删除 14px 覆写;失败态改走既有 error 通知;重新生成显示 queued 行由真实事件填充。校正 `thinking.delta` 仍在服务端公开边界丢弃,本轮未动。
|
||||
- 验证:`tests/rectification-timeline-adapter.test.ts`;`chat-stream-layout`、`chat-bundle-splitting-contract`、`rectification-agentic-entry`、`rectification-activity-receipt` 中锁死路径的断言按「注明原值与错因」改为锁新路径。
|
||||
- 防复发:任何会话面的 Agent 活动都必须投影成 `ConsultationTimelineRow` 交给 `ConsultationRunTimeline`;live 标记只允许 `InlineSpinner`;不得再引入第二种等待词汇。
|
||||
- 相关记录:BUG-474、BUG-475
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
Generated
-107
@@ -39,7 +39,6 @@
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.2",
|
||||
"thinking-orbs": "^0.1.1",
|
||||
"tsx": "^4.23.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zod": "^3.25.76"
|
||||
@@ -1745,9 +1744,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1764,9 +1760,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1783,9 +1776,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1802,9 +1792,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1821,9 +1808,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1840,9 +1824,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1859,9 +1840,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1878,9 +1856,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1897,9 +1872,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1922,9 +1894,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1947,9 +1916,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1972,9 +1938,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1997,9 +1960,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2022,9 +1982,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2047,9 +2004,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2072,9 +2026,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2508,9 +2459,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2527,9 +2475,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2546,9 +2491,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2565,9 +2507,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3886,9 +3825,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3903,9 +3839,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3920,9 +3853,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3937,9 +3867,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4205,9 +4132,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4224,9 +4148,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4243,9 +4164,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4262,9 +4180,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -9371,9 +9286,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -9394,9 +9306,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -9417,9 +9326,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -9440,9 +9346,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -13217,16 +13120,6 @@
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/thinking-orbs": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/thinking-orbs/-/thinking-orbs-0.1.1.tgz",
|
||||
"integrity": "sha512-nLvLTGJtk74K13MmP7XiRdzFiQx7UsoZAIyBZNgXyl7Q3h2mdz0r3eKn9LCsuzb3DGOVjwDvMhtnAkIqJJRVQA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">=18.0.0",
|
||||
"react-dom": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/throttle-debounce": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz",
|
||||
|
||||
@@ -48,7 +48,6 @@
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.2",
|
||||
"thinking-orbs": "^0.1.1",
|
||||
"tsx": "^4.23.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zod": "^3.25.76"
|
||||
|
||||
@@ -426,13 +426,6 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
gap: var(--space-2);
|
||||
min-height: 24px;
|
||||
}
|
||||
.agent-thinking-step.is-think:has(.message-thinking) {
|
||||
display: block;
|
||||
min-height: 0;
|
||||
}
|
||||
.agent-thinking-step.is-think .message-thinking {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.agent-thinking-marker {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
@@ -449,8 +442,6 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
}
|
||||
.agent-activity-status__row { min-height: 24px; display: flex; align-items: center; gap: var(--space-2); min-width: 0; width: 100%; }
|
||||
.agent-activity-status__live { min-height: 24px; display: flex; align-items: center; gap: var(--space-2); min-width: 0; }
|
||||
.agent-activity-status canvas,
|
||||
.agent-thinking-marker canvas { flex: 0 0 auto; }
|
||||
.agent-activity-status__elapsed,
|
||||
.agent-thinking-elapsed {
|
||||
flex: 0 0 auto;
|
||||
@@ -1054,67 +1045,10 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.consultation-step-tree__group {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.consultation-step-tree__stage {
|
||||
display: grid;
|
||||
grid-template-columns: 20px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-height: 24px;
|
||||
margin: 0;
|
||||
color: var(--color-ink-strong);
|
||||
font-family: inherit;
|
||||
font-size: var(--type-caption);
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.consultation-step-tree__stage-index,
|
||||
.consultation-step-tree__stage-mark {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--radius-xs);
|
||||
background: var(--color-canvas-muted);
|
||||
box-shadow: inset 0 0 0 1px var(--color-border);
|
||||
color: var(--color-ink-secondary);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.consultation-step-tree__stage-mark {
|
||||
background: transparent;
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-border) 80%, transparent);
|
||||
}
|
||||
.consultation-step-tree__stage-title {
|
||||
min-width: 0;
|
||||
}
|
||||
.agent-thinking-marker.is-pending {
|
||||
background: var(--color-canvas-muted);
|
||||
box-shadow: inset 0 0 0 1px var(--color-border);
|
||||
}
|
||||
.agent-thinking-step.is-more {
|
||||
color: var(--color-ink-tertiary);
|
||||
}
|
||||
.consultation-thinking-report {
|
||||
display: grid;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
.consultation-step-tree__group + .consultation-step-tree__group {
|
||||
margin-top: var(--space-6);
|
||||
}
|
||||
.consultation-step-tree__reasoning {
|
||||
margin: 0 0 var(--space-4);
|
||||
color: var(--color-ink-secondary);
|
||||
font-size: var(--type-caption);
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.consultation-report-analysis {
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -1124,11 +1058,6 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
.consultation-report-analysis .message-markdown {
|
||||
color: var(--color-ink-strong);
|
||||
}
|
||||
.consultation-thinking-report .message-thinking {
|
||||
margin-bottom: 0;
|
||||
padding-bottom: var(--space-3);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 72%, transparent);
|
||||
}
|
||||
.consultation-run-timeline {
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -2682,14 +2611,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
max-width: 720px;
|
||||
padding-bottom: var(--rectification-jump-clearance);
|
||||
}
|
||||
.conversation.is-rectification .message-assistant .agent-thinking-step {
|
||||
grid-template-columns: 14px minmax(0, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
.conversation.is-rectification .message-assistant .agent-thinking-marker {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
.conversation.is-rectification .message-actions {
|
||||
margin-inline-start: var(--assistant-content-inset);
|
||||
}
|
||||
@@ -3545,78 +3466,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
}
|
||||
|
||||
.rectification-message-wrap { display: grid; gap: var(--space-1); }
|
||||
.rectification-activity-receipt {
|
||||
width: min(720px, 100%);
|
||||
margin: 0 0 var(--space-2) 36px;
|
||||
color: var(--color-ink-tertiary);
|
||||
font-size: var(--type-caption);
|
||||
}
|
||||
.rectification-activity-receipt summary {
|
||||
display: flex;
|
||||
min-height: 32px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: 0;
|
||||
color: var(--color-ink-tertiary);
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
list-style: none;
|
||||
}
|
||||
.rectification-activity-receipt summary::-webkit-details-marker { display: none; }
|
||||
.rectification-activity-receipt summary:focus-visible {
|
||||
border-radius: var(--radius-sm);
|
||||
outline: 2px solid var(--color-focus);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.rectification-activity-receipt__summary-main,
|
||||
.rectification-activity-receipt__toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.rectification-activity-receipt__summary-main svg { color: var(--color-success); }
|
||||
.rectification-activity-receipt__toggle { flex: 0 0 auto; color: var(--color-ink-tertiary); }
|
||||
.rectification-activity-receipt__toggle svg { transition: transform 160ms ease; }
|
||||
.rectification-activity-receipt[open] .rectification-activity-receipt__toggle svg { transform: rotate(180deg); }
|
||||
.rectification-activity-receipt__failure-mark {
|
||||
display: inline-grid;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
place-items: center;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 50%;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
.rectification-activity-receipt.is-failed,
|
||||
.rectification-activity-failure { color: var(--color-danger); }
|
||||
.rectification-activity-failure { margin: 0 0 var(--space-2) 36px; font-size: var(--type-caption); }
|
||||
.rectification-activity-receipt__details {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-1);
|
||||
padding: var(--space-3) 0 var(--space-2) 19px;
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
.rectification-activity-receipt__details section { display: grid; gap: var(--space-1); }
|
||||
.rectification-activity-receipt__details h3 {
|
||||
margin: 0;
|
||||
color: var(--color-ink-tertiary);
|
||||
font-size: var(--type-overline);
|
||||
font-weight: 650;
|
||||
}
|
||||
.rectification-activity-receipt__details p { margin: 0; color: var(--color-ink-secondary); line-height: 1.65; }
|
||||
.rectification-activity-receipt__method-groups { display: grid; gap: 2px; }
|
||||
.rectification-activity-receipt__method-groups strong { color: var(--color-ink-secondary); font-weight: 600; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.rectification-activity-receipt,
|
||||
.rectification-activity-failure { margin-left: 32px; }
|
||||
.rectification-activity-receipt summary { align-items: flex-start; }
|
||||
.rectification-activity-receipt__toggle { padding-left: var(--space-1); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.agent-activity-status__text {
|
||||
@@ -3624,6 +3473,5 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
background: none;
|
||||
-webkit-text-fill-color: currentColor;
|
||||
}
|
||||
.rectification-activity-receipt__toggle svg { transition: none; }
|
||||
.technique-audit > summary::before { transition: none; }
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Check } from "lucide-react";
|
||||
import type { OrbState } from "thinking-orbs";
|
||||
|
||||
import { prefetchOnIdle } from "@/components/chat-chunk-prefetch";
|
||||
import { InlineSpinner } from "@/components/inline-spinner";
|
||||
import { activityCompletedSteps, activityElapsedLabel } from "@/lib/chat-message-view";
|
||||
import type { AgentActivityTraceItem } from "@/lib/agent-activity-trace";
|
||||
|
||||
export type AgentActivityState = "working" | "searching" | "solving" | "listening" | "composing" | "shaping";
|
||||
|
||||
const labels = {
|
||||
working: "正在处理任务…",
|
||||
@@ -16,20 +15,7 @@ const labels = {
|
||||
listening: "正在聆听…",
|
||||
composing: "正在组织回答…",
|
||||
shaping: "正在生成结果…",
|
||||
} as const satisfies Record<OrbState, string>;
|
||||
|
||||
const importThinkingOrb = () => import("thinking-orbs");
|
||||
|
||||
const ThinkingOrb = dynamic(async () => (await importThinkingOrb()).ThinkingOrb, {
|
||||
loading: () => (
|
||||
<span aria-hidden="true" style={{ display: "block", flex: "0 0 auto", height: 20, width: 20 }} />
|
||||
),
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
prefetchOnIdle(importThinkingOrb);
|
||||
|
||||
export type AgentActivityState = OrbState;
|
||||
} as const satisfies Record<AgentActivityState, string>;
|
||||
|
||||
function ActivityElapsed({ startedAt }: Readonly<{ startedAt: number }>) {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
@@ -77,13 +63,18 @@ function VargaTraceStep({ sentence }: Readonly<{ sentence: string }>) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback activity panel for an assistant row that carries no step timeline.
|
||||
* Both chat surfaces normally render `ConsultationRunTimeline`; this remains
|
||||
* for rows that only have a phase label, a completed trail or thinking text.
|
||||
* The live marker is the shared `InlineSpinner`, the same one the timeline uses.
|
||||
*/
|
||||
export function AgentActivityStatus({
|
||||
state,
|
||||
label = labels[state],
|
||||
startedAt,
|
||||
completedTrail,
|
||||
thinkingText,
|
||||
activityTrace,
|
||||
vargaSentence,
|
||||
hasAnswer = false,
|
||||
showLive = true,
|
||||
@@ -93,33 +84,14 @@ export function AgentActivityStatus({
|
||||
startedAt?: number;
|
||||
completedTrail?: string;
|
||||
thinkingText?: string;
|
||||
activityTrace?: readonly AgentActivityTraceItem[];
|
||||
vargaSentence?: string | null;
|
||||
hasAnswer?: boolean;
|
||||
showLive?: boolean;
|
||||
}>) {
|
||||
const completedSteps = activityCompletedSteps(completedTrail);
|
||||
const live = showLive && !hasAnswer;
|
||||
const trace = activityTrace ?? [];
|
||||
const varga = vargaSentence?.trim() ?? "";
|
||||
const showVarga = Boolean(varga) && !trace.some((item) => item.label === varga);
|
||||
if (trace.length > 0) {
|
||||
return (
|
||||
<div className="agent-thinking-panel agent-activity-status">
|
||||
<ol className="agent-thinking-timeline">
|
||||
{trace.map((item) => (
|
||||
<TraceStep
|
||||
key={item.id}
|
||||
item={item}
|
||||
state={state}
|
||||
hasAnswer={hasAnswer}
|
||||
/>
|
||||
))}
|
||||
{showVarga ? <VargaTraceStep sentence={varga} /> : null}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const showVarga = Boolean(varga);
|
||||
if (!live && completedSteps.length === 0 && !thinkingText?.trim() && !showVarga) return null;
|
||||
|
||||
return (
|
||||
@@ -136,8 +108,8 @@ export function AgentActivityStatus({
|
||||
))}
|
||||
{live ? (
|
||||
<li className="agent-thinking-step is-live">
|
||||
<span className="agent-thinking-marker is-live-marker">
|
||||
<ThinkingOrb aria-hidden="true" state={state} size={20} />
|
||||
<span className="agent-thinking-marker is-live-marker" aria-hidden="true">
|
||||
<InlineSpinner size={12} />
|
||||
</span>
|
||||
<span className="agent-activity-status__live" role="status">
|
||||
<span key={label} className="agent-activity-status__text">{label}</span>
|
||||
@@ -152,45 +124,3 @@ export function AgentActivityStatus({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TraceStep({
|
||||
item,
|
||||
state,
|
||||
hasAnswer,
|
||||
}: Readonly<{
|
||||
item: AgentActivityTraceItem;
|
||||
state: AgentActivityState;
|
||||
hasAnswer: boolean;
|
||||
}>) {
|
||||
if (item.kind === "think") {
|
||||
return (
|
||||
<li className={`agent-thinking-step is-think is-${item.status}`}>
|
||||
{item.text?.trim() ? (
|
||||
<MessageThinkingTrace text={item.text} hasAnswer={hasAnswer || item.status === "done"} />
|
||||
) : (
|
||||
<>
|
||||
<span className={`agent-thinking-marker${item.status === "live" ? " is-live-marker" : ""}`} aria-hidden="true">
|
||||
{item.status === "live" ? <ThinkingOrb aria-hidden="true" state={state} size={20} /> : <Check />}
|
||||
</span>
|
||||
<span>{item.label}</span>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<li className={`agent-thinking-step is-${item.status}`}>
|
||||
<span className={`agent-thinking-marker${item.status === "live" ? " is-live-marker" : ""}`} aria-hidden="true">
|
||||
{item.status === "live" ? <ThinkingOrb aria-hidden="true" state={state} size={20} /> : <Check />}
|
||||
</span>
|
||||
{item.status === "live" ? (
|
||||
<span className="agent-activity-status__live" role="status">
|
||||
<span key={item.label} className="agent-activity-status__text">{item.label}</span>
|
||||
{item.startedAt ? <ActivityElapsed key={item.startedAt} startedAt={item.startedAt} /> : null}
|
||||
</span>
|
||||
) : (
|
||||
<span>{item.label}</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { AgentActivityStatus } from "@/components/agent-activity-status";
|
||||
import { prefetchOnIdle } from "@/components/chat-chunk-prefetch";
|
||||
import { ChatMessageContent } from "@/components/chat-message-content";
|
||||
import { ConsultationRunTimeline } from "@/components/consultation-run-timeline";
|
||||
import { ConsultationThinkingReport } from "@/components/consultation-thinking-report";
|
||||
import type { ChatMessageView } from "@/lib/chat-message-view";
|
||||
import { useEffect, useLayoutEffect, useRef } from "react";
|
||||
|
||||
@@ -63,12 +62,11 @@ export function ChatMessageRow({
|
||||
?? (message.state === "thinking" ? "正在处理…" : undefined);
|
||||
const hasAnswer = Boolean(message.text.trim());
|
||||
const consultTimeline = message.timeline;
|
||||
const thinkingSections = message.thinkingSections ?? [];
|
||||
const hasTrace = (message.activityTrace?.length ?? 0) > 0;
|
||||
const showReport = consultTimeline === undefined && thinkingSections.length > 0;
|
||||
const showLiveActivity = showActivity && !showReport && consultTimeline === undefined;
|
||||
const showThinkingPanel = showLiveActivity || (!showReport && consultTimeline === undefined && (Boolean(message.thinkingText?.trim()) || hasTrace));
|
||||
const showSpokenAnswer = !showReport && Boolean(message.text);
|
||||
// Rows with a step timeline (every consultation and rectification reply) render
|
||||
// `ConsultationRunTimeline`; the activity panel is the fallback for rows without one.
|
||||
const showLiveActivity = showActivity && consultTimeline === undefined;
|
||||
const showThinkingPanel = showLiveActivity || (consultTimeline === undefined && Boolean(message.thinkingText?.trim()));
|
||||
const showSpokenAnswer = Boolean(message.text);
|
||||
const stackedThinkingAndAnswer = showThinkingPanel && showSpokenAnswer;
|
||||
|
||||
useEntryEffect(() => {
|
||||
@@ -102,8 +100,7 @@ export function ChatMessageRow({
|
||||
label={activityLabel}
|
||||
startedAt={message.activity?.startedAt}
|
||||
completedTrail={message.activity?.completedTrail}
|
||||
thinkingText={hasTrace ? undefined : message.thinkingText}
|
||||
activityTrace={message.activityTrace}
|
||||
thinkingText={message.thinkingText}
|
||||
vargaSentence={vargaSentence}
|
||||
hasAnswer={hasAnswer}
|
||||
showLive={showLiveActivity}
|
||||
@@ -145,19 +142,6 @@ export function ChatMessageRow({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{showReport && (
|
||||
<ConsultationThinkingReport
|
||||
sections={thinkingSections}
|
||||
thinkingText={message.thinkingText}
|
||||
answer={message.text}
|
||||
live={showActivity && !hasAnswer}
|
||||
liveLabel={activityLabel}
|
||||
liveState={activityState}
|
||||
startedAt={message.activity?.startedAt}
|
||||
auditRows={message.agentExecutionReceipt?.techniqueAuditTable}
|
||||
vargaSentence={vargaSentence}
|
||||
/>
|
||||
)}
|
||||
{stackedThinkingAndAnswer ? (
|
||||
<div className="message-stage-and-answer">
|
||||
{thinkingPanel}
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
import type { CompletedActivityReceiptView } from "@/lib/rectification-activity-receipt";
|
||||
import { RECTIFICATION_TOOL_DONE_LABELS } from "@/lib/rectification-activity-labels";
|
||||
import type {
|
||||
PublicRectificationMethod,
|
||||
PublicRectificationTool,
|
||||
} from "@/lib/rectification-agentic/v9/public-receipt";
|
||||
import { PUBLIC_RECTIFICATION_METHOD_LABELS } from "@/lib/rectification-varga-sentence";
|
||||
|
||||
const TOOL_LABELS = RECTIFICATION_TOOL_DONE_LABELS;
|
||||
|
||||
const METHOD_LABELS = PUBLIC_RECTIFICATION_METHOD_LABELS;
|
||||
|
||||
const METHOD_GROUPS: readonly Readonly<{
|
||||
label: string;
|
||||
methods: readonly PublicRectificationMethod[];
|
||||
}>[] = [
|
||||
{
|
||||
label: "基础判断",
|
||||
methods: [
|
||||
"d1-rashi",
|
||||
"functional-benefic-malefic",
|
||||
"vimshottari-dasha",
|
||||
"narayana-dasha",
|
||||
"arudha-pada",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "主题分盘",
|
||||
methods: [
|
||||
"d2-hora",
|
||||
"d4-chaturthamsha",
|
||||
"d9-navamsa",
|
||||
"d10-dashamsa",
|
||||
"d11-labhamsha",
|
||||
"d24-chaturvimshamsha",
|
||||
"d30-trimshamsha",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "交叉验证",
|
||||
methods: ["gochara", "ashtakavarga", "shadbala"],
|
||||
},
|
||||
];
|
||||
|
||||
function failureLabel(tool: PublicRectificationTool): string {
|
||||
return tool === "rectification-compare-candidates"
|
||||
? "候选比较未完成,当前进度已保留"
|
||||
: `${TOOL_LABELS[tool]}未完成,当前进度已保留`;
|
||||
}
|
||||
|
||||
export function CompletedActivityReceipt({
|
||||
receipt,
|
||||
}: Readonly<{ receipt: CompletedActivityReceiptView }>) {
|
||||
const stepLabels = receipt.steps.map((tool) => TOOL_LABELS[tool]);
|
||||
const methodSet = new Set(receipt.methods);
|
||||
const methodGroups = METHOD_GROUPS.map((group) => ({
|
||||
label: group.label,
|
||||
methods: group.methods.filter((method) => methodSet.has(method)).map((method) => METHOD_LABELS[method]),
|
||||
})).filter((group) => group.methods.length > 0);
|
||||
const hasDetails = stepLabels.length > 0 || methodGroups.length > 0;
|
||||
const summary = receipt.failedTool
|
||||
? failureLabel(receipt.failedTool)
|
||||
: `本轮完成 · ${stepLabels.length} 个步骤 · ${receipt.methods.length} 项计算依据`;
|
||||
|
||||
if (!hasDetails) {
|
||||
return receipt.failedTool
|
||||
? <p className="rectification-activity-failure">{summary}</p>
|
||||
: null;
|
||||
}
|
||||
|
||||
return (
|
||||
<details className={`rectification-activity-receipt${receipt.failedTool ? " is-failed" : ""}`}>
|
||||
<summary>
|
||||
<span className="rectification-activity-receipt__summary-main">
|
||||
{receipt.failedTool ? (
|
||||
<span className="rectification-activity-receipt__failure-mark" aria-hidden="true">!</span>
|
||||
) : (
|
||||
<Check aria-hidden="true" size={14} strokeWidth={2.25} />
|
||||
)}
|
||||
<span>{summary}</span>
|
||||
</span>
|
||||
<span className="rectification-activity-receipt__toggle">
|
||||
查看详情
|
||||
<ChevronDown aria-hidden="true" size={14} />
|
||||
</span>
|
||||
</summary>
|
||||
<div className="rectification-activity-receipt__details">
|
||||
{stepLabels.length > 0 && (
|
||||
<section aria-label="执行步骤">
|
||||
<h3>执行步骤</h3>
|
||||
<p>{stepLabels.join(" · ")}</p>
|
||||
</section>
|
||||
)}
|
||||
{methodGroups.length > 0 && (
|
||||
<section aria-label="计算依据">
|
||||
<h3>计算依据</h3>
|
||||
<div className="rectification-activity-receipt__method-groups">
|
||||
{methodGroups.map((group) => (
|
||||
<p key={group.label}>
|
||||
<strong>{group.label}:</strong>
|
||||
{group.methods.join("、")}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ChatMessageContent } from "@/components/chat-message-content";
|
||||
import { ThinkingStepTree } from "@/components/thinking-step-tree";
|
||||
import type { TechniqueAuditRow } from "@/lib/consultation-agent-events";
|
||||
import {
|
||||
applyThinkingSectionProgress,
|
||||
type PublicThinkingSection,
|
||||
} from "@/lib/consultation-thinking-plan";
|
||||
|
||||
export function ConsultationThinkingReport({
|
||||
sections,
|
||||
answer,
|
||||
thinkingText,
|
||||
live = false,
|
||||
liveLabel,
|
||||
liveState,
|
||||
startedAt,
|
||||
auditRows,
|
||||
vargaSentence,
|
||||
}: Readonly<{
|
||||
sections: readonly PublicThinkingSection[];
|
||||
answer: string;
|
||||
thinkingText?: string;
|
||||
live?: boolean;
|
||||
liveLabel?: string;
|
||||
liveState?: "working" | "searching" | "solving" | "listening" | "composing" | "shaping";
|
||||
startedAt?: number;
|
||||
auditRows?: readonly TechniqueAuditRow[];
|
||||
vargaSentence?: string | null;
|
||||
}>) {
|
||||
const hasAnswer = Boolean(answer.trim());
|
||||
const progressed = applyThinkingSectionProgress(sections, answer, {
|
||||
settled: !live && hasAnswer,
|
||||
});
|
||||
const activeIndex = progressed.findIndex((section) => (
|
||||
section.steps.some((step) => step.status === "active")
|
||||
));
|
||||
|
||||
return (
|
||||
<div className="consultation-thinking-report">
|
||||
<ThinkingStepTree
|
||||
caption="思考"
|
||||
reasoning={thinkingText}
|
||||
groups={progressed.map((section, index) => ({
|
||||
id: section.id,
|
||||
intent: section.title,
|
||||
steps: section.steps,
|
||||
live: live && index === activeIndex,
|
||||
}))}
|
||||
revealAll
|
||||
live={live && !hasAnswer}
|
||||
liveLabel={live && !hasAnswer ? liveLabel : undefined}
|
||||
liveState={liveState}
|
||||
startedAt={live && !hasAnswer ? startedAt : undefined}
|
||||
defaultOpen={live && !hasAnswer}
|
||||
/>
|
||||
{hasAnswer ? (
|
||||
<section className="consultation-report-analysis" aria-label="分析">
|
||||
<ChatMessageContent
|
||||
text={answer}
|
||||
auditRows={auditRows}
|
||||
vargaSentence={vargaSentence}
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
RECTIFICATION_BOARD_SPLIT_MIN_PX,
|
||||
} from "@/lib/rectification-board-model";
|
||||
import { membershipHref } from "@/lib/membership";
|
||||
import { rectificationTimelineRows } from "@/lib/rectification-timeline-adapter";
|
||||
import { vargaSentenceFromMethods } from "@/lib/rectification-varga-sentence";
|
||||
import {
|
||||
isPublicRectificationActivity,
|
||||
@@ -1158,18 +1159,29 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
&& !busy
|
||||
&& !readonly
|
||||
&& regeneratingMessageKey === null;
|
||||
const displayedMessage = regenerating
|
||||
// Both surfaces render the same step timeline: the tool trace and receipt
|
||||
// are projected onto ConsultationRunTimeline rows. A regenerating reply shows
|
||||
// the queued row until real events fill it in, never a staged label.
|
||||
const displayedMessage: RenderMessage = regenerating
|
||||
? {
|
||||
...message,
|
||||
text: "",
|
||||
thinkingText: undefined,
|
||||
state: "thinking" as const,
|
||||
activity: nextActivityView(undefined, {
|
||||
phase: "answer-composition",
|
||||
label: "正在组织回答…",
|
||||
}),
|
||||
activity: undefined,
|
||||
activityTrace: emptyActivityTrace(),
|
||||
completedReceipt: undefined,
|
||||
timeline: [],
|
||||
}
|
||||
: message;
|
||||
: {
|
||||
...message,
|
||||
timeline: rectificationTimelineRows({
|
||||
trace: message.activityTrace,
|
||||
receipt: message.completedReceipt,
|
||||
activity: message.activity,
|
||||
settled: message.state === "settled",
|
||||
}),
|
||||
};
|
||||
const settledChoice = message.choiceAttachment;
|
||||
const visibleChoiceCard = settledChoice?.card;
|
||||
const vargaSentence = !message.failed
|
||||
@@ -1177,11 +1189,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
: null;
|
||||
return (
|
||||
<div key={message.renderKey} className="rectification-message-wrap rectification-message-entry">
|
||||
{message.state === "settled" && message.failed && Boolean(message.text) && (
|
||||
<p className="rectification-activity-failure" role="status">
|
||||
回答未完成,已保留现有内容;本次不会扣点。
|
||||
</p>
|
||||
)}
|
||||
{(!message.failed || Boolean(displayedMessage.text) || regenerating) && (
|
||||
<ChatMessageRow
|
||||
message={displayedMessage}
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useId, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Check } from "lucide-react";
|
||||
import type { OrbState } from "thinking-orbs";
|
||||
|
||||
import { prefetchOnIdle } from "@/components/chat-chunk-prefetch";
|
||||
import { activityElapsedLabel } from "@/lib/chat-message-view";
|
||||
import {
|
||||
visibleThinkingSteps,
|
||||
type PublicThinkingStep,
|
||||
type ThinkingStepStatus,
|
||||
} from "@/lib/consultation-thinking-plan";
|
||||
|
||||
const importThinkingOrb = () => import("thinking-orbs");
|
||||
|
||||
const ThinkingOrb = dynamic(async () => (await importThinkingOrb()).ThinkingOrb, {
|
||||
loading: () => (
|
||||
<span aria-hidden="true" style={{ display: "block", flex: "0 0 auto", height: 20, width: 20 }} />
|
||||
),
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
prefetchOnIdle(importThinkingOrb);
|
||||
|
||||
function ActivityElapsed({ startedAt }: Readonly<{ startedAt: number }>) {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
const label = activityElapsedLabel(startedAt, now);
|
||||
if (!label) return null;
|
||||
return <span className="agent-activity-status__elapsed" aria-hidden="true">{label}</span>;
|
||||
}
|
||||
|
||||
function StepMarker({
|
||||
status,
|
||||
liveState,
|
||||
}: Readonly<{
|
||||
status: ThinkingStepStatus | "live";
|
||||
liveState?: OrbState;
|
||||
}>) {
|
||||
if (status === "live" || status === "active") {
|
||||
return (
|
||||
<span className="agent-thinking-marker is-live-marker">
|
||||
<ThinkingOrb aria-hidden="true" state={liveState ?? "working"} size={20} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === "done") {
|
||||
return (
|
||||
<span className="agent-thinking-marker" aria-hidden="true">
|
||||
<Check />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span className="agent-thinking-marker is-pending" aria-hidden="true" />;
|
||||
}
|
||||
|
||||
type ThinkingStepGroup = Readonly<{
|
||||
id: string;
|
||||
intent?: string;
|
||||
steps: readonly PublicThinkingStep[];
|
||||
live?: boolean;
|
||||
}>;
|
||||
|
||||
function StepList({
|
||||
steps,
|
||||
revealAll,
|
||||
live,
|
||||
liveLabel,
|
||||
liveState,
|
||||
startedAt,
|
||||
}: Readonly<{
|
||||
steps: readonly PublicThinkingStep[];
|
||||
revealAll: boolean;
|
||||
live: boolean;
|
||||
liveLabel?: string;
|
||||
liveState?: OrbState;
|
||||
startedAt?: number;
|
||||
}>) {
|
||||
const visible = revealAll ? { visible: steps, hiddenCount: 0 } : visibleThinkingSteps(steps);
|
||||
const hiddenDone = visible.hiddenCount > 0
|
||||
&& steps.slice(visible.visible.length).every((step) => step.status === "done");
|
||||
return (
|
||||
<ol className="agent-thinking-timeline">
|
||||
{visible.visible.map((step) => (
|
||||
<li
|
||||
className={`agent-thinking-step is-${step.status}`}
|
||||
key={step.id}
|
||||
>
|
||||
<StepMarker status={step.status} liveState={liveState} />
|
||||
<span>{step.label}</span>
|
||||
</li>
|
||||
))}
|
||||
{visible.hiddenCount > 0 ? (
|
||||
<li className={`agent-thinking-step is-more${hiddenDone ? " is-done" : ""}`}>
|
||||
<StepMarker status={hiddenDone ? "done" : "pending"} />
|
||||
<span>还有 {visible.hiddenCount} 项</span>
|
||||
</li>
|
||||
) : null}
|
||||
{live && liveLabel ? (
|
||||
<li className="agent-thinking-step is-live">
|
||||
<StepMarker status="live" liveState={liveState} />
|
||||
<span className="agent-activity-status__live" role="status">
|
||||
<span key={liveLabel} className="agent-activity-status__text">{liveLabel}</span>
|
||||
{startedAt ? <ActivityElapsed key={startedAt} startedAt={startedAt} /> : null}
|
||||
</span>
|
||||
</li>
|
||||
) : null}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
export function ThinkingStepTree({
|
||||
caption,
|
||||
intent,
|
||||
reasoning,
|
||||
steps = [],
|
||||
groups,
|
||||
hiddenCount,
|
||||
revealAll = false,
|
||||
live = false,
|
||||
liveLabel,
|
||||
liveState = "working",
|
||||
startedAt,
|
||||
defaultOpen = false,
|
||||
}: Readonly<{
|
||||
caption?: string;
|
||||
intent?: string;
|
||||
reasoning?: string;
|
||||
steps?: readonly PublicThinkingStep[];
|
||||
groups?: readonly ThinkingStepGroup[];
|
||||
hiddenCount?: number;
|
||||
revealAll?: boolean;
|
||||
live?: boolean;
|
||||
liveLabel?: string;
|
||||
liveState?: OrbState;
|
||||
startedAt?: number;
|
||||
defaultOpen?: boolean;
|
||||
}>) {
|
||||
const [userOpen, setUserOpen] = useState<boolean | null>(null);
|
||||
const treeId = useId();
|
||||
const groupItems = groups ?? [{
|
||||
id: "default",
|
||||
intent,
|
||||
steps,
|
||||
live,
|
||||
}];
|
||||
const showAll = revealAll || hiddenCount === 0;
|
||||
const stagedCount = groupItems.filter((group) => Boolean(group.intent)).length;
|
||||
const body = (
|
||||
<>
|
||||
{groupItems.map((group, index) => {
|
||||
const stageId = `${treeId}-${group.id}-stage`;
|
||||
return (
|
||||
<section
|
||||
className="consultation-step-tree__group"
|
||||
key={group.id}
|
||||
aria-labelledby={group.intent ? stageId : undefined}
|
||||
>
|
||||
{group.intent ? (
|
||||
<h3 className="consultation-step-tree__stage" id={stageId}>
|
||||
{stagedCount > 1 ? (
|
||||
<span className="consultation-step-tree__stage-index" aria-hidden="true">
|
||||
{index + 1}
|
||||
</span>
|
||||
) : (
|
||||
<span className="consultation-step-tree__stage-mark" aria-hidden="true" />
|
||||
)}
|
||||
<span className="consultation-step-tree__stage-title">
|
||||
{stagedCount > 1 ? <span className="sr-only">阶段 {index + 1}:</span> : null}
|
||||
{group.intent}
|
||||
</span>
|
||||
</h3>
|
||||
) : null}
|
||||
<StepList
|
||||
steps={group.steps}
|
||||
revealAll={showAll}
|
||||
live={Boolean(group.live)}
|
||||
liveLabel={group.live ? liveLabel : undefined}
|
||||
liveState={liveState}
|
||||
startedAt={group.live ? startedAt : undefined}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
|
||||
if (!caption) return <div className="consultation-step-tree">{body}</div>;
|
||||
|
||||
const open = userOpen ?? defaultOpen;
|
||||
return (
|
||||
<details
|
||||
className="message-thinking consultation-step-tree"
|
||||
open={open}
|
||||
onToggle={(event) => {
|
||||
setUserOpen((event.currentTarget as HTMLDetailsElement).open);
|
||||
}}
|
||||
>
|
||||
<summary>{caption}</summary>
|
||||
<div className="message-thinking-body">
|
||||
{reasoning?.trim() ? (
|
||||
<div className="consultation-step-tree__reasoning">{reasoning}</div>
|
||||
) : null}
|
||||
{body}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { AgentActivityTraceItem } from "./agent-activity-trace.ts";
|
||||
import type { AgentActivityView } from "./chat-message-view.ts";
|
||||
import type { ConsultationTimelineRow } from "./consultation-run-timeline.ts";
|
||||
import type { CompletedActivityReceiptView } from "./rectification-activity-receipt.ts";
|
||||
import { PUBLIC_RECTIFICATION_METHOD_LABELS } from "./rectification-varga-sentence.ts";
|
||||
|
||||
export const RECTIFICATION_TIMELINE_SOURCE_LIMIT = 8;
|
||||
export const RECTIFICATION_TIMELINE_LIVE_ID = "activity-live";
|
||||
const FAILED_SUFFIX = "未完成";
|
||||
|
||||
export type RectificationTimelineInput = Readonly<{
|
||||
trace: readonly AgentActivityTraceItem[] | undefined;
|
||||
receipt: CompletedActivityReceiptView | undefined;
|
||||
activity: AgentActivityView | undefined;
|
||||
settled: boolean;
|
||||
}>;
|
||||
|
||||
function methodChips(receipt: CompletedActivityReceiptView | undefined): string[] {
|
||||
if (!receipt) return [];
|
||||
const seen = new Set<string>();
|
||||
const chips: string[] = [];
|
||||
for (const method of receipt.methods) {
|
||||
const label = PUBLIC_RECTIFICATION_METHOD_LABELS[method];
|
||||
if (!label || seen.has(label)) continue;
|
||||
seen.add(label);
|
||||
chips.push(label);
|
||||
if (chips.length >= RECTIFICATION_TIMELINE_SOURCE_LIMIT) break;
|
||||
}
|
||||
return chips;
|
||||
}
|
||||
|
||||
/** Progress labels are the only ones that name an action still under way. */
|
||||
function isProgressLabel(label: string): boolean {
|
||||
return /^正在/.test(label.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Project the rectification agent's tool trace and receipt onto the same
|
||||
* timeline rows the consultation surface renders, so both sessions share one
|
||||
* step list, one live marker and one settled summary. Thinking text never
|
||||
* reaches this surface: the public stream drops it on the server.
|
||||
*/
|
||||
export function rectificationTimelineRows(input: RectificationTimelineInput): ConsultationTimelineRow[] {
|
||||
const failedTool = input.receipt?.failedTool;
|
||||
const rows: ConsultationTimelineRow[] = (input.trace ?? []).map((item) => {
|
||||
if (item.kind === "think") {
|
||||
return {
|
||||
id: item.id,
|
||||
kind: "think",
|
||||
status: item.status,
|
||||
label: item.label,
|
||||
...(item.text?.trim() ? { thinkingText: item.text } : {}),
|
||||
};
|
||||
}
|
||||
const failed = item.status === "done" && failedTool !== undefined && item.tool === failedTool;
|
||||
return {
|
||||
id: item.id,
|
||||
kind: "calculate",
|
||||
status: item.status,
|
||||
label: failed ? `${item.label}${FAILED_SUFFIX}` : item.label,
|
||||
};
|
||||
});
|
||||
|
||||
const chips = methodChips(input.receipt);
|
||||
if (chips.length > 0) {
|
||||
for (let index = rows.length - 1; index >= 0; index -= 1) {
|
||||
const row = rows[index];
|
||||
if (!row || row.kind !== "calculate" || row.status !== "done" || row.label.endsWith(FAILED_SUFFIX)) continue;
|
||||
rows[index] = { ...row, sources: chips };
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const hasLiveRow = rows.some((row) => row.status === "live");
|
||||
const label = input.activity?.label ?? "";
|
||||
if (!input.settled && !hasLiveRow && isProgressLabel(label)) {
|
||||
rows.push({
|
||||
id: RECTIFICATION_TIMELINE_LIVE_ID,
|
||||
kind: input.activity?.phase === "answer-composition" ? "write" : "calculate",
|
||||
status: "live",
|
||||
label,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
@@ -71,21 +71,16 @@ test("gsap loads on demand while keeping the reduced-motion gate", () => {
|
||||
assert.match(messageRowSource, /useEntryEffect\(\(\) => \{/);
|
||||
});
|
||||
|
||||
test("the thinking orb loads on demand behind a same-size placeholder", () => {
|
||||
// Given: only the state type is imported at build time.
|
||||
assert.doesNotMatch(activitySource, /^import \{ ThinkingOrb/m);
|
||||
assert.match(activitySource, /^import type \{ OrbState \} from "thinking-orbs";$/m);
|
||||
assert.match(activitySource, /dynamic\(async \(\) => \(await importThinkingOrb\(\)\)\.ThinkingOrb/);
|
||||
assert.match(activitySource, /ssr: false/);
|
||||
|
||||
// Then: the placeholder reserves the exact 20px orb box so the row cannot shift.
|
||||
assert.match(activitySource, /loading: \(\) => \(/);
|
||||
assert.match(activitySource, /height: 20, width: 20/);
|
||||
assert.match(activitySource, /flex: "0 0 auto"/);
|
||||
test("the live marker is the shared inline spinner, with no on-demand orb chunk", () => {
|
||||
// Former test: "the thinking orb loads on demand behind a same-size placeholder". The canvas
|
||||
// orb was a second live-marker vocabulary beside the timeline's InlineSpinner, undocumented in
|
||||
// DESIGN.md §9, and clipped inside the rectification surface's 14px marker (BUG-476). Removed.
|
||||
assert.doesNotMatch(activitySource, /thinking-orbs|ThinkingOrb|next\/dynamic/);
|
||||
assert.match(activitySource, /<InlineSpinner size=\{12\} \/>/);
|
||||
|
||||
// And: the live region and the decorative marking survive.
|
||||
assert.match(activitySource, /role="status"/);
|
||||
assert.match(activitySource, /<ThinkingOrb aria-hidden="true" state=\{state\} size=\{20\}/);
|
||||
assert.match(activitySource, /className="agent-thinking-marker is-live-marker" aria-hidden="true"/);
|
||||
});
|
||||
|
||||
test("deferred chat chunks are requested during idle time after first paint", () => {
|
||||
@@ -93,6 +88,5 @@ test("deferred chat chunks are requested during idle time after first paint", ()
|
||||
assert.match(prefetchSource, /window\.requestIdleCallback\(request, \{ timeout: 2_000 \}\)/);
|
||||
assert.match(prefetchSource, /window\.setTimeout\(request, 300\)/);
|
||||
assert.match(contentSource, /prefetchOnIdle\(loadMarkdownRenderer\)/);
|
||||
assert.match(activitySource, /prefetchOnIdle\(importThinkingOrb\)/);
|
||||
assert.match(messageRowSource, /if \(motionPreferred\(\)\) prefetchOnIdle\(loadGsap\)/);
|
||||
});
|
||||
|
||||
@@ -8,8 +8,6 @@ const transcriptSource = readFileSync(new URL("../src/components/chat-transcript
|
||||
const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
const messageRowSource = readFileSync(new URL("../src/components/chat-message-row.tsx", import.meta.url), "utf8");
|
||||
const activitySource = readFileSync(new URL("../src/components/agent-activity-status.tsx", import.meta.url), "utf8");
|
||||
const reportSource = readFileSync(new URL("../src/components/consultation-thinking-report.tsx", import.meta.url), "utf8");
|
||||
const treeSource = readFileSync(new URL("../src/components/thinking-step-tree.tsx", import.meta.url), "utf8");
|
||||
const timelineSource = readFileSync(new URL("../src/components/consultation-run-timeline.tsx", import.meta.url), "utf8");
|
||||
const reducerSource = readFileSync(new URL("../src/lib/consultation-run-timeline.ts", import.meta.url), "utf8");
|
||||
|
||||
@@ -80,7 +78,9 @@ test("shows honest agent activity states before and during streamed text", () =>
|
||||
assert.match(globalStyles, /\.message-stage-and-answer/);
|
||||
assert.match(globalStyles, /\.message-stage-and-answer \{[\s\S]*gap: var\(--space-2\)/);
|
||||
assert.match(globalStyles, /\.agent-activity-status \+ \.message-answer/);
|
||||
assert.match(activitySource, /<ThinkingOrb aria-hidden="true" state=\{state\} size=\{20\}/);
|
||||
// Former lock: `<ThinkingOrb aria-hidden="true" state={state} size={20}` — the canvas orb was a
|
||||
// second live-marker vocabulary next to the timeline's InlineSpinner (BUG-476). One marker now.
|
||||
assert.match(activitySource, /<InlineSpinner size=\{12\} \/>/);
|
||||
assert.match(activitySource, /className="agent-activity-status__text"/);
|
||||
assert.match(activitySource, /className="agent-activity-status__elapsed" aria-hidden="true"/);
|
||||
assert.match(activitySource, /className="agent-thinking-timeline"/);
|
||||
@@ -104,20 +104,14 @@ test("shows honest agent activity states before and during streamed text", () =>
|
||||
assert.match(pageSource, /activeStreamingSections/);
|
||||
assert.match(pageSource, /streamedThinking/);
|
||||
assert.match(messageRowSource, /showLiveActivity/);
|
||||
assert.match(messageRowSource, /ConsultationThinkingReport/);
|
||||
assert.match(messageRowSource, /thinkingText=\{hasTrace \? undefined : message\.thinkingText\}/);
|
||||
assert.match(messageRowSource, /activityTrace=\{message\.activityTrace\}/);
|
||||
assert.match(messageRowSource, /showLiveActivity \|\| \(!showReport && consultTimeline === undefined && \(Boolean\(message\.thinkingText/);
|
||||
assert.match(activitySource, /activityTrace \?\? \[\]/);
|
||||
assert.match(activitySource, /function TraceStep/);
|
||||
assert.match(globalStyles, /\.agent-thinking-step\.is-think:has\(\.message-thinking\)/);
|
||||
assert.match(reportSource, /caption="思考"/);
|
||||
assert.match(reportSource, /reasoning=\{thinkingText\}/);
|
||||
assert.match(reportSource, /aria-label="分析"/);
|
||||
assert.match(treeSource, /consultation-step-tree__reasoning/);
|
||||
assert.match(reportSource, /revealAll/);
|
||||
assert.match(reportSource, /groups=\{progressed\.map/);
|
||||
assert.doesNotMatch(reportSource, /analysisForSection|splitAnswerByHeadings/);
|
||||
// Former locks on `ConsultationThinkingReport`, `hasTrace`, `activityTrace={message.activityTrace}`,
|
||||
// `function TraceStep`, `.agent-thinking-step.is-think:has(...)`, and the report/tree sources:
|
||||
// those pinned the second and third thinking-render paths (sections report, trace panel) that
|
||||
// no caller could reach once every reply carries a timeline (BUG-476). One path remains.
|
||||
assert.doesNotMatch(messageRowSource, /ConsultationThinkingReport|showReport|hasTrace|activityTrace/);
|
||||
assert.match(messageRowSource, /thinkingText=\{message\.thinkingText\}/);
|
||||
assert.match(messageRowSource, /showLiveActivity \|\| \(consultTimeline === undefined && Boolean\(message\.thinkingText/);
|
||||
assert.doesNotMatch(activitySource, /activityTrace|thinking-orbs/);
|
||||
assert.match(globalStyles, /\.markdown-list/);
|
||||
assert.match(activitySource, />思考</);
|
||||
assert.match(pageSource, /event\.type === "run\.failed"/);
|
||||
@@ -144,12 +138,9 @@ test("shows honest agent activity states before and during streamed text", () =>
|
||||
assert.match(reducerSource, /kind: "calculate"/);
|
||||
assert.match(globalStyles, /\.consultation-report-analysis/);
|
||||
assert.match(globalStyles, /\.consultation-thinking-report/);
|
||||
assert.match(globalStyles, /\.consultation-thinking-report \.message-thinking/);
|
||||
assert.match(treeSource, /consultation-step-tree__stage/);
|
||||
assert.match(treeSource, /阶段 \{index \+ 1\}/);
|
||||
assert.doesNotMatch(treeSource, /consultation-step-tree__intent/);
|
||||
assert.match(globalStyles, /\.consultation-step-tree__stage-index/);
|
||||
assert.match(globalStyles, /\.consultation-step-tree__group \+ \.consultation-step-tree__group[^}]*margin-top:\s*var\(--space-6\)/);
|
||||
// Former locks on `.consultation-thinking-report .message-thinking`, `consultation-step-tree__*`
|
||||
// and the tree source: the step-tree report path is deleted (BUG-476), and with it its CSS.
|
||||
assert.doesNotMatch(globalStyles, /consultation-step-tree/);
|
||||
});
|
||||
|
||||
test("nothing sits between the transcript and the composer to shift height while streaming", () => {
|
||||
|
||||
@@ -54,7 +54,6 @@ const tailwindCollisions = new Set([
|
||||
const knownUnstyled = new Set([
|
||||
"birth-time-evidence-receipt",
|
||||
"chart-nav",
|
||||
"consultation-step-tree",
|
||||
"is-changed",
|
||||
"is-done",
|
||||
"is-live",
|
||||
|
||||
@@ -99,8 +99,16 @@ test("answer deltas preserve a still-running server activity", () => {
|
||||
chatSource.indexOf('event.type === "answer.delta"'),
|
||||
chatSource.indexOf('event.type === "run.failed"'),
|
||||
);
|
||||
assert.match(deltaBranch, /state: raw\.trim\(\) \? "streaming" : "thinking"/);
|
||||
assert.match(deltaBranch, /text: raw,/);
|
||||
// Former locks: `state: raw.trim() ? "streaming" : "thinking"` and `text: raw,` inside the
|
||||
// delta branch. Those pinned one setMessages per network chunk (BUG-473); the same state
|
||||
// derivation now lives in the frame buffer's flush, which the delta branch feeds.
|
||||
const frameFlush = chatSource.slice(
|
||||
chatSource.indexOf("const frames = createStreamFrameBuffer"),
|
||||
chatSource.indexOf("const abortController = new AbortController()"),
|
||||
);
|
||||
assert.match(frameFlush, /state: text\.trim\(\) \? "streaming" : "thinking"/);
|
||||
assert.match(frameFlush, /^\s*text,$/m);
|
||||
assert.match(deltaBranch, /frames\.setAnswer\(raw\)/);
|
||||
assert.match(deltaBranch, /event\.replace === true \? event\.text : raw \+ event\.text/);
|
||||
assert.doesNotMatch(deltaBranch, /settled\.spoken/);
|
||||
assert.match(deltaBranch, /正在组织回答/);
|
||||
@@ -110,7 +118,11 @@ test("answer deltas preserve a still-running server activity", () => {
|
||||
test("failed server turns retain their receipt state without parsing activity from answer text", () => {
|
||||
assert.match(chatSource, /failed:\s*true/);
|
||||
assert.match(chatSource, /completedReceipt/);
|
||||
assert.match(chatSource, /message\.state === "settled" && message\.failed/);
|
||||
// Former lock: `message.state === "settled" && message.failed`, the inline red banner above a
|
||||
// failed reply. That banner was the second, rectification-only failure surface (BUG-476);
|
||||
// failure now reaches the shared error notice while the receipt state still gates actions.
|
||||
assert.match(chatSource, /&& !message\.failed/);
|
||||
assert.doesNotMatch(chatSource, /rectification-activity-failure/);
|
||||
assert.doesNotMatch(chatSource, /parsed\.(?:activity|receipt|tools|methods)/);
|
||||
});
|
||||
|
||||
|
||||
@@ -44,10 +44,6 @@ const progressLabels = readFileSync(
|
||||
new URL("../src/lib/rectification-activity-labels.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const completedActivityReceipt = readFileSync(
|
||||
new URL("../src/components/completed-activity-receipt.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const choiceCardComponent = readFileSync(
|
||||
new URL("../src/components/rectification-choice-card.tsx", import.meta.url),
|
||||
"utf8",
|
||||
@@ -344,9 +340,10 @@ test("rectification keeps receipts for the varga sentence and shows live tool pr
|
||||
const replyIndex = messageRender.indexOf("<ChatMessageRow");
|
||||
assert.ok(replyIndex >= 0);
|
||||
assert.doesNotMatch(messageRender, /<RectificationHouseTableView/);
|
||||
assert.match(completedActivityReceipt, /<details className=/);
|
||||
assert.doesNotMatch(completedActivityReceipt, /<details[^>]*\sopen/);
|
||||
assert.match(completedActivityReceipt, /本轮完成 · \$\{stepLabels\.length\} 个步骤 · \$\{receipt\.methods\.length\} 项计算依据/);
|
||||
// Former locks on `completed-activity-receipt.tsx` (`<details>`, never open, the "本轮完成 · N 个
|
||||
// 步骤" summary): that was a second collapsed block under every settled rectification reply.
|
||||
// Its methods now sit as source chips on the shared timeline's calculate row (BUG-476).
|
||||
assert.match(chat, /completedReceipt/);
|
||||
for (const genericCopy of ["开始本轮执行", "正在加载专用方法", "专用方法已加载", "本轮做了什么"]) {
|
||||
assert.doesNotMatch(chat, new RegExp(genericCopy));
|
||||
}
|
||||
@@ -360,7 +357,10 @@ test("rectification keeps receipts for the varga sentence and shows live tool pr
|
||||
assert.doesNotMatch(chat, /本轮处理未完成,已保留服务端记录的执行进度/);
|
||||
assert.match(chat, /startActivityTraceStep/);
|
||||
assert.match(chat, /completeActivityTraceStep/);
|
||||
assert.match(activityStatus, /activityTrace/);
|
||||
// Former lock: `activityStatus` contained `activityTrace` — the rectification-only trace panel.
|
||||
// The trace is now projected onto the shared timeline rows (BUG-476); the panel is a fallback.
|
||||
assert.match(chat, /rectificationTimelineRows/);
|
||||
assert.doesNotMatch(activityStatus, /activityTrace/);
|
||||
assert.match(activityStatus, /className="message-thinking"/);
|
||||
assert.match(activityStatus, />思考</);
|
||||
assert.match(activityStatus, /userOpen \?\? !hasAnswer/);
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
completeActivityTraceStep,
|
||||
emptyActivityTrace,
|
||||
startActivityTraceStep,
|
||||
} from "../src/lib/agent-activity-trace.ts";
|
||||
import {
|
||||
RECTIFICATION_TIMELINE_LIVE_ID,
|
||||
RECTIFICATION_TIMELINE_SOURCE_LIMIT,
|
||||
rectificationTimelineRows,
|
||||
} from "../src/lib/rectification-timeline-adapter.ts";
|
||||
import { PUBLIC_RECTIFICATION_METHODS } from "../src/lib/rectification-agentic/v9/public-receipt.ts";
|
||||
|
||||
const read = (path: string) => readFileSync(new URL(`../${path}`, import.meta.url), "utf8");
|
||||
const chatSource = read("src/components/rectification-agentic-chat.tsx");
|
||||
const rowSource = read("src/components/chat-message-row.tsx");
|
||||
const activitySource = read("src/components/agent-activity-status.tsx");
|
||||
const packageJson = read("package.json");
|
||||
const globalStyles = read("src/app/globals.css");
|
||||
|
||||
test("a started tool is a live calculate row and a completed tool a done row with the done label", () => {
|
||||
const started = startActivityTraceStep(emptyActivityTrace(), "rectification-read-case", "正在读取校正记录…", 1);
|
||||
assert.deepEqual(rectificationTimelineRows({ trace: started, receipt: undefined, activity: undefined, settled: false }), [
|
||||
{ id: "activity-rectification-read-case-1", kind: "calculate", status: "live", label: "正在读取校正记录…" },
|
||||
]);
|
||||
|
||||
const done = completeActivityTraceStep(started, "rectification-read-case", "读取校正记录");
|
||||
assert.deepEqual(rectificationTimelineRows({
|
||||
trace: done,
|
||||
receipt: { steps: ["rectification-read-case"], methods: [] },
|
||||
activity: undefined,
|
||||
settled: true,
|
||||
}), [
|
||||
{ id: "activity-rectification-read-case-1", kind: "calculate", status: "done", label: "读取校正记录" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("a failed tool keeps its row with a 未完成 suffix and never carries the method chips", () => {
|
||||
let trace = startActivityTraceStep(emptyActivityTrace(), "rectification-read-case", "正在读取校正记录…", 1);
|
||||
trace = completeActivityTraceStep(trace, "rectification-read-case", "读取校正记录");
|
||||
trace = startActivityTraceStep(trace, "rectification-compare-candidates", "正在比较候选时间…", 2);
|
||||
trace = completeActivityTraceStep(trace, "rectification-compare-candidates", "比较候选时间");
|
||||
const rows = rectificationTimelineRows({
|
||||
trace,
|
||||
receipt: { steps: ["rectification-read-case"], methods: ["d1-rashi"], failedTool: "rectification-compare-candidates" },
|
||||
activity: undefined,
|
||||
settled: true,
|
||||
});
|
||||
assert.equal(rows.length, 2);
|
||||
assert.equal(rows[1]?.label, "比较候选时间未完成");
|
||||
assert.equal(rows[1]?.sources, undefined);
|
||||
assert.deepEqual(rows[0]?.sources, ["D1 本命盘"]);
|
||||
});
|
||||
|
||||
test("method chips are de-duplicated, labelled, capped, and attached to the last completed row", () => {
|
||||
let trace = startActivityTraceStep(emptyActivityTrace(), "rectification-read-case", "正在读取校正记录…", 1);
|
||||
trace = completeActivityTraceStep(trace, "rectification-read-case", "读取校正记录");
|
||||
trace = startActivityTraceStep(trace, "rectification-compare-candidates", "正在比较候选时间…", 2);
|
||||
trace = completeActivityTraceStep(trace, "rectification-compare-candidates", "比较候选时间");
|
||||
const rows = rectificationTimelineRows({
|
||||
trace,
|
||||
receipt: {
|
||||
steps: ["rectification-read-case", "rectification-compare-candidates"],
|
||||
methods: [...PUBLIC_RECTIFICATION_METHODS, ...PUBLIC_RECTIFICATION_METHODS],
|
||||
},
|
||||
activity: undefined,
|
||||
settled: true,
|
||||
});
|
||||
assert.equal(rows[0]?.sources, undefined);
|
||||
const chips = rows[1]?.sources ?? [];
|
||||
assert.equal(chips.length, RECTIFICATION_TIMELINE_SOURCE_LIMIT);
|
||||
assert.equal(new Set(chips).size, chips.length);
|
||||
for (const chip of chips) assert.doesNotMatch(chip, /^d\d+-|-/);
|
||||
});
|
||||
|
||||
test("a phase label that names work under way becomes the live row when no tool is live", () => {
|
||||
const queued = rectificationTimelineRows({
|
||||
trace: emptyActivityTrace(),
|
||||
receipt: undefined,
|
||||
activity: { phase: "evidence-validation", label: "正在处理…" },
|
||||
settled: false,
|
||||
});
|
||||
assert.deepEqual(queued, [{ id: RECTIFICATION_TIMELINE_LIVE_ID, kind: "calculate", status: "live", label: "正在处理…" }]);
|
||||
|
||||
const composing = rectificationTimelineRows({
|
||||
trace: emptyActivityTrace(),
|
||||
receipt: undefined,
|
||||
activity: { phase: "answer-composition", label: "正在组织回答…" },
|
||||
settled: false,
|
||||
});
|
||||
assert.equal(composing[0]?.kind, "write");
|
||||
|
||||
// A done label left on the activity after a tool completed is not a live step.
|
||||
const doneLabel = rectificationTimelineRows({
|
||||
trace: emptyActivityTrace(),
|
||||
receipt: undefined,
|
||||
activity: { phase: "chart-calculation", label: "比较候选时间" },
|
||||
settled: false,
|
||||
});
|
||||
assert.deepEqual(doneLabel, []);
|
||||
|
||||
// A live tool row already covers the activity, so nothing is appended.
|
||||
const live = startActivityTraceStep(emptyActivityTrace(), "rectification-read-case", "正在读取校正记录…", 1);
|
||||
assert.equal(rectificationTimelineRows({
|
||||
trace: live,
|
||||
receipt: undefined,
|
||||
activity: { phase: "loading-method", label: "正在读取校正记录…" },
|
||||
settled: false,
|
||||
}).length, 1);
|
||||
});
|
||||
|
||||
test("after attempt.reset the timeline is empty, and a settled reply without a receipt renders no rows", () => {
|
||||
assert.deepEqual(rectificationTimelineRows({ trace: emptyActivityTrace(), receipt: undefined, activity: undefined, settled: false }), []);
|
||||
assert.deepEqual(rectificationTimelineRows({ trace: undefined, receipt: { steps: [], methods: [] }, activity: undefined, settled: true }), []);
|
||||
});
|
||||
|
||||
test("both chat surfaces render one timeline, one live marker, and no second receipt block", () => {
|
||||
assert.match(chatSource, /rectificationTimelineRows\(\{/);
|
||||
assert.match(chatSource, /timeline: rectificationTimelineRows/);
|
||||
assert.doesNotMatch(chatSource, /rectification-activity-failure|<CompletedActivityReceipt/);
|
||||
assert.doesNotMatch(chatSource, /label: "正在组织回答…",\s*\}\),\s*\}\s*:\s*message;/);
|
||||
assert.doesNotMatch(rowSource, /ConsultationThinkingReport|showReport|activityTrace/);
|
||||
assert.doesNotMatch(activitySource, /thinking-orbs|ThinkingOrb|\bTraceStep\b|activityTrace/);
|
||||
assert.match(activitySource, /<InlineSpinner size=\{12\} \/>/);
|
||||
assert.doesNotMatch(packageJson, /thinking-orbs/);
|
||||
assert.doesNotMatch(globalStyles, /\.conversation\.is-rectification \.message-assistant \.agent-thinking-(step|marker)/);
|
||||
assert.doesNotMatch(globalStyles, /rectification-activity-receipt|rectification-activity-failure|consultation-step-tree/);
|
||||
});
|
||||
Reference in New Issue
Block a user