From 628c1bffe0c35d46baef1f492fdaba9e53ae77e7 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 27 Jul 2026 15:47:44 +0800 Subject: [PATCH 001/189] feat(ui): add trajectory inspection ledger --- ...-27-trajectory-inspection-ledger.i18n.yaml | 6 + ...2026-07-27-trajectory-inspection-ledger.md | 36 + ...6-07-27-trajectory-inspection-ledger.zh.md | 36 + apps/web/tests/navigation-panes.e2e.ts | 19 +- .../navigation-panes/trajectory.expected.md | 58 +- packages/client/runtime/src/client/index.ts | 2 +- .../src/client/sessions/conversation.ts | 16 +- .../src/client/sessions/fold-adapter.ts | 50 +- .../runtime/src/client/sessions/session.ts | 33 +- .../ui-conversation/src/client/apply.ts | 8 +- .../src/client/contract/slots.ts | 2 + .../src/client/skeleton/ConversationRoot.tsx | 14 +- .../client/skeleton/ConversationSession.tsx | 15 +- .../ui-layout/src/client/AppFrame.module.css | 2 +- packages/client/ui-primitives/package.json | 4 + .../ui-primitives/src/JsonTree.module.css | 221 +++ .../client/ui-primitives/src/JsonTree.tsx | 390 +++++ .../client/ui-primitives/src/Menu.module.css | 31 + packages/client/ui-primitives/src/Menu.tsx | 8 +- packages/client/ui-primitives/src/index.ts | 4 + .../ui-primitives/src/markdown/plain-text.ts | 126 ++ .../tests/markdown-plain-text.spec.ts | 42 + .../client/ui-trajectory/README.i18n.yaml | 6 +- packages/client/ui-trajectory/README.md | 4 +- packages/client/ui-trajectory/README.zh.md | 4 +- packages/client/ui-trajectory/package.json | 4 +- .../src/client/TrajectoryCell.tsx | 63 +- .../src/client/TrajectoryTable.module.css | 1067 ++++++++++++++ .../src/client/TrajectoryTable.tsx | 1301 +++++++++++++++++ .../src/client/TrajectoryToolbar.module.css | 85 ++ .../src/client/TrajectoryToolbar.tsx | 66 + .../src/client/TrajectoryView.tsx | 115 +- .../src/client/WaterfallView.tsx | 2 +- .../client/ui-trajectory/src/client/layout.ts | 294 +++- .../src/client/trajectory-record.ts | 81 + .../ui-trajectory/src/client/views.module.css | 17 +- .../ui-trajectory/tests/layout.spec.tsx | 15 +- .../client/ui-trajectory/tests/table.spec.tsx | 88 ++ .../client/ui-trajectory/tests/views.spec.tsx | 42 +- patches/react-json-view-lite@2.5.0.patch | 319 ++++ pnpm-lock.yaml | 28 + pnpm-workspace.yaml | 3 + 42 files changed, 4549 insertions(+), 178 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md create mode 100644 .agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md create mode 100644 packages/client/ui-primitives/src/JsonTree.module.css create mode 100644 packages/client/ui-primitives/src/JsonTree.tsx create mode 100644 packages/client/ui-primitives/src/markdown/plain-text.ts create mode 100644 packages/client/ui-primitives/tests/markdown-plain-text.spec.ts create mode 100644 packages/client/ui-trajectory/src/client/TrajectoryTable.module.css create mode 100644 packages/client/ui-trajectory/src/client/TrajectoryTable.tsx create mode 100644 packages/client/ui-trajectory/src/client/TrajectoryToolbar.module.css create mode 100644 packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx create mode 100644 packages/client/ui-trajectory/src/client/trajectory-record.ts create mode 100644 packages/client/ui-trajectory/tests/table.spec.tsx create mode 100644 patches/react-json-view-lite@2.5.0.patch diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml new file mode 100644 index 0000000000..035cb14e90 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md +2026-07-27-trajectory-inspection-ledger.md: 30d1a2b0b43c8ca134f934c7197f85ff03c65742 +2026-07-27-trajectory-inspection-ledger.zh.md: 2c8724160ca25a4bd9ae4cdddd5b1e07bc1d808c diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md new file mode 100644 index 0000000000..30d1a2b0b4 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md @@ -0,0 +1,36 @@ +# Agent Note: Trajectory inspection ledger + +Status: implemented + +English | [中文](2026-07-27-trajectory-inspection-ledger.zh.md) + +## Problem + +Trajectory has to make prose, machine payloads, token usage, timing, and nested tool activity readable in the same viewport. The earlier stacked Turn and Step cards preserved hierarchy but spent too much vertical space on repeated chrome, while a completely flat table would erase the causal structure that makes a trajectory useful. Role colors also risked borrowing success and warning semantics, which made visual decoration indistinguishable from runtime state. + +## Decision + +**Render a compact, turn-aware event ledger with a local record inspector, using the existing DeepSeek design system.** + +- Turn boundaries are thick rules between record rows, while each Step appears as a compact inline marker on its first record. Individual User, Assistant, Tool, and Subtool events share stable columns for index, event kind, and content; token usage and duration stay in the inspector, a thin timeline rail preserves sequence, and nested subtools receive a small indentation. +- Product prose continues to use the existing sans stack. Record indexes, token counts, durations, group summaries, tool calls, and raw payloads use the existing code stack because they are machine data. +- Existing semantic theme tokens own both light and dark rendering. Neutral borders and surfaces form the structure; business blue is limited to Assistant identity, selection, links, and focus; warning is limited to running work; error is limited to failed work. User and Tool roles do not impersonate runtime states. +- Entity surfaces stay flat and separated by hairline borders. Shadow appears only when the inspector becomes an overlay at narrow widths. +- Selecting a record opens an inspector inside Trajectory with Overview, Input, Output, and Timing tabs. This state is deliberately independent from the conversation-wide Chat details column: it inspects a trajectory record without changing the user's Chat context. +- The three-column ledger reserves its width for record content. At narrow widths the inspector overlays the ledger and remains dismissible by keyboard or pointer. + +## Alternatives considered + +**Copy Vite DevTools fonts, colors, glass surfaces, or component shapes.** Rejected: those choices express a different product identity. The implementation only adopts the transferable method: neutral structure, semantic accents, machine-data typography, dense scanning, and shadows reserved for floating layers. + +**Keep one card per Turn and Step.** Rejected: repeated card chrome reduced the number of visible records and made cross-step comparison slower. + +**Flatten every record without turn rules or step markers.** Rejected: a trajectory is not merely a log stream; Turn and Step boundaries are essential causal landmarks even when they do not consume dedicated rows. + +**Reuse the global Chat details column.** Rejected: it would couple local inspection to conversation navigation and make a row click unexpectedly change another view's state. + +**Change global theme tokens to match the reference.** Rejected: the existing theme already provides paired light and dark semantic layers, and a local redesign does not justify changing unrelated surfaces. + +## Consequences + +Trajectory shows more useful records per viewport while retaining Turn and Step orientation. The main ledger omits token usage and duration so content receives the available width; the local inspector exposes those facts together with full payload and assistant timing. The inspector floats over the table only when a permanent split would make both panes unusable. Focused component tests pin the ledger, fold control, keyboard selection, payload tabs, timing facts, and running/error semantics; the assembled Web snapshot pins the real seeded session with the local inspector open. diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md new file mode 100644 index 0000000000..2c8724160c --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md @@ -0,0 +1,36 @@ +# Agent Note:轨迹检查记录表 + +Status: implemented + +[English](2026-07-27-trajectory-inspection-ledger.md) | 中文 + +## 问题 + +轨迹视图需要在同一视口内清晰呈现正文、机器载荷、token 用量、计时数据和嵌套工具活动。此前堆叠式的轮次与步骤卡片虽然保留了层级,却在重复界面框架上耗费了太多垂直空间;完全扁平化的表格又会抹去因果结构,而这种结构正是轨迹视图的价值所在。角色配色还可能借用成功与警告语义,使视觉装饰与运行时状态无法区分。 + +## 决策 + +**使用现有 DeepSeek 设计系统,渲染保留轮次结构的紧凑事件记录表,并提供局部记录检查器。** + +- 轮次边界由记录行之间较粗的分割线表示,每个步骤在其首条记录上以紧凑的行内标记呈现。用户、助手、工具和子工具事件共用稳定的索引、事件类型和内容列;token 用量与耗时留在检查器中,细线时间轴保留事件顺序,嵌套子工具则采用小幅缩进。 +- 产品正文继续使用现有无衬线字体栈。记录索引、token 数、耗时、分组摘要、工具调用和原始载荷属于机器数据,因此使用现有代码字体栈。 +- 现有语义主题 token 同时负责亮色和暗色渲染。中性边框与表面构成整体结构;业务蓝色仅用于助手身份、选择状态、链接和焦点;警告色仅用于运行中的工作;错误色仅用于失败的工作。用户和工具角色不借用运行时状态的视觉语义。 +- 各记录表面保持平面化,并以细线边框分隔。只有在窄屏下检查器变为浮层时才使用阴影。 +- 选择记录后,轨迹视图内部会打开包含概览、输入、输出和计时标签页的检查器。该状态有意与会话级 Chat 详情栏相互独立:检查轨迹记录不会改变用户在 Chat 中的上下文。 +- 三列记录表将宽度留给记录内容。在窄屏下,检查器会覆盖在记录表上,并且仍可通过键盘或指针关闭。 + +## 曾考虑的替代方案 + +**照搬 Vite DevTools 的字体、颜色、玻璃表面或组件形状。** 不予采纳:这些选择表达的是另一种产品身份。实现仅吸收可迁移的方法,即中性结构、语义强调色、机器数据排版、紧凑扫读,以及只为浮层保留阴影。 + +**每个轮次和步骤保留一张卡片。** 不予采纳:重复的卡片框架减少了可见记录数量,并降低了跨步骤比较的速度。 + +**不使用轮次分割线与步骤标记,将所有记录完全扁平化。** 不予采纳:轨迹并非普通日志流;即使轮次与步骤边界不再占用独立行,它们仍是不可缺少的关键因果标记。 + +**复用全局 Chat 详情栏。** 不予采纳:这会让局部检查与会话导航耦合,还会使行点击意外改变另一个视图的状态。 + +**修改全局主题 token 以匹配参考设计。** 不予采纳:现有主题已经提供配对的亮色与暗色语义层,局部重新设计不足以成为修改无关表面的理由。 + +## 后果 + +轨迹视图在保留轮次与步骤定位的同时,每个视口可以显示更多有效记录。主记录表省略 token 用量和耗时,让内容获得可用宽度;局部检查器则将这些数据与完整载荷、助手计时一并展示。只有固定分栏会让两个面板都无法使用时,检查器才浮在表格之上。针对性组件测试锁定事件记录表、折叠控制、键盘选择、载荷标签页、计时数据和运行/错误语义;组装后的 Web 快照则锁定真实预置会话在局部检查器打开时的渲染结果。 diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index bbae7363df..a4abc5cecd 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -124,17 +124,24 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1) }, 60_000) - it.skipIf(MODE === 'record')('renders the trajectory tab with turn sections and step cells', async () => { + it.skipIf(MODE === 'record')('renders the trajectory ledger and opens its local record inspector', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-trajectory')) await page.getByRole('tab', { name: 'Trajectory' }).click() - // Two sticky turn sections; turn 1's step group summarizes its tool mix - // (bash + the two parallel reads collapse to 'bash read×2'). - await expect.poll(() => page.getByText('Turn 1', { exact: true }).count(), { timeout: 15_000 }).toBe(1) - await expect.poll(() => page.getByText('Turn 2', { exact: true }).count(), { timeout: 10_000 }).toBe(1) - await expect.poll(() => page.getByText('bash read×2', { exact: false }).count(), { timeout: 10_000 }).toBe(1) + // Thick row rules mark the two turns; compact inline markers identify + // each step without introducing dedicated group rows. + await expect.poll(() => page.locator('tr[data-turn-start="true"]').count(), { timeout: 15_000 }).toBe(2) + await expect.poll(() => page.locator('[aria-label^="Step "]').count(), { timeout: 10_000 }).toBe(3) + await expect.poll(() => page.getByRole('columnheader', { name: 'Tokens' }).count(), { timeout: 10_000 }).toBe(0) + await expect.poll(() => page.getByRole('columnheader', { name: '耗时' }).count(), { timeout: 10_000 }).toBe(0) + await page.locator('tr[data-kind="tool"]').first().click() + await expect.poll(() => page.getByRole('complementary', { name: '记录详情' }).count(), { timeout: 10_000 }).toBe(1) + await page.getByRole('tab', { name: '输出' }).click() + await expect.poll(() => page.getByText('NAVIGATION_OK', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1) const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE) + await page.getByRole('complementary', { name: '记录详情' }) + .getByRole('button', { name: '关闭详情' }).click() }, 60_000) it.skipIf(MODE === 'record')('renders the waterfall tab with span stats and one lane per span', async () => { diff --git a/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md b/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md index 80d6f161ca..4224bce857 100644 --- a/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md +++ b/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md @@ -1 +1,57 @@ -- text: "Turn 1 Message {{duration}} #1 User NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop. +{{duration}} Step 1 {{duration}} bash read×2 #2 Tool bash · {\"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\"} +{{duration}} #3 Tool read · {\"file_path\": \"nav-a.md\"} +{{duration}} #4 Tool read · {\"file_path\": \"nav-b.md\"} +{{duration}} Step 2 {{duration}} #5 Message FIRST_DONE 349 56 51 +{{duration}} Turn 2 Message {{duration}} #6 User Reply in markdown with: a level-2 heading \"Navigation Summary\", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop. +{{duration}} Step 1 {{duration}} #7 Message ## Navigation Summary - alpha nav - beta nav ``` echo WATERFALL ``` 141 36 16 +{{duration}}" +- toolbar "轨迹工具栏": + - text: 轨迹 + - strong: "8" + - text: 条记录 + - strong: "2" + - text: 轮 + - strong: "3" + - text: 次工具 + - button "收起记录" +- table: + - rowgroup: + - row "# 事件 内容": + - columnheader "#" + - columnheader "事件" + - columnheader "内容" + - rowgroup: + - 'row "记录 1,USER,NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop."': + - cell "#1" + - cell "USER" + - 'cell "NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop."' + - row "记录 2,ASSISTANT,请求调用 bash、read×2": + - cell "#2" + - cell "Step 1 ASSISTANT": S1 ASSISTANT + - cell "请求调用 bash、read×2" + - 'row "记录 3,TOOL,bash · {\"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\"}" [selected]': + - cell "#3" + - cell "TOOL" + - 'cell "bash · {\"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\"}→NAVIGATION_OK"' + - 'row "记录 4,TOOL,read · {\"file_path\": \"nav-a.md\"}"': + - cell "#4" + - cell "TOOL" + - 'cell "read · {\"file_path\": \"nav-a.md\"}→{{cwd}}/nav-a.md file 1: # alpha nav (End of file - total 1 lines) "' + - 'row "记录 5,TOOL,read · {\"file_path\": \"nav-b.md\"}"': + - cell "#5" + - cell "TOOL" + - 'cell "read · {\"file_path\": \"nav-b.md\"}→{{cwd}}/nav-b.md file 1: # beta nav (End of file - total 1 lines) "' + - row "记录 6,ASSISTANT,FIRST_DONE": + - cell "#6" + - cell "Step 2 ASSISTANT": S2 ASSISTANT + - cell "FIRST_DONE" + - 'row "记录 7,USER,Reply in markdown with: a level-2 heading \"Navigation Summary\", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop."': + - cell "#7" + - cell "USER" + - 'cell "Reply in markdown with: a level-2 heading \"Navigation Summary\", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop."' + - 'row "记录 8,ASSISTANT,## Navigation Summary - alpha nav - beta nav ``` echo WATERFALL ```"': + - cell "#8" + - cell "Step 1 ASSISTANT": S1 ASSISTANT + - 'cell "## Navigation Summary - alpha nav - beta nav ``` echo WATERFALL ```"' +- complementary "记录详情": + - text: "TOOL 记录 #3 Turn 1 · Step 1" + - button "关闭详情" + - tablist "记录详情": + - tab "概览" + - tab "输入" + - tab "输出" [selected] + - tab "计时" + - tabpanel "输出": NAVIGATION_OK diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index b1300a192c..15b3beebb8 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -28,7 +28,7 @@ export type { EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore, } from './contract/store.ts' export type { - AssistantBlock, AssistantMessageNode, CodeSubCall, ComposerPhase, ContextMessageNode, ConversationNode, + AssistantBlock, AssistantMessageNode, AssistantTiming, CodeSubCall, ComposerPhase, ContextMessageNode, ConversationNode, ConversationSnapshot, QueuedMessage, RunningToolCall, SteeringMessageNode, ToolResultNode, UnknownSurfaceNode, UserMessageNode, } from './sessions/conversation.ts' diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 49ae8634ec..609c2830bc 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -3,7 +3,7 @@ // substructures keep their references (the React.memo premise). callId/approvalId stay plain // string here (narrow to real brands when convenient). -import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm/types' import type { RpcError, SessionId, ToolCallView, ToolResultView, } from '@deepseek-ai/dsh-client-connection/client' @@ -50,6 +50,16 @@ export interface UserMessageNode { source: unknown } +/** Recorded boundaries used to derive assistant latency and throughput. */ +export interface AssistantTiming { + /** Matching step/start timestamp, or null when it is outside the current event window. */ + stepStartTime: number | null + /** First non-empty text/reasoning/tool delta timestamp, or null when no token delta was recorded. */ + firstTokenTime: number | null + /** Final assistant/message timestamp. */ + completedTime: number +} + /** A finalized (or interruption-frozen) assistant message. */ export interface AssistantMessageNode { kind: 'assistant' @@ -60,6 +70,8 @@ export interface AssistantMessageNode { step: number blocks: readonly AssistantBlock[] usage?: unknown + /** Timing derived from the recorded step/chunk/message event sequence. */ + timing?: AssistantTiming /** Frozen partial of an aborted turn (no finalize ever arrives): rendered with a 已停止 marker. * Synthetic seq (fractional, derived from the turn/end seq) keeps it ordered inside the flow. */ interrupted?: true @@ -216,6 +228,8 @@ export interface ConversationSnapshot { * unrelated snapshot swaps (memo premise, same regime as `nodes`). */ codeDispatches: ReadonlyMap + /** Model-visible tool schema captured for each recorded call id. */ + callSchemas?: ReadonlyMap pending: readonly PendingInteraction[] /** Read-only inbox mirror (session/queued frames + mux-open baseline; cleared by the leave-running flip). */ queue: readonly QueuedMessage[] diff --git a/packages/client/runtime/src/client/sessions/fold-adapter.ts b/packages/client/runtime/src/client/sessions/fold-adapter.ts index 0f40d9bf2a..9fc302398a 100644 --- a/packages/client/runtime/src/client/sessions/fold-adapter.ts +++ b/packages/client/runtime/src/client/sessions/fold-adapter.ts @@ -9,7 +9,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types' // browser bundle cannot resolve; surface.ts has no Node dependencies. import { SurfaceManager, isSurfaceEligibleType } from '@deepseek-ai/dsh-session/surface' import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' -import type { ConversationNode } from './conversation.ts' +import type { AssistantTiming, ConversationNode } from './conversation.ts' import { toAssistantBlocks } from './conversation.ts' /** In-window tool/call index entry (result-card backfill + runningCalls material). */ @@ -37,6 +37,7 @@ function materializeNode( event: SessionEvent, callIndex: ReadonlyMap, resultView: ToolResultView | null, + assistantTiming?: AssistantTiming, ): ConversationNode { switch (event.type) { case 'user/message': @@ -58,6 +59,7 @@ function materializeNode( kind: 'assistant', seq: event.seq, time: event.time, turn: event.data.turn, step: event.data.step, blocks: toAssistantBlocks(event.data.content), usage: event.data.usage, + ...(assistantTiming !== undefined ? { timing: assistantTiming } : {}), } case 'steering/message': return { @@ -177,7 +179,12 @@ export class FoldAdapter { const event = this.padded[seq] /* v8 ignore next -- sparse guard: both seq sources (surface fold and degradedSeqs) only emit indexes present in padded. */ if (event === undefined) continue - const node = materializeNode(event, this.callIdx, this.resultViews.get(seq) ?? null) + const node = materializeNode( + event, + this.callIdx, + this.resultViews.get(seq) ?? null, + event.type === 'assistant/message' ? this.assistantTiming(event) : undefined, + ) this.nodeCache.set(seq, node) out.push(node) } @@ -196,6 +203,33 @@ export class FoldAdapter { return seqs } + private assistantTiming(event: SessionEvent<'assistant/message'>): AssistantTiming { + let stepStartTime: number | null = null + let firstTokenTime: number | null = null + for (let i = this.baseSeq; i < this.padded.length; i++) { + const candidate = this.padded[i] + if (candidate === undefined || candidate.seq > event.seq) break + if ( + candidate.type === 'step/start' + && candidate.data.turn === event.data.turn + && candidate.data.step === event.data.step + ) { + stepStartTime = candidate.time + continue + } + if ( + firstTokenTime === null + && candidate.type === 'assistant/chunk' + && candidate.data.turn === event.data.turn + && candidate.data.step === event.data.step + && isTokenDelta(candidate.data.chunk) + ) { + firstTokenTime = candidate.time + } + } + return { stepStartTime, firstTokenTime, completedTime: event.time } + } + private indexCall(event: SessionEvent, view?: ToolEventView): void { if (event.type === 'tool/result') { if (view?.for === 'result') this.resultViews.set(event.seq, view.view) @@ -211,3 +245,15 @@ export class FoldAdapter { // (window order puts the call before its result; cannot happen on the normal path). } } + +function isTokenDelta(chunk: SessionEvent<'assistant/chunk'>['data']['chunk']): boolean { + switch (chunk.type) { + case 'text-delta': + case 'reasoning-delta': + return chunk.text !== '' + case 'tool-call-delta': + return chunk.argumentsDelta !== '' || chunk.name !== undefined + default: + return false + } +} diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 75c55bc4bd..493ca6baa7 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -1,7 +1,7 @@ // Sessions remain resident after creation so they continue consuming mux frames off-screen. import type { Context } from 'cordis' -import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult, @@ -104,6 +104,12 @@ export class Session implements ObservableSnapshot { private codeDispatches = new Map() private dispatchesRev = 0 private dispatchesCache: { rev: number; value: ReadonlyMap } | null = null + /** Schemas in force for the next tool/call, updated by request/header. */ + private activeToolSchemas = new Map() + /** Call-time schema snapshots keyed by native or code-dispatch call id. */ + private callSchemas = new Map() + private callSchemasRev = 0 + private callSchemasCache: { rev: number; value: ReadonlyMap } | null = null private running = false /** * Sticky send marker, private input of the composerPhase derivation: set @@ -611,6 +617,7 @@ export class Session implements ObservableSnapshot { argsRaw: JSON.stringify(data.arguments), turn: 0, step: 0, time: event.time, callView: null, } + this.captureCallSchema(data.subCallId, data.name) const siblings = this.codeDispatches.get(data.parentCallId) ?? [] this.codeDispatches.set(data.parentCallId, [...siblings, running]) this.dispatchesRev++ @@ -630,6 +637,7 @@ export class Session implements ObservableSnapshot { content: ContentBlock[] } const siblings = this.codeDispatches.get(data.parentCallId) ?? [] + this.captureCallSchema(data.subCallId, data.name) const at = siblings.findIndex(sub => sub.callId === data.subCallId) const started = at === -1 ? undefined : siblings[at] const settled: CodeSubCall = { @@ -651,6 +659,12 @@ export class Session implements ObservableSnapshot { return } switch (event.type) { + case 'request/header': { + this.activeToolSchemas = new Map( + (event.data.header.tools ?? []).map(schema => [schema.name, schema]), + ) + return + } case 'assistant/chunk': { const { turn, step, chunk } = event.data if (this.partial === null || this.partial.turn !== turn || this.partial.step !== step) { @@ -666,6 +680,7 @@ export class Session implements ObservableSnapshot { return } case 'tool/call': { + this.captureCallSchema(String(event.data.callId), event.data.name) this.openCalls.set(String(event.data.callId), { callId: String(event.data.callId), name: event.data.name, argsRaw: event.data.arguments, turn: event.data.turn, step: event.data.step, time: event.time, @@ -720,6 +735,15 @@ export class Session implements ObservableSnapshot { } } + /** Preserve the schema active when one call starts. */ + private captureCallSchema(callId: string, name: string): void { + if (this.callSchemas.has(callId)) return + const schema = this.activeToolSchemas.get(name) + if (schema === undefined) return + this.callSchemas.set(callId, schema) + this.callSchemasRev++ + } + /** Re-derive state (partial/openCalls/frozenNodes) from raw window events after a rebuild — keeps * paging/stitching consistent, and makes the live freeze and the history replay converge on the * same interrupted nodes (chunks are logged, so the replayed sweep re-freezes identical text). */ @@ -731,6 +755,9 @@ export class Session implements ObservableSnapshot { this.frozenRev++ this.codeDispatches = new Map() this.dispatchesRev++ + this.activeToolSchemas = new Map() + this.callSchemas = new Map() + this.callSchemasRev++ for (let i = 0; i < this.events.length; i++) { const event = this.events[i] /* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */ @@ -766,6 +793,9 @@ export class Session implements ObservableSnapshot { if (this.dispatchesCache === null || this.dispatchesCache.rev !== this.dispatchesRev) { this.dispatchesCache = { rev: this.dispatchesRev, value: new Map(this.codeDispatches) } } + if (this.callSchemasCache === null || this.callSchemasCache.rev !== this.callSchemasRev) { + this.callSchemasCache = { rev: this.callSchemasRev, value: new Map(this.callSchemas) } + } if (this.queueCache === null || this.queueCache.rev !== this.queueRev) { this.queueCache = { rev: this.queueRev, value: this.queued.map(entry => entry.row) } } @@ -778,6 +808,7 @@ export class Session implements ObservableSnapshot { runningCalls: this.callsCache.value, pending: this.pendingCache.value, codeDispatches: this.dispatchesCache.value, + callSchemas: this.callSchemasCache.value, queue: this.queueCache.value, running: this.running, composerPhase: derivePhase( diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index c8d5be336d..76ac8a8db3 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -72,8 +72,8 @@ export function apply(ctx: Context): void { }, }), 'ui-conversation: input standard-kit provider') - // Resident current-session-optional shell. It owns the stable Hero/composer - // frame while strict session slots fill only their session-bound regions. + // Resident current-session-optional shell. It constructs the stable + // composer frame; the strict session child decides whether Chat mounts it. slots.register({ name: 'conversation', children: { @@ -106,8 +106,8 @@ export function apply(ctx: Context): void { }), }, ConversationRoot) - // The strict session subtree owns only per-session store and view content; - // the resident parent keeps Hero and composer layout identity stable. + // The strict session subtree owns the per-session store and view content; + // the resident parent supplies the composer node through owner props. slots.register({ name: 'conversation.session', children: { 'conversation.view': { kind: 'list', scope: 'session' } }, diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 1e620f0905..b2b193bc40 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -107,6 +107,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Owner share of the strict session content seat. */ export interface ConversationSessionOwnerProps { + /** Composer chain assembled by the resident parent; only Chat mounts it. */ + composer: ReactNode } /** diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index 5f9e91a049..51e5fe9d33 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -74,6 +74,11 @@ export function ConversationRoot({ {inputBar} ) + const composer = renderSlotChain( + 'conversation.composer', + { interactions: pending }, + { fallback: composerBar, overlay: true }, + ) return (
@@ -81,12 +86,9 @@ export function ConversationRoot({ renders no chrome while blank but owns the draft-persistence mirror bind — unmounting it in the hero would lose pre-first-send text on a refresh or scope rebuild. */} - {sessionId !== undefined && renderSlot('conversation.session', {})} - {renderSlotChain( - 'conversation.composer', - { interactions: pending }, - { fallback: composerBar, overlay: true }, - )} + {sessionId !== undefined + ? renderSlot('conversation.session', { composer }) + : composer}
) } diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx index 515bfe1f93..26615905a1 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx @@ -1,6 +1,6 @@ /** Strict per-session conversation content: header, view ring, and chat store bindings. */ -import { useEffect, useSyncExternalStore } from 'react' +import { Fragment, useEffect, useSyncExternalStore } from 'react' import clsx from 'clsx' import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client' @@ -24,7 +24,7 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session export function ConversationSession({ sessionId, useSession, useSessions, useInput, inputActions, useStore, actions, - renderSlot, views, bindDraftMirror, open, + renderSlot, views, bindDraftMirror, open, composer, }: ConversationSessionProps) { useSyncExternalStore(views.subscribe, views.version) const tabs = views.list() @@ -45,11 +45,11 @@ export function ConversationSession({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [inputActions]) - if (blank && composerPhase === 'blank') return null + const blankHero = blank && composerPhase === 'blank' return ( <> -
+ {!blankHero &&
)} -
-
+
} + {!blankHero &&
{active !== undefined && renderSlot('conversation.view', {}, { only: active.id })} -
+ } + {(blankHero || active?.id === 'chat') && {composer}} ) } diff --git a/packages/client/ui-layout/src/client/AppFrame.module.css b/packages/client/ui-layout/src/client/AppFrame.module.css index b805bb178a..fef81b6e9f 100644 --- a/packages/client/ui-layout/src/client/AppFrame.module.css +++ b/packages/client/ui-layout/src/client/AppFrame.module.css @@ -54,12 +54,12 @@ Handle component; the hit strip stays wider than the pill. */ .handle { position: absolute; + z-index: 6; top: 0; bottom: 0; width: 8px; margin-left: -4px; cursor: col-resize; - z-index: 2; touch-action: none; /* Rides the same curve as the tracks so the pill stays on the moving border during collapse/expand; paused while dragging (frame rule). */ diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index 9ce2bc8676..dfb9fa8739 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -22,8 +22,12 @@ "dependencies": { "@shikijs/langs": "^4.3.1", "clsx": "^2.0.0", + "mdast-util-from-markdown": "^2.0.3", + "mdast-util-gfm": "^3.1.0", + "micromark-extension-gfm": "^3.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-json-view-lite": "^2.5.0", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "shiki": "^4.3.1" diff --git a/packages/client/ui-primitives/src/JsonTree.module.css b/packages/client/ui-primitives/src/JsonTree.module.css new file mode 100644 index 0000000000..34b690a0f0 --- /dev/null +++ b/packages/client/ui-primitives/src/JsonTree.module.css @@ -0,0 +1,221 @@ +.root { + --json-tree-property: #881391; + --json-tree-string: #c41a16; + --json-tree-number: #1c00cf; + --json-tree-keyword: #1c00cf; + --json-tree-punctuation: #202124; + --json-tree-icon: #5f6368; + --json-tree-hover: rgb(60 64 67 / 4%); + + min-width: 0; + overflow: auto; + position: relative; + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-bg-layer-1); + font: 12px/16px var(--ds-font-family-code); + overscroll-behavior: contain; +} + +:global(body[data-ds-dark-theme]) .root { + --json-tree-property: #5db0d7; + --json-tree-string: #f28b82; + --json-tree-number: #99c8ff; + --json-tree-keyword: #99c8ff; + --json-tree-punctuation: #e8eaed; + --json-tree-icon: #9aa0a6; + --json-tree-hover: rgb(232 234 237 / 5%); +} + +.container { + box-sizing: border-box; + width: max-content; + min-width: 100%; + margin: 0; + padding: 6px 8px 8px; + white-space: pre; +} + +.expandedTopLevel { + box-sizing: border-box; + width: max-content; + min-width: 100%; + padding: 6px 8px 8px 14px; +} + +.expandedTopLevel:has(> .topLevelBracket[data-json-root-row]:hover), +.expandedTopLevel:has(> .topLevelBracket[data-json-root-row][data-json-copy-active]) { + background: var(--json-tree-hover); +} + +.expandedTopLevelContainer { + padding: 0 0 0 calc(2ch - 12px); +} + +.row.topLevelBracket { + margin-left: 0; + padding-left: 0; +} + +.children { + margin: 0; + padding: 0 0 0 4px; + list-style: none; +} + +.row { + position: relative; + box-sizing: border-box; + min-width: 100%; + min-height: 16px; + margin: 0; + padding: 0 0 0 12px; + list-style: none; +} + +.row:not(.topLevelBracket):hover:not(:has(.row:hover))::after, +.row:not(.topLevelBracket)[data-json-copy-active]::after, +.row:has(> .expander:focus-visible)::after { + position: absolute; + z-index: 0; + top: 0; + right: -100vw; + left: -100vw; + height: 16px; + background: var(--json-tree-hover); + content: ''; + pointer-events: none; +} + +.row > span:not(.expander) { + position: relative; + z-index: 1; +} + +.label { + margin-right: 3px; + color: var(--json-tree-property); + font-weight: 400; +} + +.clickableLabel { + cursor: pointer; +} + +.stringValue { + color: var(--json-tree-string); +} + +.numberValue { + color: var(--json-tree-number); +} + +.keywordValue { + color: var(--json-tree-keyword); +} + +.otherValue { + color: var(--dsw-alias-label-secondary); +} + +.punctuation { + color: var(--json-tree-punctuation); +} + +.preview { + color: var(--json-tree-punctuation); +} + +.previewProperty { + color: var(--json-tree-punctuation); +} + +.previewEllipsis { + color: var(--dsw-alias-label-tertiary); +} + +.copyAnchor { + position: absolute; + z-index: 3; + display: inline-flex; +} + +.copyButton { + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 16px; + margin: 0; + padding: 0; + border: 0; + border-radius: 3px; + color: var(--dsw-alias-label-secondary); + background: var(--dsw-alias-bg-layer-1); + box-shadow: -5px 0 5px var(--dsw-alias-bg-layer-1); + cursor: pointer; +} + +.copyButton:hover { + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-interactive-bg-hover); +} + +.copyButton:focus-visible { + outline: 1px solid var(--dsw-alias-state-business-primary); + outline-offset: -1px; +} + +.copyButton[data-state='failed'] { + color: var(--dsw-alias-state-error-primary); +} + +.expander { + position: absolute; + z-index: 2; + top: 0; + left: 0; + display: inline-flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + width: 8px; + height: 16px; + margin: 0; + color: var(--json-tree-icon); + cursor: pointer; + user-select: none; +} + +.expander::before { + width: 0; + height: 0; + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; + border-left: 6px solid currentColor; + content: ''; + transform: scale(0.75); + transform-origin: center; +} + +.collapseIcon::before { + transform: rotate(90deg) scale(0.75); +} + +.expander:hover { + color: var(--dsw-alias-label-primary); +} + +.expander:focus-visible { + outline: none; +} + +.collapsedContent { + margin: 0 1px; + color: var(--json-tree-punctuation); + cursor: pointer; +} + +.collapsedContent::after { + content: '…'; +} diff --git a/packages/client/ui-primitives/src/JsonTree.tsx b/packages/client/ui-primitives/src/JsonTree.tsx new file mode 100644 index 0000000000..324c7abf1a --- /dev/null +++ b/packages/client/ui-primitives/src/JsonTree.tsx @@ -0,0 +1,390 @@ +import clsx from 'clsx' +import { collapseAllNested, JsonView } from 'react-json-view-lite' +import { useEffect, useRef, useState } from 'react' +import type { MouseEvent as ReactMouseEvent, ReactNode, UIEvent as ReactUIEvent } from 'react' +import type { Props as LiteJsonViewProps } from 'react-json-view-lite' +import { IconCheckOutline16, IconCopyOutline16 } from './icons/index.tsx' +import { Menu } from './Menu.tsx' +import type { MenuEntry } from './Menu.tsx' +import css from './JsonTree.module.css' + +const OBJECT_PREVIEW_LIMIT = 4 +const ARRAY_PREVIEW_LIMIT = 5 +const PREVIEW_DEPTH_LIMIT = 2 +const VALUE_COPY_MENU_ITEMS: readonly MenuEntry[] = [ + { id: 'value', label: 'Copy value' }, + { id: 'json', label: 'Copy JSON' }, + { id: 'path', label: 'Copy property path' }, +] +const OBJECT_COPY_MENU_ITEMS: readonly MenuEntry[] = [ + { id: 'prettyJson', label: 'Copy pretty JSON' }, + { id: 'json', label: 'Copy compact JSON' }, + { id: 'path', label: 'Copy property path' }, +] + +const TREE_STYLES: NonNullable = { + container: css.container!, + childFieldsContainer: css.children!, + basicChildStyle: css.row!, + label: css.label!, + clickableLabel: `${css.label!} ${css.clickableLabel!}`, + nullValue: css.keywordValue!, + undefinedValue: css.keywordValue!, + numberValue: css.numberValue!, + stringValue: css.stringValue!, + booleanValue: css.keywordValue!, + otherValue: css.otherValue!, + punctuation: css.punctuation!, + expandIcon: `${css.expander!} ${css.expandIcon!}`, + collapseIcon: `${css.expander!} ${css.collapseIcon!}`, + collapsedContent: css.collapsedContent!, + noQuotesForStringValues: false, + quotesForFieldNames: false, + stringifyStringValues: true, + ariaLables: { + collapseJson: 'Collapse JSON node', + expandJson: 'Expand JSON node', + }, +} + +const EXPANDED_TOP_LEVEL_TREE_STYLES: NonNullable = { + ...TREE_STYLES, + container: `${css.container!} ${css.expandedTopLevelContainer!}`, +} + +function previewPrimitive(value: unknown): ReactNode { + if (value === null) return null + if (typeof value === 'string') { + return {JSON.stringify(value)} + } + if (typeof value === 'number') { + return {String(value)} + } + if (typeof value === 'boolean') { + return {String(value)} + } + return {String(value)} +} + +function previewValue(value: unknown, depth: number): ReactNode { + if (typeof value !== 'object' || value === null) return previewPrimitive(value) + + const array = Array.isArray(value) + const entries = array + ? value.map((item, index) => [String(index), item] as const) + : Object.entries(value) + const limit = array ? ARRAY_PREVIEW_LIMIT : OBJECT_PREVIEW_LIMIT + const visible = entries.slice(0, limit) + const open = array ? '[' : '{' + const close = array ? ']' : '}' + + return ( + <> + {open} + {depth >= PREVIEW_DEPTH_LIMIT + ? + : visible.map(([key, item], index) => ( + + {index > 0 && , } + {!array && ( + <> + {key} + : + + )} + {previewValue(item, depth + 1)} + + ))} + {depth < PREVIEW_DEPTH_LIMIT && entries.length > limit && ( + {visible.length > 0 ? ', …' : '…'} + )} + {close} + + ) +} + +function renderExpandableValue(value: object): ReactNode { + return {previewValue(value, 0)} +} + +interface CopyTarget { + left: number + path: readonly (number | string)[] + side: 'bottom' | 'top' + top: number + value: unknown +} + +function fieldOf(row: HTMLElement): string | undefined { + const label = Array.from(row.children).find( + child => child instanceof HTMLElement && child.classList.contains(css.label!), + ) + const text = label?.textContent + return text === undefined || text === null ? undefined : text.slice(0, -1) +} + +function resolveRow(data: object | unknown[], row: HTMLElement, expandTopLevel: boolean): { + path: readonly (number | string)[] + value: unknown +} | undefined { + if (row.hasAttribute('data-json-root-row')) return { path: [], value: data } + + const lineage: HTMLElement[] = [] + let cursor: HTMLElement | null = row + while (cursor !== null) { + lineage.unshift(cursor) + const group: HTMLElement | null = cursor.parentElement + const parentRow: Element | null = group?.getAttribute('role') === 'group' + ? group.parentElement?.closest('[role="treeitem"]') ?? null + : null + cursor = parentRow instanceof HTMLElement ? parentRow : null + } + + let value: unknown = data + const path: (number | string)[] = [] + for (const item of expandTopLevel ? lineage : lineage.slice(1)) { + const field = fieldOf(item) + if (field === undefined) return undefined + if (Array.isArray(value)) { + const index = Number(field) + if (!Number.isInteger(index)) return undefined + path.push(index) + value = value[index] + } else if (typeof value === 'object' && value !== null) { + path.push(field) + value = (value as Record)[field] + } else { + return undefined + } + } + return { path, value } +} + +function formattedPath(path: readonly (number | string)[]): string { + return path.reduce((result, part) => { + if (typeof part === 'number') return `${result}[${String(part)}]` + return /^[A-Za-z_$][\w$]*$/.test(part) + ? `${result}.${part}` + : `${result}[${JSON.stringify(part)}]` + }, '$') +} + +function copyText(target: CopyTarget, mode: 'json' | 'path' | 'prettyJson' | 'value'): string { + if (mode === 'path') return formattedPath(target.path) + if (mode === 'prettyJson') return JSON.stringify(target.value, null, 2) + if (mode === 'json') return JSON.stringify(target.value) ?? String(target.value) + if (typeof target.value === 'string') return target.value + if (typeof target.value === 'object' && target.value !== null) { + return JSON.stringify(target.value, null, 2) + } + return JSON.stringify(target.value) ?? String(target.value) +} + +/** Props for the read-only, token-themed JSON tree. */ +export interface JsonTreeProps { + /** Parsed JSON object or array. */ + data: object | unknown[] + /** Accessible label for the tree. */ + label?: string + /** Optional positioning class owned by the caller. */ + className?: string | undefined + /** Whether JSON rows expose copy actions. */ + copyable?: boolean + /** Whether the top-level object or array is always expanded. */ + expandTopLevel?: boolean +} + +/** + * Render parsed JSON as a compact, keyboard-accessible inspector tree. + * @param props - Parsed data, accessible label, and display options. + * @returns A read-only JSON tree with an optionally fixed-open top level. + */ +export function JsonTree({ + data, + label = 'JSON', + className, + copyable = true, + expandTopLevel = true, +}: JsonTreeProps) { + const rootRef = useRef(null) + const activeRowRef = useRef() + const copyButtonRef = useRef(null) + const copyMenuOpenRef = useRef(false) + const resetTimer = useRef>() + const [copyTarget, setCopyTarget] = useState() + const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle') + const [copyMenuOpen, setCopyMenuOpen] = useState(false) + + useEffect(() => () => { + if (resetTimer.current !== undefined) clearTimeout(resetTimer.current) + activeRowRef.current?.removeAttribute('data-json-copy-active') + }, []) + + const setActiveRow = (row: HTMLElement | undefined) => { + activeRowRef.current?.removeAttribute('data-json-copy-active') + activeRowRef.current = row + row?.setAttribute('data-json-copy-active', '') + } + + const positionCopyButton = (row: HTMLElement, target: { + path: readonly (number | string)[] + value: unknown + }) => { + const root = rootRef.current + if (root === null) return + const rootRect = root.getBoundingClientRect() + const rowRect = row.getBoundingClientRect() + setCopyTarget({ + left: root.scrollLeft + root.clientWidth - 26, + path: target.path, + side: rowRect.top - rootRect.top > root.clientHeight / 2 ? 'top' : 'bottom', + top: root.scrollTop + rowRect.top - rootRect.top, + value: target.value, + }) + } + + const clearCopyTarget = () => { + setActiveRow(undefined) + setCopyTarget(undefined) + setCopyState('idle') + copyMenuOpenRef.current = false + setCopyMenuOpen(false) + } + + const handleMouseOver = (event: ReactMouseEvent) => { + if (!copyable || !(event.target instanceof Element)) return + if (copyMenuOpenRef.current) return + if (!event.currentTarget.contains(event.target)) return + if (event.target.closest('[data-json-copy-button]') !== null) return + const row = event.target.closest('[data-json-root-row], [role="treeitem"]') + if (row === null) { + clearCopyTarget() + return + } + if (activeRowRef.current === row) return + const resolved = resolveRow(data, row, expandTopLevel) + if (resolved === undefined) return + setActiveRow(row) + setCopyState('idle') + copyMenuOpenRef.current = false + setCopyMenuOpen(false) + positionCopyButton(row, resolved) + } + + const handleScroll = (event: ReactUIEvent) => { + if (event.currentTarget !== event.target) return + const row = activeRowRef.current + if (row === undefined) return + const resolved = resolveRow(data, row, expandTopLevel) + if (resolved !== undefined) positionCopyButton(row, resolved) + } + + const copy = async (mode: 'json' | 'path' | 'prettyJson' | 'value') => { + if (copyTarget === undefined) return + try { + await navigator.clipboard.writeText(copyText(copyTarget, mode)) + setCopyState('copied') + } catch { + setCopyState('failed') + } + if (resetTimer.current !== undefined) clearTimeout(resetTimer.current) + resetTimer.current = setTimeout(() => setCopyState('idle'), 1_500) + } + + const copyTargetIsObject = typeof copyTarget?.value === 'object' && copyTarget.value !== null + const defaultCopyMode = copyTargetIsObject ? 'prettyJson' : 'value' + const copyTitle = copyState === 'copied' + ? 'Copied' + : copyState === 'failed' + ? 'Copy failed' + : copyTargetIsObject ? 'Copy pretty JSON' : 'Copy value' + + return ( +
{ + if (!copyMenuOpenRef.current) clearCopyTarget() + }} + onScroll={handleScroll} + > + {expandTopLevel + ? ( +
+
+ {Array.isArray(data) ? '[' : '{'} +
+ +
+ {Array.isArray(data) ? ']' : '}'} +
+
+ ) + : ( + + )} + {copyTarget !== undefined && ( + + void copy(defaultCopyMode)} + onContextMenu={(event) => { + event.preventDefault() + event.stopPropagation() + copyMenuOpenRef.current = true + setCopyMenuOpen(true) + }} + > + {copyState === 'copied' + ? + : } + + )} + items={copyTargetIsObject ? OBJECT_COPY_MENU_ITEMS : VALUE_COPY_MENU_ITEMS} + onSelect={(id) => { + if (id === 'value' || id === 'json' || id === 'prettyJson' || id === 'path') { + void copy(id) + } + copyMenuOpenRef.current = false + setCopyMenuOpen(false) + }} + onClose={clearCopyTarget} + getAnchorRect={() => copyButtonRef.current?.getBoundingClientRect() ?? null} + /> + + )} +
+ ) +} diff --git a/packages/client/ui-primitives/src/Menu.module.css b/packages/client/ui-primitives/src/Menu.module.css index 6825731275..e6a59ea84a 100644 --- a/packages/client/ui-primitives/src/Menu.module.css +++ b/packages/client/ui-primitives/src/Menu.module.css @@ -77,6 +77,37 @@ background: var(--dsw-alias-interactive-bg-hover); } +.list.compactList, +.submenu.compactList { + min-width: 164px; + padding: 2px; + border-radius: 7px; +} + +.compactList .item { + min-height: 26px; + gap: 6px; + padding: 3px 7px; + border-radius: 5px; + font-size: 12px; + line-height: 18px; +} + +.compactList .itemIcon { + width: 14px; + height: 14px; +} + +.compactList .separator { + margin: 2px; +} + +.compactList .label { + padding: 4px 7px; + font-size: 11px; + line-height: 16px; +} + .item:disabled { color: var(--dsw-alias-label-dimmed); cursor: not-allowed; diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index 9abb6a3bb2..e99722875b 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -66,6 +66,7 @@ function isLabel(entry: MenuEntry): entry is MenuLabel { * keeps the pure-CSS in-place behavior. * @param props.closeOnPointerLeave - close the list when the pointer leaves * it (default false keeps it open until outside click/Escape/selection). + * @param props.compact - use reduced menu typography and spacing. * @param props.getAnchorRect - portal mode only: supply the anchor rect * directly (e.g. from a host-owned trigger button) instead of measuring the * Menu's own wrapper span. Required when the wrapper isn't itself laid out at @@ -74,7 +75,7 @@ function isLabel(entry: MenuEntry): entry is MenuLabel { * scroll/resize; return null to skip placement for that frame. * @returns anchor wrapper with the conditional list. */ -export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, getAnchorRect, className }: { +export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, compact = false, getAnchorRect, className }: { open: boolean anchor: ReactNode items: readonly MenuEntry[] @@ -85,6 +86,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align side?: 'bottom' | 'top' portal?: boolean closeOnPointerLeave?: boolean + compact?: boolean getAnchorRect?: () => DOMRect | null className?: string }) { @@ -149,7 +151,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align const list = open && (!portal || fixedPos !== null) && (
{ onClose() } : undefined} @@ -196,7 +198,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align {entry.id === selectedId && } {subOpen && entry.submenu !== undefined && ( -
+
{entry.submenu.map(sub => ( + ) + : ( +
+ + {`Block #${index + 1} ${block.type}`} + +
+ )} + {block.imageSrc !== undefined + ? + :
{block.content}
} + + ))} +
+ ) +} + +function PanelImage({ + block, + preview = false, +}: { + block: TrajectorySourceBlock + preview?: boolean +}) { + if (block.imageSrc === undefined) return null + return ( + + {block.imageAlt + + ) +} + +function MessageImages({ + blocks, + preview, +}: { + blocks: readonly TrajectorySourceBlock[] | undefined + preview: boolean +}) { + const images = blocks?.filter(block => block.imageSrc !== undefined) ?? [] + if (images.length === 0) return null + return ( +
+ {images.map((block, index) => )} +
+ ) +} + +function AssistantToolCalls({ + blocks, + preview, + onOpenCall, +}: { + blocks: readonly TrajectorySourceBlock[] | undefined + preview: boolean + onOpenCall(callId: string): void +}) { + const calls = blocks?.filter(block => block.type === 'tool-call') ?? [] + if (calls.length === 0) return null + return ( +
    + {calls.map((call, index) => ( +
  • + +
  • + ))} +
+ ) +} + +function ToolOutputBlocks({ + blocks, + preview, +}: { + blocks: readonly TrajectorySourceBlock[] + preview: boolean +}) { + return ( +
+ {blocks.map((block, index) => ( + block.imageSrc !== undefined + ? + : block.content !== '' + ?
{block.content}
+ : null + ))} +
+ ) +} + +function MarkdownRecordContent({ + record, + rendered, + preview = false, + thinkingExpanded, + onThinkingExpandedChange, + onOpenCall, +}: { + record: TableRecord + rendered: boolean + preview?: boolean + thinkingExpanded: boolean + onThinkingExpandedChange(expanded: boolean): void + onOpenCall(callId: string): void +}) { + if (!rendered && record.cell.sourceBlocks && record.cell.sourceBlocks.length > 0) { + return + } + if (record.cell.kind === 'message' && record.cell.thinkingDetail) { + if (!rendered) { + const source = [ + record.cell.thinkingDetail, + record.cell.outputDetail, + ].filter((value): value is string => value !== undefined && value !== '').join('\n\n') + return + } + return ( +
+
+ + {thinkingExpanded && ( + + )} +
+ {record.cell.outputDetail && ( +
+ +
+ )} + + +
+ ) + } + const source = markdownSource(record) + const hasImages = record.cell.sourceBlocks?.some(block => block.imageSrc !== undefined) === true + const hasToolCalls = record.cell.kind === 'message' + && record.cell.sourceBlocks?.some(block => block.type === 'tool-call') === true + if (!source && !hasImages && !hasToolCalls) { + const emptyLabel = isToolCallOnly(record.cell) + ? 'Tool call only' + : record.cell.text || 'No content' + return

{emptyLabel}

+ } + if (!rendered || (!hasImages && !hasToolCalls)) { + return + } + return ( +
+ {source && } + {record.cell.kind === 'message' && ( + + )} + +
+ ) +} + +function RecordTiming({ record }: { record: TableRecord }) { + return record.cell.kind === 'message' && record.cell.assistantMetrics !== undefined + ? + : ( +
+
Started
+
Duration
{formatElapsedSeconds(record.cell.timeSeconds)}
+
Timing source
{record.cell.timeSeconds === null ? 'Not available' : 'Session timestamps'}
+
+ ) +} + +function RecordPayload({ + record, + direction, + preview = false, +}: { + record: TableRecord + direction: 'input' | 'output' + preview?: boolean +}) { + const value = direction === 'input' ? record.cell.inputDetail : record.cell.outputDetail + const missing = direction === 'input' + ? 'No payload captured' + : 'No result captured' + if (!value) return

{missing}

+ + if (direction === 'output' && record.cell.outputBlocks && record.cell.outputBlocks.length > 0) { + return ( + + ) + } + + const markdown = ( + direction === 'input' && record.cell.kind === 'user' + ) || ( + direction === 'output' && record.cell.kind === 'message' + ) + if (markdown) { + return ( +
+ +
+ ) + } + const json = parseJsonContainer(value) + if (json !== undefined) { + return ( + + ) + } + return ( +
 value !== undefined).join(' ')}
+    >
+      {value}
+    
+ ) +} + +function RecordSchema({ + record, + preview = false, +}: { + record: TableRecord + preview?: boolean +}) { + if (!record.cell.schemaDetail) { + return

Schema unavailable

+ } + const schema = parseToolSchema(record.cell.schemaDetail) + if (schema !== undefined) { + return ( +
+
+

{schema.name}

+

{schema.description}

+
+
+

Parameters

+ +
+
+ ) + } + return ( +
+      {record.cell.schemaDetail}
+    
+ ) +} + +interface ParsedToolSchema { + name: string + description: string + parameters: object +} + +function parseToolSchema(value: string): ParsedToolSchema | undefined { + try { + const parsed: unknown = JSON.parse(value) + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined + const schema = parsed as Record + if ( + typeof schema.name !== 'string' + || typeof schema.description !== 'string' + || typeof schema.parameters !== 'object' + || schema.parameters === null + || Array.isArray(schema.parameters) + ) return undefined + return { + name: schema.name, + description: schema.description, + parameters: schema.parameters, + } + } catch { + return undefined + } +} + +function parseJsonContainer(value: string): object | undefined { + try { + const parsed: unknown = JSON.parse(value) + return typeof parsed === 'object' && parsed !== null ? parsed : undefined + } catch { + return undefined + } +} + +function OverviewSection({ + label, + onOpen, + children, +}: { + label: string + onOpen(): void + children: ReactNode +}) { + return ( +
+

+ +

+
{children}
+
+ ) +} + +/** + * Render trajectory events as a dense ledger with turn and step separators. + * @param props - Grouped trajectory data and whole-ledger fold state. + * @returns The ledger and an optional local record inspector. + */ +export function TrajectoryTable({ + turns, + collapsedTurns, + onToggleTurn, + collapsedAssistants, + onToggleAssistant, +}: TrajectoryTableProps) { + const [selectedIndex, setSelectedIndex] = useState(null) + const [activeTab, setActiveTab] = useState('overview') + const [thinkingExpanded, setThinkingExpanded] = useState(true) + const [detailsWidth, setDetailsWidth] = useState(null) + const [toolRequestOffset, setToolRequestOffset] = useState(null) + const detailsResizeDrag = useRef(null) + const tabHistory = useRef>(new Set(['overview'])) + const allRecords = flattenRecords(turns) + const turnRecords = collapseTurnRecords(allRecords, collapsedTurns) + const records = collapseAssistantRecords(turnRecords, collapsedAssistants) + const selected = allRecords.find(record => record.cell.index === selectedIndex) + const selectedState = selected === undefined ? undefined : stateOf(selected) + const selectedTabs = selected === undefined ? [] : detailTabs(selected) + const selectedParents: ParentRecords = selected === undefined + ? {} + : parentRecords(allRecords, selected) + const hasSelectedParents = selectedParents.message !== undefined + || selectedParents.tool !== undefined + const splitStyle: TrajectorySplitStyle | undefined = toolRequestOffset === null + ? undefined + : { + '--trajectory-tool-request-width': `calc(58cqw - ${toolRequestOffset}px)`, + } + + const activateTab = (tab: DetailTab) => { + tabHistory.current.delete(tab) + tabHistory.current.add(tab) + setActiveTab(tab) + } + + const selectRecord = (index: number) => { + const record = allRecords.find(candidate => candidate.cell.index === index) + setSelectedIndex(index) + if (record === undefined) return + const available = new Set(detailTabs(record).map(tab => tab.id)) + const recent = [...tabHistory.current].reverse().find(tab => available.has(tab)) + setActiveTab(recent ?? 'overview') + } + + const openRecordSummary = (target: TableRecord) => { + const targetAt = allRecords.findIndex(record => record.cell.index === target.cell.index) + if (collapsedTurns.has(target.turn)) onToggleTurn(target.turn) + if (target.cell.kind === 'tool' || target.cell.kind === 'subtool') { + for (let i = targetAt - 1; i >= 0; i--) { + const candidate = allRecords[i] + if (candidate === undefined || candidate.turn !== target.turn) break + if (candidate.cell.kind !== 'message') continue + if (collapsedAssistants.has(candidate.cell.index)) onToggleAssistant(candidate.cell.index) + break + } + } + setSelectedIndex(target.cell.index) + activateTab('overview') + } + + const openCallSummary = (callId: string) => { + const target = allRecords.find(record => record.cell.callId === callId) + if (target !== undefined) openRecordSummary(target) + } + + return ( +
+
+ + + + + + + {records.map((record) => { + const displayText = recordDisplayText(record.cell) + const isCollapsedSummary = record.collapsedSummary !== undefined + return ( + { + if (record.collapsedSummaryKind === 'turn') onToggleTurn(record.turn) + else onToggleAssistant(record.cell.index) + } + : () => { selectRecord(record.cell.index) }} + onDoubleClick={(event) => { + if (isCollapsedSummary) return + if (collapsedTurns.has(record.turn)) { + event.preventDefault() + onToggleTurn(record.turn) + return + } + if ( + record.cell.kind === 'message' + && assistantToolCalls(allRecords, record.cell.index).length > 0 + ) { + event.preventDefault() + onToggleAssistant(record.cell.index) + return + } + if (!record.turnStart) return + if (allRecords.filter(candidate => candidate.turn === record.turn).length <= 1) return + event.preventDefault() + onToggleTurn(record.turn) + }} + onKeyDown={(event) => { + if (event.key !== 'Enter' && event.key !== ' ') return + event.preventDefault() + if (isCollapsedSummary) { + if (record.collapsedSummaryKind === 'turn') onToggleTurn(record.turn) + else onToggleAssistant(record.cell.index) + return + } + selectRecord(record.cell.index) + }} + > + + + + ) + })} + +
+ {selected?.turn === record.turn && ( + + {record.collapsedSummary !== undefined + ? ( + + + {record.collapsedSummary} + + ) + : ( + + + {isToolCallOnly(record.cell) ? null : displayText || '—'} + + {record.cell.result !== undefined && ( + + + {record.cell.result} + + )} + + )} +
+
+ {selected !== undefined && selectedState !== undefined && ( + + )} +
+ ) +} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryToolbar.module.css b/packages/client/ui-trajectory/src/client/TrajectoryToolbar.module.css new file mode 100644 index 0000000000..d5d81521c2 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/TrajectoryToolbar.module.css @@ -0,0 +1,85 @@ +.root { + position: sticky; + top: 0; + z-index: 4; + box-sizing: border-box; + width: 100%; + height: var(--dsh-trajectory-toolbar-height); + border-bottom: 1px solid var(--dsw-alias-border-l2); + background: var(--dsw-alias-bg-layer-1); +} + +.inner { + display: flex; + align-items: center; + justify-content: space-between; + box-sizing: border-box; + width: 100%; + height: 100%; + padding: 0 14px 0 16px; +} + +.summary { + display: flex; + align-items: center; + min-width: 0; + gap: 10px; +} + +.title { + flex: none; + color: var(--dsw-alias-label-primary); + font: var(--dsw-font-xs-strong-13); +} + +.actions { + display: flex; + flex: none; + align-items: center; + gap: 2px; +} + +.action { + display: inline-flex; + flex: none; + align-items: center; + box-sizing: border-box; + height: 26px; + padding: 0 7px; + gap: 6px; + border: 0; + border-radius: 4px; + color: var(--dsw-alias-label-tertiary); + background: transparent; + cursor: pointer; + font: var(--dsw-font-xs-13); + transition: + color 120ms var(--ds-ease-in-out), + background-color 120ms var(--ds-ease-in-out); +} + +.action:hover:not(:disabled) { + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-interactive-bg-hover); +} + +.action:focus-visible { + outline: 1px solid var(--dsw-alias-state-business-primary); + outline-offset: 2px; +} + +.action:disabled { + color: var(--dsw-alias-label-dimmed); + cursor: not-allowed; +} + +.actionIcon { + color: var(--dsw-alias-label-tertiary); + font: 13px/13px var(--ds-font-family-code); +} + +@media (max-width: 720px) { + .summary { + gap: 7px; + } +} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx b/packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx new file mode 100644 index 0000000000..f063377a00 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx @@ -0,0 +1,66 @@ +/** Trajectory toolbar: view identity, record totals, and the ledger fold control. */ + +import css from './TrajectoryToolbar.module.css' + +export interface TrajectoryToolbarProps { + /** Number of turns containing more than one row. */ + collapsibleTurns: number + /** Whether every collapsible turn is currently folded. */ + allTurnsCollapsed: boolean + /** Fold or expand every collapsible turn. */ + onToggleAllTurns(): void + /** Number of assistant messages followed by tool calls. */ + collapsibleAssistants: number + /** Whether every collapsible assistant's tool calls are currently folded. */ + allAssistantsCollapsed: boolean + /** Fold or expand tool calls under every collapsible assistant. */ + onToggleAllAssistants(): void +} + +/** + * Render the sticky trajectory toolbar. + * @param props - rendered counts and whole-list fold state. + * @returns the toolbar element. + */ +export function TrajectoryToolbar({ + collapsibleTurns, + allTurnsCollapsed, + onToggleAllTurns, + collapsibleAssistants, + allAssistantsCollapsed, + onToggleAllAssistants, +}: TrajectoryToolbarProps) { + return ( +
+
+
+ Trajectory +
+
+ + +
+
+
+ ) +} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 3d417b085e..ea1bb8a1e7 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -1,41 +1,110 @@ -// TrajectoryView: sticky Turn sections with Message/Step groups and step cells. +/** Trajectory view: compact summary over a turn-aware event ledger. */ -import { useMemo } from 'react' +import { useMemo, useState } from 'react' import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client' -import { TrajectoryCell } from './TrajectoryCell.tsx' -import { TrajectoryGroupHeader } from './TrajectoryGroupHeader.tsx' -import { TrajectoryTurn } from './TrajectoryTurn.tsx' +import { TrajectoryTable } from './TrajectoryTable.tsx' +import { TrajectoryToolbar } from './TrajectoryToolbar.tsx' import { deriveTrajectoryLayout } from './layout.ts' import css from './views.module.css' export function TrajectoryView({ useSession }: ConvViewProps) { + const [collapsedTurns, setCollapsedTurns] = useState>(() => new Set()) + const [collapsedAssistants, setCollapsedAssistants] = useState>(() => new Set()) const nodes = useSession((s) => s.nodes) const partial = useSession((s) => s.partial) const runningCalls = useSession((s) => s.runningCalls) + const callSchemas = useSession((s) => s.callSchemas) const codeDispatches = useSession((s) => s.codeDispatches) const turns = useMemo( - () => deriveTrajectoryLayout({ nodes, partial, runningCalls, codeDispatches }), - [nodes, partial, runningCalls, codeDispatches], + () => deriveTrajectoryLayout({ nodes, partial, runningCalls, callSchemas, codeDispatches }), + [nodes, partial, runningCalls, callSchemas, codeDispatches], ) - if (turns.length === 0) { - return

暂无轨迹数据

+ const collapsibleTurnIds = useMemo( + () => turns + .filter(turn => turn.groups.reduce((count, group) => count + group.cells.length, 0) > 1) + .map(turn => turn.turn), + [turns], + ) + const allTurnsCollapsed = collapsibleTurnIds.length > 0 + && collapsibleTurnIds.every(turn => collapsedTurns.has(turn)) + const collapsibleAssistantIds = useMemo(() => { + const ids: number[] = [] + for (const turn of turns) { + const cells = turn.groups.flatMap(group => group.cells) + for (let i = 0; i < cells.length; i++) { + const cell = cells[i] + if (cell?.kind !== 'message') continue + const next = cells[i + 1] + if (next?.kind === 'tool' || next?.kind === 'subtool') ids.push(cell.index) + } + } + return ids + }, [turns]) + const allAssistantsCollapsed = collapsibleAssistantIds.length > 0 + && collapsibleAssistantIds.every(index => collapsedAssistants.has(index)) + + const toggleTurn = (turn: number) => { + setCollapsedTurns((current) => { + const next = new Set(current) + if (next.has(turn)) next.delete(turn) + else next.add(turn) + return next + }) } + + const toggleAllTurns = () => { + setCollapsedTurns((current) => { + const next = new Set(current) + if (allTurnsCollapsed) { + for (const turn of collapsibleTurnIds) next.delete(turn) + } else { + for (const turn of collapsibleTurnIds) next.add(turn) + } + return next + }) + } + + const toggleAssistant = (index: number) => { + setCollapsedAssistants((current) => { + const next = new Set(current) + if (next.has(index)) next.delete(index) + else next.add(index) + return next + }) + } + + const toggleAllAssistants = () => { + setCollapsedAssistants((current) => { + const next = new Set(current) + if (allAssistantsCollapsed) { + for (const index of collapsibleAssistantIds) next.delete(index) + } else { + for (const index of collapsibleAssistantIds) next.add(index) + } + return next + }) + } + return (
- {turns.map((turn) => ( - - {turn.groups.flatMap((group) => [ - , - ...group.cells.map((cell) => ( - - )), - ])} - - ))} + + {turns.length === 0 &&

No trajectory events

} + {turns.length > 0 && ( + + )}
) } diff --git a/packages/client/ui-trajectory/src/client/WaterfallView.tsx b/packages/client/ui-trajectory/src/client/WaterfallView.tsx index ad81845fb9..6776680882 100644 --- a/packages/client/ui-trajectory/src/client/WaterfallView.tsx +++ b/packages/client/ui-trajectory/src/client/WaterfallView.tsx @@ -28,7 +28,7 @@ export function WaterfallView({ useSession, pxPerNode }: ConvViewProps & Waterfa const codeDispatches = useSession((s) => s.codeDispatches) const spans = useMemo(() => deriveSpans(nodes), [nodes]) const subSpans = useMemo(() => deriveSubSpans(nodes, codeDispatches), [nodes, codeDispatches]) - if (spans.length === 0) return

暂无瀑布数据

+ if (spans.length === 0) return

No timing data

return ( <> diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts index 37c86f6eb4..54d025ab8f 100644 --- a/packages/client/ui-trajectory/src/client/layout.ts +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -3,12 +3,16 @@ * own-duration times, in-flight partial/runningCalls, and group descriptions. */ import type { + AssistantBlock, AssistantMessageNode, CodeSubCall, ConversationSnapshot, ToolResultNode, } from '@deepseek-ai/dsh-client-runtime/client' -import type { TrajectoryCellProps } from './TrajectoryCell.tsx' +import type { + TrajectoryCellProps, + TrajectorySourceBlock, +} from './trajectory-record.ts' /** One Message or Step group inside a turn. */ export interface TrajectoryGroupModel { @@ -28,6 +32,7 @@ export interface TrajectoryLayoutInput { nodes: ConversationSnapshot['nodes'] partial: ConversationSnapshot['partial'] runningCalls: ConversationSnapshot['runningCalls'] + callSchemas?: ConversationSnapshot['callSchemas'] /** run_code sub-dispatches by parent callId (sub-cells nest under the parent Tool cell). */ codeDispatches: ConversationSnapshot['codeDispatches'] } @@ -52,8 +57,17 @@ interface LaidCell { * @returns turns ordered by first appearance. */ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly TrajectoryTurnModel[] { - const { nodes, partial, runningCalls, codeDispatches } = input + const { nodes, partial, runningCalls, callSchemas, codeDispatches } = input const resultByCall = indexResults(nodes) + const callStartById = new Map() + for (const result of resultByCall.values()) { + const startedAt = finiteTime(result.callTime) + if (startedAt !== null) callStartById.set(result.callId, startedAt) + } + for (const call of runningCalls) { + const startedAt = finiteTime(call.time) + if (startedAt !== null) callStartById.set(call.callId, startedAt) + } const turns = new Map }>() let index = 0 let prevAbsTime: number | null = null @@ -92,14 +106,21 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T absTime: finiteTime(node.time), cell: { index: ++index, kind: 'user', text: summarizeContent(node.content), + opensTurn: node.kind === 'user', + inputDetail: detailContent(node.content), + sourceBlocks: node.content.map(block => sourceBlock(block)), timeSeconds: 0, + startedAt: finiteTime(node.time), }, }) prevAbsTime = finiteTime(node.time) ?? prevAbsTime continue } if (node.kind === 'assistant') { - const laidList = withSubCalls(expandAssistant(node, index + 1, prevAbsTime, resultByCall), codeDispatches) + const laidList = withSubCalls( + expandAssistant(node, index + 1, prevAbsTime, resultByCall, callStartById), + codeDispatches, + ) for (const laid of laidList) { if (node.step > 0) pushStep(node.turn, node.step, laid) else pushMessage(node.turn, laid) @@ -128,7 +149,14 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T text: node.call !== null ? summarizeCall(node.call.name, node.call.argsRaw) : summarizeResult(node), + ...(node.call !== null ? { inputDetail: node.call.argsRaw } : {}), + outputDetail: detailResult(node), + outputBlocks: node.content.map(block => sourceBlock(block)), + result: summarizeResult(node), + callId: node.callId, + isError: node.isError, timeSeconds: durationSeconds(node.time, node.callTime), + startedAt: finiteTime(node.callTime), }, }) for (const laid of expandSubCalls(codeDispatches.get(node.callId), index)) { @@ -145,7 +173,14 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T kind: 'assistant', seq: Number.MAX_SAFE_INTEGER, time: 0, turn: partial.turn, step: partial.step, blocks: partial.blocks, } - const laidList = expandAssistant(fake, index + 1, prevAbsTime, resultByCall, { streaming: true }) + const laidList = expandAssistant( + fake, + index + 1, + prevAbsTime, + resultByCall, + callStartById, + { streaming: true }, + ) for (const laid of laidList) { if (partial.step > 0) pushStep(partial.turn, partial.step, laid) else pushMessage(partial.turn, laid) @@ -165,7 +200,10 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T index: ++index, kind: 'tool', text: summarizeCall(call.name, call.argsRaw), + inputDetail: call.argsRaw, + callId: call.callId, timeSeconds: null, + startedAt: finiteTime(call.time), }, }) for (const laid of expandSubCalls(codeDispatches.get(call.callId), index)) { @@ -191,11 +229,28 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T turns.set(1, first) } + for (const entry of turns.values()) { + for (const laid of entry.message) attachToolSchema(laid, callSchemas) + for (const laid of entry.steps.values()) { + for (const cell of laid) attachToolSchema(cell, callSchemas) + } + } + return [...turns.entries()] .sort(([a], [b]) => a - b) .map(([turn, entry]) => toTurnModel(turn, entry)) } +function attachToolSchema( + laid: LaidCell, + callSchemas: ConversationSnapshot['callSchemas'], +): void { + if (laid.callId === undefined || callSchemas === undefined) return + const schema = callSchemas.get(laid.callId) + if (schema === undefined) return + laid.cell.schemaDetail = JSON.stringify(schema, null, 2) +} + function toTurnModel( turn: number, entry: { message: LaidCell[]; steps: Map }, @@ -267,8 +322,8 @@ function durationSeconds(later: number, earlier: number | null): number | null { } /** Epoch-ms usable as an absolute time, else null. */ -function finiteTime(time: number): number | null { - return Number.isFinite(time) ? time : null +function finiteTime(time: number | null | undefined): number | null { + return typeof time === 'number' && Number.isFinite(time) ? time : null } function expandAssistant( @@ -276,6 +331,7 @@ function expandAssistant( startIndex: number, prevAbsTime: number | null, results: Map, + callStarts: ReadonlyMap, opts?: { streaming?: boolean }, ): LaidCell[] { const out: LaidCell[] = [] @@ -284,58 +340,155 @@ function expandAssistant( const streaming = opts?.streaming === true const messageDuration = streaming ? null : durationSeconds(node.time, prevAbsTime) const nodeAbs = streaming ? null : finiteTime(node.time) - let usageAttached = false + const messageText = node.blocks + .filter(block => block.kind === 'text' && (!streaming || block.text !== '')) + .map(block => block.kind === 'text' ? block.text : '') + .join('\n\n') + const thinkingText = node.blocks + .filter(block => block.kind === 'reasoning' && (!streaming || block.text !== '')) + .map(block => block.kind === 'reasoning' ? block.text : '') + .join('\n\n') + const message: TrajectoryCellProps = { + index: ++index, + kind: 'message', + text: messageText !== '' + ? summarizeText(messageText) + : thinkingText !== '' + ? summarizeText(thinkingText) + : summarizeAssistantActivity(node.blocks), + ...(messageText !== '' ? { outputDetail: messageText } : {}), + ...(thinkingText !== '' ? { thinkingDetail: thinkingText } : {}), + sourceBlocks: node.blocks.map(block => assistantSourceBlock(block)), + timeSeconds: messageDuration, + startedAt: finiteTime(node.timing?.stepStartTime), + } + attachUsage(message, usage) + message.assistantMetrics = { + timingRecorded: node.timing !== undefined, + stepStartTime: node.timing?.stepStartTime ?? null, + firstTokenTime: node.timing?.firstTokenTime ?? null, + completedTime: streaming ? null : finiteTime(node.time), + usageProvided: usage !== undefined, + outputTokens: Number.isFinite(usage?.outputTokens) ? usage?.outputTokens ?? null : null, + } + out.push({ absTime: nodeAbs, cell: message }) for (const block of node.blocks) { - // Reasoning blocks are skipped: no block-level clock, so no Think cell. - if (block.kind === 'reasoning') continue - if (block.kind === 'text') { - if (block.text === '' && streaming) continue - const cell: TrajectoryCellProps = { - index: ++index, kind: 'message', text: summarizeText(block.text), - timeSeconds: messageDuration, - } - if (!usageAttached) { - attachUsage(cell, usage) - usageAttached = usage !== undefined - } - out.push({ absTime: nodeAbs, cell }) - continue - } - if (block.kind === 'tool-call') { - const result = results.get(block.callId) - const toolDuration = streaming || result === undefined - ? null - : durationSeconds(result.time, result.callTime) - const callAbs = streaming - ? null - : (result?.callTime !== null && result?.callTime !== undefined && Number.isFinite(result.callTime) - ? result.callTime - : nodeAbs) - out.push({ - absTime: callAbs, - toolName: block.name, + // Text and reasoning belong to the one Assistant record emitted above. + if (block.kind !== 'tool-call') continue + const result = results.get(block.callId) + const toolDuration = streaming || result === undefined + ? null + : durationSeconds(result.time, result.callTime) + const callAbs = finiteTime(callStarts.get(block.callId)) + out.push({ + absTime: callAbs, + toolName: block.name, + callId: block.callId, + cell: { + index: ++index, kind: 'tool', + text: summarizeCall(block.name, block.argsRaw), + inputDetail: block.argsRaw, callId: block.callId, - cell: { - index: ++index, kind: 'tool', - text: summarizeCall(block.name, block.argsRaw), - timeSeconds: toolDuration, - }, - }) - } - } - - if (out.length === 0 && !streaming) { - // Reasoning-only / empty success still owns provider usage on the Message row. - const cell: TrajectoryCellProps = { - index: ++index, kind: 'message', text: '', timeSeconds: messageDuration, - } - attachUsage(cell, usage) - out.push({ absTime: nodeAbs, cell }) + ...(result !== undefined + ? { + outputDetail: detailResult(result), + outputBlocks: result.content.map(block => sourceBlock(block)), + result: summarizeResult(result), + isError: result.isError, + } + : {}), + timeSeconds: toolDuration, + startedAt: callAbs, + }, + }) } return out } +function summarizeAssistantActivity(blocks: readonly AssistantBlock[]): string { + const tools = new Map() + for (const block of blocks) { + if (block.kind !== 'tool-call') continue + tools.set(block.name, (tools.get(block.name) ?? 0) + 1) + } + if (tools.size > 0) { + return 'Tool call only' + } + return '' +} + +function assistantSourceBlock(block: AssistantBlock): TrajectorySourceBlock { + switch (block.kind) { + case 'text': return { type: 'text', content: block.text } + case 'reasoning': return { type: 'thinking', content: block.text } + case 'tool-call': return { + type: 'tool-call', + content: block.argsRaw, + callId: block.callId, + toolName: block.name, + } + case 'other': return sourceBlock(block.block) + } +} + +function sourceBlock(value: unknown): TrajectorySourceBlock { + if (typeof value !== 'object' || value === null) { + return { type: 'unknown', content: stringifySourceValue(value) } + } + const block = value as Record + const type = typeof block.type === 'string' ? block.type : 'unknown' + if (typeof block.text === 'string') { + return { type: type === 'reasoning' ? 'thinking' : type, content: block.text } + } + const imageSrc = sourceImage(block) + const imageAlt = typeof block.alt === 'string' ? block.alt : undefined + return { + type, + content: imageSrc === undefined ? stringifySourceValue(value) : '', + ...(imageSrc !== undefined ? { imageSrc } : {}), + ...(imageAlt !== undefined ? { imageAlt } : {}), + } +} + +function sourceImage(block: Record): string | undefined { + if (typeof block.type !== 'string' || !block.type.toLowerCase().includes('image')) return undefined + for (const candidate of [block.url, block.image_url]) { + if (typeof candidate === 'string') return safeImageSource(candidate) + } + if (typeof block.data === 'string') { + const mediaType = [block.mimeType, block.mediaType, block.media_type] + .find((candidate): candidate is string => typeof candidate === 'string') + ?? 'image/png' + return safeImageSource( + block.data.startsWith('data:') + ? block.data + : `data:${mediaType};base64,${block.data}`, + ) + } + if (typeof block.source !== 'object' || block.source === null) return undefined + const source = block.source as Record + if (typeof source.url === 'string') return safeImageSource(source.url) + if (typeof source.data !== 'string') return undefined + const mediaType = typeof source.media_type === 'string' ? source.media_type : 'image/png' + return safeImageSource(`data:${mediaType};base64,${source.data}`) +} + +function safeImageSource(value: string): string | undefined { + if (value.startsWith('data:image/') || value.startsWith('blob:')) return value + try { + const protocol = new URL(value).protocol + return protocol === 'http:' || protocol === 'https:' ? value : undefined + } catch { + return undefined + } +} + +function stringifySourceValue(value: unknown): string { + const json = JSON.stringify(value, null, 2) + return json ?? String(value) +} + /** * Turn that encloses a user/message: next assistant/steering turn, else the * in-flight partial, else the turn after the last finalized assistant (or 1). @@ -433,12 +586,27 @@ function expandSubCalls( cell: { index: ++index, kind: 'subtool', + callId: sub.callId, text: settled ? (sub.call !== null ? summarizeCall(sub.call.name, sub.call.argsRaw) : summarizeResult(sub)) : summarizeCall(sub.name, sub.argsRaw), + ...(settled + ? (sub.call !== null ? { inputDetail: sub.call.argsRaw } : {}) + : { inputDetail: sub.argsRaw }), + ...(settled + ? { + outputDetail: detailResult(sub), + outputBlocks: sub.content.map(block => sourceBlock(block)), + result: summarizeResult(sub), + isError: sub.isError, + } + : {}), // PR3's start/settle pair carries per-sub-call wall time; a running // (unsettled) or pre-pair log entry shows the em dash. timeSeconds: settled ? durationSeconds(sub.time, sub.callTime) : null, + startedAt: settled + ? finiteTime(sub.callTime) + : finiteTime(sub.time), }, }) } @@ -448,8 +616,7 @@ function expandSubCalls( function summarizeCall(name: string, argsRaw: string): string { const args = argsRaw.replace(/\s+/g, ' ').trim() if (args === '') return name - const clipped = args.length > 72 ? `${args.slice(0, 71)}…` : args - return `${name} · ${clipped}` + return `${name} · ${args}` } function summarizeResult(node: ToolResultNode): string { @@ -464,6 +631,27 @@ function summarizeResult(node: ToolResultNode): string { return node.call?.name ?? node.callId } +function detailResult(node: ToolResultNode): string { + if (node.isError) { + return node.error === undefined + ? 'error' + : `${node.error.name}: ${node.error.code}` + } + const text = node.content + .filter(block => block.type === 'text' && typeof block.text === 'string') + .map(block => block.type === 'text' ? block.text : '') + .join('\n') + if (text !== '') return text + return JSON.stringify(node.content, null, 2) +} + +function detailContent(content: readonly { type: string; text?: string }[]): string { + return content + .filter(block => block.type === 'text' && typeof block.text === 'string') + .map(block => block.text ?? '') + .join('\n') +} + function summarizeContent(content: readonly { type: string; text?: string }[]): string { for (const block of content) { if (block.type === 'text' && typeof block.text === 'string') return summarizeText(block.text) diff --git a/packages/client/ui-trajectory/src/client/trajectory-record.ts b/packages/client/ui-trajectory/src/client/trajectory-record.ts new file mode 100644 index 0000000000..9b2a835de9 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/trajectory-record.ts @@ -0,0 +1,81 @@ +/** Shared trajectory record data and formatting contracts. */ + +import type { HTMLAttributes } from 'react' + +/** Closed set of trajectory record kinds. */ +export type TrajectoryCellKind = 'user' | 'message' | 'tool' | 'subtool' + +/** Recorded inputs needed to derive assistant TTFT and decode throughput. */ +export interface AssistantMetricDetail { + timingRecorded: boolean + stepStartTime: number | null + firstTokenTime: number | null + completedTime: number | null + usageProvided: boolean + outputTokens: number | null +} + +/** One source content block preserved in model order for the details panel. */ +export interface TrajectorySourceBlock { + type: string + content: string + imageSrc?: string + imageAlt?: string + callId?: string + toolName?: string +} + +/** Data and optional presentation attributes for one trajectory record. */ +export interface TrajectoryCellProps extends HTMLAttributes { + /** 1-based record index shown as `#N`. */ + index: number + kind: TrajectoryCellKind + /** Single-line summary; CSS ellipsis when it overflows. */ + text: string + /** Whether this user record opens a new model turn. */ + opensTurn?: boolean + /** Full request/message content for the details panel. */ + inputDetail?: string + /** Full assistant/tool result content for the details panel. */ + outputDetail?: string + /** Full assistant reasoning content for the details panel. */ + thinkingDetail?: string + /** Original message blocks in source order for the details panel. */ + sourceBlocks?: readonly TrajectorySourceBlock[] + /** Original tool result blocks in source order for the details panel. */ + outputBlocks?: readonly TrajectorySourceBlock[] + /** Call-time model-visible tool schema for the details panel. */ + schemaDetail?: string + /** Assistant-only timing and token facts for the details panel. */ + assistantMetrics?: AssistantMetricDetail + /** Tool-only result summary paired with the call in the same record. */ + result?: string + /** Tool call id used to link message source blocks to tool records. */ + callId?: string + /** Tool-only result failure state. */ + isError?: boolean + /** Own duration in seconds, or `null` when no duration is known. */ + timeSeconds: number | null + /** Unix epoch milliseconds when this operation actually started, when known. */ + startedAt?: number | null + /** Message-only prompt token count. */ + input?: number + /** Message-only completion token count. */ + output?: number + /** Message-only reasoning token count. */ + think?: number + /** Whether the legacy standalone cell renders its selection treatment. */ + selected?: boolean +} + +/** + * Format own-duration for the trailing time column. + * @param seconds - Duration seconds, or `null` when absent. + * @returns `—` when unknown, otherwise a signed seconds label. + */ +export function formatElapsedSeconds(seconds: number | null): string { + if (seconds === null || !Number.isFinite(seconds)) return '—' + const rounded = Math.round(seconds * 10) / 10 + if (Number.isInteger(rounded)) return `${rounded}s` + return `${rounded.toFixed(1)}s` +} diff --git a/packages/client/ui-trajectory/src/client/views.module.css b/packages/client/ui-trajectory/src/client/views.module.css index 16a853c441..4f378e7fc3 100644 --- a/packages/client/ui-trajectory/src/client/views.module.css +++ b/packages/client/ui-trajectory/src/client/views.module.css @@ -1,17 +1,24 @@ -/* Full-bleed scroll host so Turn sticky bars can paint edge-to-edge; - * cell content width is capped on the turn body (max 880). */ +/* Full-bleed, fixed-height host for the trajectory ledger and waterfall. */ .root { - overflow-y: auto; + --dsh-trajectory-toolbar-height: 48px; + + display: flex; + flex-direction: column; + overflow: hidden; height: 100%; min-height: 0; width: 100%; box-sizing: border-box; color: var(--dsw-alias-label-primary); - background: var(--dsw-specific-sidebar-fill); + background: var(--dsw-alias-bg-layer-1); } .empty { - padding: 16px; + display: grid; + flex: 1; + margin: 0; + padding: 24px; + place-items: center; color: var(--dsw-alias-label-tertiary); font: var(--dsw-font-xs-13); } diff --git a/packages/client/ui-trajectory/tests/layout.spec.tsx b/packages/client/ui-trajectory/tests/layout.spec.tsx index b74782a4c4..a78ca1ba42 100644 --- a/packages/client/ui-trajectory/tests/layout.spec.tsx +++ b/packages/client/ui-trajectory/tests/layout.spec.tsx @@ -139,7 +139,7 @@ describe('deriveTrajectoryLayout', () => { }, ] as unknown as ConversationSnapshot['nodes'] const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] }) - expect(turns[0]?.groups[0]?.description).toBe('2.9s bash×2') + expect(turns[0]?.groups[0]?.description).toBe('3s bash×2') }) it('assigns each user message to its enclosing turn instead of pooling into Turn 1', () => { @@ -161,7 +161,7 @@ describe('deriveTrajectoryLayout', () => { expect(turns[1]?.groups.flatMap((g) => g.cells.map((c) => c.text))).toEqual(['second', 'ok2']) }) - it('keeps usage on the fallback Message row when assistant has no text block', () => { + it('keeps usage and a meaningful summary when assistant has no text block', () => { const nodes = [ { kind: 'assistant', seq: 1, time: 5_000, turn: 1, step: 0, @@ -172,7 +172,7 @@ describe('deriveTrajectoryLayout', () => { const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] }) const message = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'message') expect(message).toMatchObject({ - text: '', input: 11, output: 22, think: 3, + text: '仅推理输出', input: 11, output: 22, think: 3, }) }) @@ -235,11 +235,12 @@ describe('run_code sub-dispatch cells', () => { ]]]) as unknown as ConversationSnapshot['codeDispatches'] const turns = deriveTrajectoryLayout({ codeDispatches, nodes: runCodeNodes, partial: null, runningCalls: [] }) const cells = turns[0]!.groups.flatMap((g) => g.cells) - expect(cells.map((c) => c.kind)).toEqual(['tool', 'subtool', 'subtool']) + expect(cells.map((c) => c.kind)).toEqual(['message', 'tool', 'subtool', 'subtool']) + expect(cells[0]?.text).toBe('请求调用 run_code') // Sequential indexes across the interleave; durations from the pair times. - expect(cells.map((c) => c.index)).toEqual([1, 2, 3]) - expect(cells[1]).toMatchObject({ text: 'bash · {"x":1}', timeSeconds: 1 }) - expect(cells[2]).toMatchObject({ timeSeconds: 0.5 }) + expect(cells.map((c) => c.index)).toEqual([1, 2, 3, 4]) + expect(cells[2]).toMatchObject({ text: 'bash · {"x":1}', timeSeconds: 1 }) + expect(cells[3]).toMatchObject({ timeSeconds: 0.5 }) }) it('a running (unsettled) sub-call renders a subtool cell with blank time', () => { diff --git a/packages/client/ui-trajectory/tests/table.spec.tsx b/packages/client/ui-trajectory/tests/table.spec.tsx new file mode 100644 index 0000000000..3b096a1955 --- /dev/null +++ b/packages/client/ui-trajectory/tests/table.spec.tsx @@ -0,0 +1,88 @@ +// @vitest-environment jsdom +/** Trajectory ledger selection, details, status, and fold behavior. */ + +import { afterEach, describe, expect, it } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { TrajectoryTable } from '../src/client/TrajectoryTable.tsx' +import type { TrajectoryTurnModel } from '../src/client/layout.ts' + +afterEach(cleanup) + +const TURNS: readonly TrajectoryTurnModel[] = [{ + turn: 1, + groups: [{ + title: 'Step 1', + description: '1.5s bash×2', + cells: [ + { + index: 1, + kind: 'message', + text: 'Checking files', + outputDetail: 'Checking files', + input: 10, + output: 20, + think: 5, + timeSeconds: 1.5, + assistantMetrics: { + timingRecorded: true, + stepStartTime: 1_000, + firstTokenTime: 1_500, + completedTime: 2_500, + usageProvided: true, + outputTokens: 20, + }, + }, + { + index: 2, + kind: 'tool', + text: 'bash · {"command":"pwd"}', + inputDetail: '{"command":"pwd"}', + timeSeconds: null, + }, + { + index: 3, + kind: 'tool', + text: 'bash · {"command":"false"}', + inputDetail: '{"command":"false"}', + outputDetail: 'ToolError: non_zero_exit', + result: 'non_zero_exit', + isError: true, + timeSeconds: 0.2, + }, + ], + }], +}] + +describe('TrajectoryTable', () => { + it('shows assistant timing facts after keyboard selection', () => { + render() + fireEvent.keyDown(screen.getByRole('row', { name: /记录 1,ASSISTANT/ }), { key: 'Enter' }) + fireEvent.click(screen.getByRole('tab', { name: '计时' })) + + expect(screen.getByText('500 ms')).toBeTruthy() + expect(screen.getByText('1.00 s')).toBeTruthy() + expect(screen.getByText('20.0 tok/s')).toBeTruthy() + }) + + it('keeps running and failure semantics distinct from record roles', () => { + const view = render() + expect(view.container.querySelector('tr[data-kind="tool"][data-running="true"]')).toBeTruthy() + expect(view.container.querySelector('tr[data-kind="tool"][data-error="true"]')).toBeTruthy() + + fireEvent.click(screen.getByRole('row', { name: /记录 2,TOOL/ })) + expect(screen.getByText('进行中')).toBeTruthy() + fireEvent.click(screen.getByRole('row', { name: /记录 3,TOOL/ })) + expect(screen.getByText('失败')).toBeTruthy() + fireEvent.click(screen.getByRole('tab', { name: '输出' })) + expect(screen.getByText('ToolError: non_zero_exit')).toBeTruthy() + }) + + it('retains the ledger header and record count when collapsed', () => { + render() + expect(screen.getByRole('columnheader', { name: '事件' })).toBeTruthy() + expect(screen.queryByRole('columnheader', { name: 'Tokens' })).toBeNull() + expect(screen.queryByRole('columnheader', { name: '耗时' })).toBeNull() + expect(screen.getByText('3 条记录已收起')).toBeTruthy() + expect(screen.queryByRole('row', { name: /记录 1,ASSISTANT/ })).toBeNull() + }) +}) diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index cbc8760dae..b755fe8b5f 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -4,7 +4,7 @@ * registers trajectory/waterfall into a real SlotsService view ring, tabs * switch inside ConversationRoot (renderSlot share driven by the same tab * projection apply uses) without collapsing chat, trajectory renders the - * turn-list chrome (no span stats bar), waterfall keeps in-body stats, and + * event-ledger chrome (no span stats bar), waterfall keeps in-body stats, and * fiber disposal removes both tabs. Span derivation edge cases ride along. */ import { Context } from 'cordis' @@ -169,22 +169,48 @@ describe('plugin registration', () => { }) describe('tab switching in ConversationRoot', () => { - it('renders all three tabs, defaults to chat, and switches to trajectory without stats chrome', async () => { + it('renders all three tabs, defaults to chat, and switches to the trajectory ledger', async () => { const b = await bench() - mount(b.slots) + const view = mount(b.slots) expect(screen.getByTestId('chat-body')).toBeTruthy() expect(screen.getAllByRole('tab').map((t) => t.textContent)).toEqual(['Chat', 'Trajectory', 'Waterfall']) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) expect(screen.queryByText(/turns ·/)).toBeNull() - expect(screen.getByText('Turn 1')).toBeTruthy() - expect(screen.getByText('Turn 2')).toBeTruthy() - expect(screen.getAllByText('Message').length).toBeGreaterThan(0) - expect(screen.getAllByText('Step 1').length).toBeGreaterThan(0) - expect(screen.getAllByText('Input').length).toBeGreaterThan(0) + expect(view.container.querySelectorAll('tr[data-turn-start="true"]')).toHaveLength(2) + expect(screen.getAllByLabelText('Step 1')).toHaveLength(2) + expect(screen.getByRole('columnheader', { name: '事件' })).toBeTruthy() + expect(screen.getByRole('columnheader', { name: '内容' })).toBeTruthy() + expect(screen.queryByRole('columnheader', { name: 'Tokens' })).toBeNull() + expect(screen.queryByRole('columnheader', { name: '耗时' })).toBeNull() + expect(screen.getByRole('toolbar', { name: '轨迹工具栏' }).textContent).toContain('4 条记录') + fireEvent.click(screen.getByRole('button', { name: '收起记录' })) + expect(screen.getByText('4 条记录已收起')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: '展开记录' })) + expect(screen.getByRole('row', { name: /记录 1,USER/ })).toBeTruthy() expect(screen.queryByTestId('chat-body')).toBeNull() }) + it('opens a local record inspector and switches payload tabs without opening chat details', async () => { + const b = await bench() + mount(b.slots) + fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) + + fireEvent.keyDown(screen.getByRole('row', { name: /记录 3,TOOL/ }), { key: 'Enter' }) + expect(screen.getByRole('complementary', { name: '记录详情' })).toBeTruthy() + expect(screen.getByText('记录 #3')).toBeTruthy() + expect(screen.getByText('Turn 1 · Step 1')).toBeTruthy() + expect(screen.getByText('完成')).toBeTruthy() + + fireEvent.click(screen.getByRole('tab', { name: '输入' })) + expect(screen.getByText('这条记录没有输入载荷')).toBeTruthy() + fireEvent.click(screen.getByRole('tab', { name: '输出' })) + expect(screen.getByText('[]')).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: '关闭详情' })) + expect(screen.queryByRole('complementary', { name: '记录详情' })).toBeNull() + }) + it('waterfall renders bars and switching back to chat does not collapse it', async () => { const b = await bench() mount(b.slots) diff --git a/patches/react-json-view-lite@2.5.0.patch b/patches/react-json-view-lite@2.5.0.patch new file mode 100644 index 0000000000..b7b4b7425a --- /dev/null +++ b/patches/react-json-view-lite@2.5.0.patch @@ -0,0 +1,319 @@ +diff --git a/dist/DataRenderer.d.ts b/dist/DataRenderer.d.ts +index 6838d1c3aa8cea1e77534801bf02b9c11ddad1de..3dc8c6568cc4f174126dad32c33a64b0dc25f198 100644 +--- a/dist/DataRenderer.d.ts ++++ b/dist/DataRenderer.d.ts +@@ -30,6 +30,8 @@ interface CommonRenderProps { + clickToExpandNode: boolean; + outerRef: React.RefObject; + beforeExpandChange?: (event: NodeExpandingEvent) => boolean; ++ renderExpandableValue?: (value: Object | Array, expanded: boolean) => React.ReactNode; ++ renderStringValue?: (value: string) => React.ReactNode; + } + export interface JsonRenderProps extends CommonRenderProps { + field?: string; +diff --git a/dist/index.d.ts b/dist/index.d.ts +index 23e9bca4586b7ea71e7d016230e33eb444cd1304..c49677664f0f0a10e67be688a0157ef9f0d93d6d 100644 +--- a/dist/index.d.ts ++++ b/dist/index.d.ts +@@ -16,10 +16,12 @@ export interface Props extends React.AriaAttributes { + shouldExpandNode?: (level: number, value: any, field?: string) => boolean; + clickToExpandNode?: boolean; + beforeExpandChange?: (event: NodeExpandingEvent) => boolean; ++ renderExpandableValue?: (value: Object | Array, expanded: boolean) => React.ReactNode; ++ renderStringValue?: (value: string) => React.ReactNode; + compactTopLevel?: boolean; + } + export declare const defaultStyles: StyleProps; + export declare const darkStyles: StyleProps; + export declare const allExpanded: () => boolean; + export declare const collapseAllNested: (level: number) => boolean; +-export declare const JsonView: ({ data, style, shouldExpandNode, clickToExpandNode, beforeExpandChange, compactTopLevel, ...ariaAttrs }: Props) => React.JSX.Element; ++export declare const JsonView: ({ data, style, shouldExpandNode, clickToExpandNode, beforeExpandChange, renderExpandableValue, renderStringValue, compactTopLevel, ...ariaAttrs }: Props) => React.JSX.Element; +diff --git a/dist/index.js b/dist/index.js +index ab8b98c992bd34e00e7c3a0257d6778d7928dde8..669304fbba450c789bf4a4264f0627a8eec9d178 100644 +--- a/dist/index.js ++++ b/dist/index.js +@@ -50,7 +50,9 @@ function ExpandableObject(_ref) { + shouldExpandNode, + clickToExpandNode, + outerRef, +- beforeExpandChange ++ beforeExpandChange, ++ renderExpandableValue, ++ renderStringValue + } = _ref; + const shouldExpandNodeCalledRef = React.useRef(false); + const [expanded, setExpanded] = React.useState(() => shouldExpandNode(level, value, field)); +@@ -146,7 +148,7 @@ function ExpandableObject(_ref) { + onKeyDown: onKeyDown + }, quoteString(field, style.quotesForFieldNames), ":")) : (/*#__PURE__*/React.createElement("span", { + className: style.label +- }, quoteString(field, style.quotesForFieldNames), ":"))), /*#__PURE__*/React.createElement("span", { ++ }, quoteString(field, style.quotesForFieldNames), ":"))), renderExpandableValue ? renderExpandableValue(value, expanded) : /*#__PURE__*/React.createElement("span", { + className: style.punctuation + }, openBracket), expanded ? (/*#__PURE__*/React.createElement("ul", { + id: contentsId, +@@ -162,16 +164,19 @@ function ExpandableObject(_ref) { + shouldExpandNode: shouldExpandNode, + clickToExpandNode: clickToExpandNode, + beforeExpandChange: beforeExpandChange, +- outerRef: outerRef ++ outerRef: outerRef, ++ renderExpandableValue: renderExpandableValue, ++ renderStringValue: renderStringValue + }))))) : ( ++ renderExpandableValue ? null : + /*#__PURE__*/ + React.createElement("span", { + className: style.collapsedContent, + onClick: onClick, + onKeyDown: onKeyDown +- })), /*#__PURE__*/React.createElement("span", { ++ })), renderExpandableValue ? null : /*#__PURE__*/React.createElement("span", { + className: style.punctuation +- }, closeBracket), !lastElement && /*#__PURE__*/React.createElement("span", { ++ }, closeBracket), !renderExpandableValue && !lastElement && /*#__PURE__*/React.createElement("span", { + className: style.punctuation + }, ",")); + } +@@ -207,7 +212,9 @@ function JsonObject(_ref3) { + clickToExpandNode, + level, + outerRef, +- beforeExpandChange ++ beforeExpandChange, ++ renderExpandableValue, ++ renderStringValue + } = _ref3; + return ExpandableObject({ + field, +@@ -221,7 +228,9 @@ function JsonObject(_ref3) { + clickToExpandNode, + data: Object.keys(value).map(key => [key, value[key]]), + outerRef, +- beforeExpandChange ++ beforeExpandChange, ++ renderExpandableValue, ++ renderStringValue + }); + } + function JsonArray(_ref4) { +@@ -234,7 +243,9 @@ function JsonArray(_ref4) { + shouldExpandNode, + clickToExpandNode, + outerRef, +- beforeExpandChange ++ beforeExpandChange, ++ renderExpandableValue, ++ renderStringValue + } = _ref4; + return ExpandableObject({ + field, +@@ -246,9 +257,11 @@ function JsonArray(_ref4) { + style, + shouldExpandNode, + clickToExpandNode, +- data: value.map(element => [undefined, element]), ++ data: value.map((element, index) => [String(index), element]), + outerRef, +- beforeExpandChange ++ beforeExpandChange, ++ renderExpandableValue, ++ renderStringValue + }); + } + function JsonPrimitiveValue(_ref5) { +@@ -256,7 +269,8 @@ function JsonPrimitiveValue(_ref5) { + field, + value, + style, +- lastElement ++ lastElement, ++ renderStringValue + } = _ref5; + let stringValue; + let valueStyle = style.otherValue; +@@ -291,7 +305,7 @@ function JsonPrimitiveValue(_ref5) { + "aria-selected": undefined + }, (field || field === '') && (/*#__PURE__*/React.createElement("span", { + className: style.label +- }, quoteString(field, style.quotesForFieldNames), ":")), /*#__PURE__*/React.createElement("span", { ++ }, quoteString(field, style.quotesForFieldNames), ":")), isString(value) && renderStringValue ? renderStringValue(value) : /*#__PURE__*/React.createElement("span", { + className: valueStyle + }, stringValue), !lastElement && /*#__PURE__*/React.createElement("span", { + className: style.punctuation +@@ -365,6 +379,8 @@ const JsonView = _ref => { + shouldExpandNode = allExpanded, + clickToExpandNode = false, + beforeExpandChange, ++ renderExpandableValue, ++ renderStringValue, + compactTopLevel, + ...ariaAttrs + } = _ref; +@@ -390,7 +406,9 @@ const JsonView = _ref => { + shouldExpandNode: shouldExpandNode, + clickToExpandNode: clickToExpandNode, + beforeExpandChange: beforeExpandChange, +- outerRef: outerRef ++ outerRef: outerRef, ++ renderExpandableValue: renderExpandableValue, ++ renderStringValue: renderStringValue + }); + }) : (/*#__PURE__*/React.createElement(DataRender, { + value: data, +@@ -403,7 +421,9 @@ const JsonView = _ref => { + shouldExpandNode: shouldExpandNode, + clickToExpandNode: clickToExpandNode, + outerRef: outerRef, +- beforeExpandChange: beforeExpandChange ++ beforeExpandChange: beforeExpandChange, ++ renderExpandableValue: renderExpandableValue, ++ renderStringValue: renderStringValue + }))); + }; + +diff --git a/dist/index.modern.js b/dist/index.modern.js +index e25aea1bc8936b69275b9fbe7011d0466480f99c..f4c509e4459f70080b90695c00f8a17e8f4aac4e 100644 +--- a/dist/index.modern.js ++++ b/dist/index.modern.js +@@ -50,7 +50,9 @@ function ExpandableObject(_ref) { + shouldExpandNode, + clickToExpandNode, + outerRef, +- beforeExpandChange ++ beforeExpandChange, ++ renderExpandableValue, ++ renderStringValue + } = _ref; + const shouldExpandNodeCalledRef = useRef(false); + const [expanded, setExpanded] = useState(() => shouldExpandNode(level, value, field)); +@@ -146,7 +148,7 @@ function ExpandableObject(_ref) { + onKeyDown: onKeyDown + }, quoteString(field, style.quotesForFieldNames), ":")) : (/*#__PURE__*/createElement("span", { + className: style.label +- }, quoteString(field, style.quotesForFieldNames), ":"))), /*#__PURE__*/createElement("span", { ++ }, quoteString(field, style.quotesForFieldNames), ":"))), renderExpandableValue ? renderExpandableValue(value, expanded) : /*#__PURE__*/createElement("span", { + className: style.punctuation + }, openBracket), expanded ? (/*#__PURE__*/createElement("ul", { + id: contentsId, +@@ -162,16 +164,19 @@ function ExpandableObject(_ref) { + shouldExpandNode: shouldExpandNode, + clickToExpandNode: clickToExpandNode, + beforeExpandChange: beforeExpandChange, +- outerRef: outerRef ++ outerRef: outerRef, ++ renderExpandableValue: renderExpandableValue, ++ renderStringValue: renderStringValue + }))))) : ( ++ renderExpandableValue ? null : + /*#__PURE__*/ + createElement("span", { + className: style.collapsedContent, + onClick: onClick, + onKeyDown: onKeyDown +- })), /*#__PURE__*/createElement("span", { ++ })), renderExpandableValue ? null : /*#__PURE__*/createElement("span", { + className: style.punctuation +- }, closeBracket), !lastElement && /*#__PURE__*/createElement("span", { ++ }, closeBracket), !renderExpandableValue && !lastElement && /*#__PURE__*/createElement("span", { + className: style.punctuation + }, ",")); + } +@@ -207,7 +212,9 @@ function JsonObject(_ref3) { + clickToExpandNode, + level, + outerRef, +- beforeExpandChange ++ beforeExpandChange, ++ renderExpandableValue, ++ renderStringValue + } = _ref3; + return ExpandableObject({ + field, +@@ -221,7 +228,9 @@ function JsonObject(_ref3) { + clickToExpandNode, + data: Object.keys(value).map(key => [key, value[key]]), + outerRef, +- beforeExpandChange ++ beforeExpandChange, ++ renderExpandableValue, ++ renderStringValue + }); + } + function JsonArray(_ref4) { +@@ -234,7 +243,9 @@ function JsonArray(_ref4) { + shouldExpandNode, + clickToExpandNode, + outerRef, +- beforeExpandChange ++ beforeExpandChange, ++ renderExpandableValue, ++ renderStringValue + } = _ref4; + return ExpandableObject({ + field, +@@ -246,9 +257,11 @@ function JsonArray(_ref4) { + style, + shouldExpandNode, + clickToExpandNode, +- data: value.map(element => [undefined, element]), ++ data: value.map((element, index) => [String(index), element]), + outerRef, +- beforeExpandChange ++ beforeExpandChange, ++ renderExpandableValue, ++ renderStringValue + }); + } + function JsonPrimitiveValue(_ref5) { +@@ -256,7 +269,8 @@ function JsonPrimitiveValue(_ref5) { + field, + value, + style, +- lastElement ++ lastElement, ++ renderStringValue + } = _ref5; + let stringValue; + let valueStyle = style.otherValue; +@@ -291,7 +305,7 @@ function JsonPrimitiveValue(_ref5) { + "aria-selected": undefined + }, (field || field === '') && (/*#__PURE__*/createElement("span", { + className: style.label +- }, quoteString(field, style.quotesForFieldNames), ":")), /*#__PURE__*/createElement("span", { ++ }, quoteString(field, style.quotesForFieldNames), ":")), isString(value) && renderStringValue ? renderStringValue(value) : /*#__PURE__*/createElement("span", { + className: valueStyle + }, stringValue), !lastElement && /*#__PURE__*/createElement("span", { + className: style.punctuation +@@ -365,6 +379,8 @@ const JsonView = _ref => { + shouldExpandNode = allExpanded, + clickToExpandNode = false, + beforeExpandChange, ++ renderExpandableValue, ++ renderStringValue, + compactTopLevel, + ...ariaAttrs + } = _ref; +@@ -390,7 +406,9 @@ const JsonView = _ref => { + shouldExpandNode: shouldExpandNode, + clickToExpandNode: clickToExpandNode, + beforeExpandChange: beforeExpandChange, +- outerRef: outerRef ++ outerRef: outerRef, ++ renderExpandableValue: renderExpandableValue, ++ renderStringValue: renderStringValue + }); + }) : (/*#__PURE__*/createElement(DataRender, { + value: data, +@@ -403,7 +421,9 @@ const JsonView = _ref => { + shouldExpandNode: shouldExpandNode, + clickToExpandNode: clickToExpandNode, + outerRef: outerRef, +- beforeExpandChange: beforeExpandChange ++ beforeExpandChange: beforeExpandChange, ++ renderExpandableValue: renderExpandableValue, ++ renderStringValue: renderStringValue + }))); + }; + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2e7b31fe0..3491486b69 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +patchedDependencies: + react-json-view-lite@2.5.0: 8caeb240cf32d6060a32781982025430c919fe9eba857054195179dd08acff5e + importers: .: @@ -994,12 +997,24 @@ importers: clsx: specifier: ^2.0.0 version: 2.1.1 + mdast-util-from-markdown: + specifier: ^2.0.3 + version: 2.0.3 + mdast-util-gfm: + specifier: ^3.1.0 + version: 3.1.0 + micromark-extension-gfm: + specifier: ^3.0.0 + version: 3.0.0 react: specifier: ^18.2.0 version: 18.3.1 react-dom: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) + react-json-view-lite: + specifier: ^2.5.0 + version: 2.5.0(patch_hash=8caeb240cf32d6060a32781982025430c919fe9eba857054195179dd08acff5e)(react@18.3.1) react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@18.3.31)(react@18.3.1) @@ -1279,6 +1294,9 @@ importers: '@deepseek-ai/dsh-client-ui-conversation': specifier: workspace:^ version: link:../ui-conversation + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -9283,6 +9301,12 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-json-view-lite@2.5.0: + resolution: {integrity: sha512-tk7o7QG9oYyELWHL8xiMQ8x4WzjCzbWNyig3uexmkLb54r8jO0yH3WCWx8UZS0c49eSA4QUmG5caiRJ8fAn58g==} + engines: {node: '>=18'} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-markdown@10.1.0: resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} peerDependencies: @@ -14434,6 +14458,10 @@ snapshots: react-is@17.0.2: {} + react-json-view-lite@2.5.0(patch_hash=8caeb240cf32d6060a32781982025430c919fe9eba857054195179dd08acff5e)(react@18.3.1): + dependencies: + react: 18.3.1 + react-markdown@10.1.0(@types/react@18.3.31)(react@18.3.1): dependencies: '@types/hast': 3.0.5 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8da07afcf0..ed1bca64f2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -54,3 +54,6 @@ minimumReleaseAgeExclude: # Fresh pi-ai releases carry the model catalog updates that are the whole # point of bumping it; waiting out the release age would defeat that. - '@earendil-works/pi-ai@0.81.1' + +patchedDependencies: + react-json-view-lite@2.5.0: patches/react-json-view-lite@2.5.0.patch From 714090bb4d7ba380502f01bc61984fb997a2802b Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 27 Jul 2026 15:58:06 +0800 Subject: [PATCH 002/189] feat(ui): add trajectory context generations --- packages/client/runtime/src/client/index.ts | 4 +- .../src/client/sessions/conversation.ts | 21 +++ .../src/client/sessions/fold-adapter.ts | 79 ++++++++++- .../runtime/src/client/sessions/session.ts | 2 + .../src/client/ContextsPanel.module.css | 116 ++++++++++++++++ .../src/client/ContextsPanel.tsx | 84 ++++++++++++ .../src/client/TrajectoryCell.module.css | 5 + .../src/client/TrajectoryCell.tsx | 2 + .../src/client/TrajectoryTable.module.css | 6 + .../src/client/TrajectoryTable.tsx | 18 ++- .../src/client/TrajectoryToolbar.module.css | 30 ++++ .../src/client/TrajectoryToolbar.tsx | 15 ++ .../src/client/TrajectoryView.tsx | 129 ++++++++++++++---- .../client/ui-trajectory/src/client/layout.ts | 14 +- .../src/client/trajectory-record.ts | 2 +- .../ui-trajectory/src/client/views.module.css | 16 +++ packages/core/session/src/surface.ts | 42 +++++- 17 files changed, 542 insertions(+), 43 deletions(-) create mode 100644 packages/client/ui-trajectory/src/client/ContextsPanel.module.css create mode 100644 packages/client/ui-trajectory/src/client/ContextsPanel.tsx diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 15b3beebb8..5ee2b3e492 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -28,8 +28,8 @@ export type { EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore, } from './contract/store.ts' export type { - AssistantBlock, AssistantMessageNode, AssistantTiming, CodeSubCall, ComposerPhase, ContextMessageNode, ConversationNode, - ConversationSnapshot, QueuedMessage, RunningToolCall, + AssistantBlock, AssistantMessageNode, AssistantTiming, CodeSubCall, ComposerPhase, ContextMessageNode, + ConversationContext, ConversationContextOriginKind, ConversationNode, ConversationSnapshot, QueuedMessage, RunningToolCall, SteeringMessageNode, ToolResultNode, UnknownSurfaceNode, UserMessageNode, } from './sessions/conversation.ts' export { PendingWait } from './sessions/pending.ts' diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 609c2830bc..b888e8668f 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -206,6 +206,25 @@ export type OpenState = 'cold' | 'loading' | 'open' | 'error' */ export type ComposerPhase = 'blank' | 'engaging' | 'active' +/** Operation that started a new append-only model context. */ +export type ConversationContextOriginKind = 'compaction' | 'rewind' | 'rewrite' + +/** One immutable model-context generation reconstructed from surface replacements. */ +export interface ConversationContext { + /** Zero-based generation within the session; stable across later appends. */ + id: number + /** Previous generation in this session; absent for the initial context. */ + parentId?: number + /** Why this generation exists; absent for the initial context. */ + origin?: ConversationContextOriginKind + /** Event seq of the replacement that created this generation. */ + originSeq?: number + /** Unix epoch ms of the replacement that created this generation. */ + createdAt?: number + /** Final frozen nodes for historical generations, or current folded nodes for the tail. */ + nodes: readonly ConversationNode[] +} + /** Send/stop failure surfaced in the input error strip; op picks the user-facing copy (发送失败 vs 停止失败). */ export interface PromptError { op: 'send' | 'stop' @@ -217,6 +236,8 @@ export interface ConversationSnapshot { sessionId: SessionId /** Surface fold product (finalized conversation nodes in surface order). */ nodes: readonly ConversationNode[] + /** Append-only context generations split at every model-surface replacement. */ + contexts?: readonly ConversationContext[] /** Fold degradation flag (cross-window replace defense): when true, nodes come from the lenient linear scan. */ foldDegraded: boolean partial: PartialAssistant | null diff --git a/packages/client/runtime/src/client/sessions/fold-adapter.ts b/packages/client/runtime/src/client/sessions/fold-adapter.ts index 9fc302398a..ef715854f3 100644 --- a/packages/client/runtime/src/client/sessions/fold-adapter.ts +++ b/packages/client/runtime/src/client/sessions/fold-adapter.ts @@ -7,9 +7,13 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types' // Subpath export (package.json exports "./surface", alias added for this): all value imports // go through it — the package root points at lib/index.js (needs a build) which the vite // browser bundle cannot resolve; surface.ts has no Node dependencies. -import { SurfaceManager, isSurfaceEligibleType } from '@deepseek-ai/dsh-session/surface' +import { + SurfaceManager, isSurfaceEligibleType, isSurfaceEvent, +} from '@deepseek-ai/dsh-session/surface' import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' -import type { AssistantTiming, ConversationNode } from './conversation.ts' +import type { + AssistantTiming, ConversationContext, ConversationContextOriginKind, ConversationNode, +} from './conversation.ts' import { toAssistantBlocks } from './conversation.ts' /** In-window tool/call index entry (result-card backfill + runningCalls material). */ @@ -107,6 +111,9 @@ export class FoldAdapter { * reference-stability contract (§A.9.4) starts here. */ private rev = 0 private nodesResult: { rev: number; value: { nodes: ConversationNode[]; degraded: boolean } } | null = null + /** Revision of the model-visible surface only; log-only chunks do not rebuild context generations. */ + private surfaceRev = 0 + private contextsResult: { rev: number; value: readonly ConversationContext[] } | null = null /** In-window tool/call index (Session uses it for runningCalls and result-card backfill). */ get callIndex(): ReadonlyMap { @@ -122,6 +129,7 @@ export class FoldAdapter { */ reset(events: readonly SessionEvent[], baseSeq: number, views?: readonly (ToolEventView | undefined)[]): void { this.rev++ + this.surfaceRev++ this.baseSeq = baseSeq this.padded = [] for (let i = 0; i < baseSeq; i++) this.padded.push(paddingEvent(i)) @@ -146,6 +154,7 @@ export class FoldAdapter { */ append(event: SessionEvent, view?: ToolEventView): void { this.rev++ + if (isSurfaceEvent(event)) this.surfaceRev++ this.padded.push(event) this.indexCall(event, view) } @@ -193,6 +202,41 @@ export class FoldAdapter { return value } + /** + * Append-only context generations reconstructed from canonical surface replacements. + * @returns Frozen historical contexts followed by the current context. + */ + contexts(): readonly ConversationContext[] { + if (this.contextsResult !== null && this.contextsResult.rev === this.surfaceRev) { + return this.contextsResult.value + } + const current = this.nodes() + if (current.degraded) { + const value: readonly ConversationContext[] = [{ id: 0, nodes: current.nodes }] + this.contextsResult = { rev: this.surfaceRev, value } + return value + } + const value = this.surface.contexts.map((context): ConversationContext => { + const nodes: ConversationNode[] = [] + for (const seq of context.nodes) { + const node = this.materialize(seq) + if (node !== undefined) nodes.push(node) + } + if (context.origin === undefined) return { id: context.generation, nodes } + const originEvent = this.padded[context.origin.seq] + return { + id: context.generation, + parentId: context.generation - 1, + origin: contextOriginKind(originEvent), + originSeq: context.origin.seq, + ...(originEvent === undefined ? {} : { createdAt: originEvent.time }), + nodes, + } + }) + this.contextsResult = { rev: this.surfaceRev, value } + return value + } + /** Degradation branch: lenient linear scan ignoring surfaceOp/replace (all surface-eligible events in append order). */ private degradedSeqs(): number[] { const seqs: number[] = [] @@ -203,6 +247,21 @@ export class FoldAdapter { return seqs } + private materialize(seq: number): ConversationNode | undefined { + const cached = this.nodeCache.get(seq) + if (cached !== undefined) return cached + const event = this.padded[seq] + if (event === undefined) return + const node = materializeNode( + event, + this.callIdx, + this.resultViews.get(seq) ?? null, + event.type === 'assistant/message' ? this.assistantTiming(event) : undefined, + ) + this.nodeCache.set(seq, node) + return node + } + private assistantTiming(event: SessionEvent<'assistant/message'>): AssistantTiming { let stepStartTime: number | null = null let firstTokenTime: number | null = null @@ -246,6 +305,22 @@ export class FoldAdapter { } } +function contextOriginKind(event: SessionEvent | undefined): ConversationContextOriginKind { + if (event?.type !== 'user/message') return 'rewrite' + const source = event.data.source + if ( + typeof source === 'object' + && source !== null + && 'kind' in source + && 'plugin' in source + && source.kind === 'plugin' + ) { + if (source.plugin === 'compact') return 'compaction' + if (source.plugin === 'rewind') return 'rewind' + } + return 'rewrite' +} + function isTokenDelta(chunk: SessionEvent<'assistant/chunk'>['data']['chunk']): boolean { switch (chunk.type) { case 'text-delta': diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 493ca6baa7..3e1eed36bd 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -772,6 +772,7 @@ export class Session implements ObservableSnapshot { private buildSnapshot(): ConversationSnapshot { const { nodes: folded, degraded } = this.foldAdapter.nodes() + const contexts = this.foldAdapter.contexts() // Frozen interrupted nodes ride fractional seqs: a stable merge keeps them in flow order. // The merged array is cached on (folded reference, frozenRev) so an unchanged flow keeps its // reference across snapshot swaps (§A.9.4). @@ -803,6 +804,7 @@ export class Session implements ObservableSnapshot { return { sessionId: this.sessionId, nodes, + contexts, foldDegraded: degraded, partial, runningCalls: this.callsCache.value, diff --git a/packages/client/ui-trajectory/src/client/ContextsPanel.module.css b/packages/client/ui-trajectory/src/client/ContextsPanel.module.css new file mode 100644 index 0000000000..e358fe4055 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/ContextsPanel.module.css @@ -0,0 +1,116 @@ +.root { + display: flex; + flex: none; + width: 218px; + min-width: 176px; + flex-direction: column; + box-sizing: border-box; + overflow: hidden; + border-right: 1px solid var(--dsw-alias-border-l2); + background: var(--dsw-alias-bg-layer-1); +} + +.header { + display: flex; + flex: none; + height: 34px; + align-items: center; + box-sizing: border-box; + padding: 0 12px; + border-bottom: 1px solid var(--dsw-alias-border-l1); + color: var(--dsw-alias-label-secondary); + font: var(--dsw-font-xs-strong-13); + user-select: none; +} + +.list { + min-height: 0; + padding: 4px; + overflow: auto; +} + +.item { + display: flex; + width: 100%; + min-width: 0; + min-height: 42px; + align-items: center; + box-sizing: border-box; + padding: 4px 7px; + gap: 7px; + border: 0; + border-radius: 4px; + color: var(--dsw-alias-label-primary); + background: transparent; + cursor: pointer; + text-align: left; +} + +.item:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.item:focus-visible { + outline: 1px solid var(--dsw-alias-state-business-primary); + outline-offset: -1px; +} + +.itemSelected { + background: var(--dsw-alias-interactive-bg-active); + box-shadow: inset 2px 0 var(--dsw-alias-state-business-primary); +} + +.icon { + flex: none; + color: var(--dsw-alias-label-caption); +} + +.itemSelected .icon { + color: var(--dsw-alias-state-business-primary); +} + +.itemBody { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; +} + +.itemTitle, +.itemMeta { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.itemTitle { + font: var(--dsw-font-xs-13); +} + +.itemMeta { + color: var(--dsw-alias-label-tertiary); + font: 11px/16px var(--ds-font-family-code); +} + +.current, +.frozen { + flex: none; + align-self: flex-start; + padding-top: 1px; + font: 10px/16px var(--ds-font-family-code); + user-select: none; +} + +.current { + color: var(--dsw-alias-state-business-primary); +} + +.frozen { + color: var(--dsw-alias-label-caption); +} + +@media (max-width: 820px) { + .root { + width: 184px; + } +} diff --git a/packages/client/ui-trajectory/src/client/ContextsPanel.tsx b/packages/client/ui-trajectory/src/client/ContextsPanel.tsx new file mode 100644 index 0000000000..aa58789beb --- /dev/null +++ b/packages/client/ui-trajectory/src/client/ContextsPanel.tsx @@ -0,0 +1,84 @@ +/** Context-generation selector for a trajectory session. */ + +import { IconBranchOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import type { + ConversationContext, ConversationContextOriginKind, +} from '@deepseek-ai/dsh-client-runtime/client' +import css from './ContextsPanel.module.css' + +export interface ContextsPanelProps { + contexts: readonly ConversationContext[] + selectedId: number + currentId: number + onSelect(id: number): void +} + +function formatTime(timestamp: number | undefined): string | undefined { + if (timestamp === undefined || !Number.isFinite(timestamp)) return + const date = new Date(timestamp) + const two = (value: number) => String(value).padStart(2, '0') + return `${two(date.getHours())}:${two(date.getMinutes())}:${two(date.getSeconds())}` +} + +function originLabel(origin: ConversationContextOriginKind | undefined): string { + if (origin === 'compaction') return 'Compaction' + if (origin === 'rewind') return 'Rewind' + if (origin === 'rewrite') return 'Context rewrite' + return 'Initial context' +} + +/** Human-facing context title without exposing internal generation ids. */ +export function contextLabel(context: ConversationContext): string { + const label = originLabel(context.origin) + const time = formatTime(context.createdAt) + return time === undefined ? label : `${label} · ${time}` +} + +/** + * Render every append-only context generation in creation order. + * @param props - Contexts and the selected/current identities. + * @returns The context navigation panel. + */ +export function ContextsPanel({ + contexts, + selectedId, + currentId, + onSelect, +}: ContextsPanelProps) { + return ( + + ) +} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryCell.module.css b/packages/client/ui-trajectory/src/client/TrajectoryCell.module.css index c5efc232d1..496a75c566 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryCell.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryCell.module.css @@ -51,6 +51,11 @@ background: var(--dsw-alias-state-success-tertiary); } +.tagContext { + color: var(--dsw-alias-label-secondary); + background: var(--dsw-alias-bg-layer-3); +} + .tagMessage { color: var(--dsw-alias-brand-primary-new-colorprimary-new-color); background: var(--dsw-specific-bubble); diff --git a/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx b/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx index 384d0e8b30..211b1dc3d4 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx @@ -17,6 +17,7 @@ export type { /** Display label per kind (matches the design tags). */ const KIND_LABEL: Record = { user: 'User', + context: 'Context', message: 'Message', tool: 'Tool', subtool: 'Sub', @@ -24,6 +25,7 @@ const KIND_LABEL: Record = { const TAG_CLASS: Record = { user: css.tagUser!, + context: css.tagContext!, message: css.tagMessage!, tool: css.tagTool!, subtool: css.tagSubtool!, diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css index e936d2eb48..f7098796a2 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css @@ -230,6 +230,12 @@ background: var(--dsw-alias-state-business-tertiary); } +.context { + border-color: var(--dsw-alias-border-l2); + color: var(--dsw-alias-label-secondary); + background: var(--dsw-alias-bg-layer-1); +} + .message { border-color: var(--dsw-alias-border-l2); color: var(--dsw-alias-label-secondary); diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index f50ec809a5..db153276cd 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -14,6 +14,7 @@ import css from './TrajectoryTable.module.css' const KIND_LABEL: Record = { user: 'USER', + context: 'CONTEXT', message: 'ASSISTANT', tool: 'TOOL', subtool: 'SUBTOOL', @@ -331,7 +332,9 @@ function tokenSummary(cell: TrajectoryCellProps): string { } function isMarkdownRecord(record: TableRecord): boolean { - return record.cell.kind === 'user' || record.cell.kind === 'message' + return record.cell.kind === 'user' + || record.cell.kind === 'context' + || record.cell.kind === 'message' } function parentRecords( @@ -369,7 +372,9 @@ function parentRecords( } function markdownSource(record: TableRecord): string | undefined { - if (record.cell.kind === 'user') return record.cell.inputDetail + if (record.cell.kind === 'user' || record.cell.kind === 'context') { + return record.cell.inputDetail + } if (record.cell.kind === 'message') return record.cell.outputDetail return undefined } @@ -394,7 +399,7 @@ function detailTabs(record: TableRecord): readonly DetailTabItem[] { function recordDisplayText(cell: TrajectoryCellProps): string { if (isToolCallOnly(cell)) return '' - const markdown = cell.kind === 'user' + const markdown = cell.kind === 'user' || cell.kind === 'context' ? cell.inputDetail : cell.kind === 'message' ? cell.outputDetail ?? cell.thinkingDetail @@ -735,7 +740,8 @@ function RecordPayload({ } const markdown = ( - direction === 'input' && record.cell.kind === 'user' + direction === 'input' + && (record.cell.kind === 'user' || record.cell.kind === 'context') ) || ( direction === 'output' && record.cell.kind === 'message' ) @@ -1015,7 +1021,9 @@ export function TrajectoryTable({ {!isCollapsedSummary && (
Trajectory + {contextLabel !== undefined && ( + <> + / + {contextLabel} + + {contextCurrent ? 'Current' : 'Frozen'} + + + )}
- {selected !== undefined && selectedState !== undefined && ( + {(promptSelected || (selected !== undefined && selectedState !== undefined)) && (
diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 828d8e61a0..12bc9841f0 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -161,17 +161,15 @@ export function TrajectoryView({ useSession }: ConvViewProps) { /> )}
- {turns.length === 0 &&

No trajectory events

} - {turns.length > 0 && ( - - )} +
From fcbf0f0952d61634e0931fccba155463602e22ea Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 27 Jul 2026 17:12:08 +0800 Subject: [PATCH 005/189] perf(skill): avoid catalog event copy --- .../feature/2026-07-27-skill-catalog-hot-refresh.i18n.yaml | 4 ++-- .../feature/2026-07-27-skill-catalog-hot-refresh.md | 2 +- .../feature/2026-07-27-skill-catalog-hot-refresh.zh.md | 2 +- packages/skill/tool-skill/src/index.ts | 6 +++++- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.i18n.yaml index ec6e7fd572..3530f2c4f7 100644 --- a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md -2026-07-27-skill-catalog-hot-refresh.md: f818766eb55f237e21aa3da9586887e493b9de75 -2026-07-27-skill-catalog-hot-refresh.zh.md: 3f0be2e760f4a18504e18803bcb8a8e47acffa5d +2026-07-27-skill-catalog-hot-refresh.md: 7a63574a7a260489760f5ec376a6c1fdcd71e681 +2026-07-27-skill-catalog-hot-refresh.zh.md: 7cf6dcdbfe6e3540779bbe4cf48bdef037edf070 diff --git a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md index f818766eb5..7a63574a7a 100644 --- a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md +++ b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md @@ -18,7 +18,7 @@ The skill capability separates catalog membership from instruction-body loading. A missing root is followed from its nearest existing ancestor one absent segment at a time with `fs.watchFile`, then handed to Chokidar once the real root exists. Deleting a root re-establishes ancestor observation. Chokidar configuration exposes native-versus-polling mode, write stability, polling interval, symlink following, and project watcher capacity. First-party `write` and `edit` tool observations synchronously invalidate a relevant provider, so the next model step sees its own mutation without waiting for host delivery. Watch startup/runtime failures make discovery incomplete and retry; teardown closes watchers and ignores late callbacks. -`@deepseek-ai/dsh-tool-skill` keeps the initial complete catalog in `agent/session-prefix`. Before every model step it computes a digest over exact `skill` tool visibility and the ordered rendered names and descriptions. A changed digest appends a durable, complete replacement catalog through `agent.inject()`, including an explicit empty catalog when all skills disappear. The logged message carries `{ kind: 'skill-catalog', version: 1, digest }`, so a still-visible replacement supplies the baseline across replay or plugin reload. If compaction shadows it, the next pre-step falls back to the loop's initial-prefix baseline and re-establishes the current catalog when needed. An incomplete snapshot emits no replacement and preserves the last-good model view. +`@deepseek-ai/dsh-tool-skill` keeps the initial complete catalog in `agent/session-prefix`. Before every model step it computes a digest over exact `skill` tool visibility and the ordered rendered names and descriptions. A changed digest appends a durable, complete replacement catalog through `agent.inject()`, including an explicit empty catalog when all skills disappear. The logged message carries `{ kind: 'skill-catalog', version: 1, digest }`, so a still-visible replacement supplies the baseline across replay or plugin reload. The lookup scans the read-only event view by descending index and stops at the newest visible replacement, avoiding a full event-array copy on every model step. If compaction shadows it, the next pre-step falls back to the loop's initial-prefix baseline and re-establishes the current catalog when needed. An incomplete snapshot emits no replacement and preserves the last-good model view. The TUI consumes the same invalidation as presentation state, not session history. `skills/change` carries no diff; the TUI refetches `snapshot()` for the active session cwd, applies only the latest complete result, and retains the previous commands across incomplete observations. A complete empty result clears stale completions. Because pi-tui closes autocomplete when its provider is replaced, a catalog that arrives while the user is typing a slash-command name also triggers a suggestion-only re-query of the current draft. diff --git a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md index 3f0be2e760..7cf6dcdbfe 100644 --- a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md @@ -18,7 +18,7 @@ skill 服务将目录成员关系与指令正文加载分离。`ctx.skills.snaps 系统从缺失根目录最近的现有祖先开始,使用 `fs.watchFile` 每次跟进一层缺失路径片段;真实根目录出现后,再交给 Chokidar。删除根目录后,系统会重新建立祖先观察。Chokidar 配置公开原生事件或轮询模式、写入稳定性、轮询间隔、符号链接跟随选项和项目 watcher 容量。第一方 `write` 和 `edit` 工具观察会同步使相关提供方失效,因此下一个模型步骤无需等待宿主事件投递,就能看到自身改动。watcher 启动或运行失败会使发现结果不完整并触发重试;资源销毁会关闭 watcher,并忽略延迟回调。 -`@deepseek-ai/dsh-tool-skill` 将初始完整目录保存在 `agent/session-prefix` 中。每个模型步骤开始前,它都会针对 `skill` 工具的精确可见性,以及按顺序渲染的名称和描述计算 digest。digest 变化时,插件通过 `agent.inject()` 追加一份持久的完整替换目录;所有 skill 消失时,也会追加显式空目录。记录的消息携带 `{ kind: 'skill-catalog', version: 1, digest }`。恢复后,最新且仍可见的替换是比较基线;如果压缩(compaction)遮蔽了替换消息,模型步骤前的观察会改以 `agent/session-prefix` 为基线,并在必要时重新发布当前完整目录。不完整的快照不会产生替换,并会保留最后一次完整的模型视图。 +`@deepseek-ai/dsh-tool-skill` 将初始完整目录保存在 `agent/session-prefix` 中。每个模型步骤开始前,它都会针对 `skill` 工具的精确可见性,以及按顺序渲染的名称和描述计算 digest。digest 变化时,插件通过 `agent.inject()` 追加一份持久的完整替换目录;所有 skill 消失时,也会追加显式空目录。记录的消息携带 `{ kind: 'skill-catalog', version: 1, digest }`。恢复后,最新且仍可见的替换是比较基线。查找会按索引降序扫描只读事件视图,在找到最新且仍可见的替换时停止,从而避免在每个模型步骤复制整个事件数组。如果压缩(compaction)遮蔽了替换消息,模型步骤前的观察会改以 `agent/session-prefix` 为基线,并在必要时重新发布当前完整目录。不完整的快照不会产生替换,并会保留最后一次完整的模型视图。 TUI 将同一失效通知作为界面状态而非会话历史来消费。`skills/change` 不携带 diff;TUI 会为活动会话的 cwd 重新获取 `snapshot()`,仅应用最新的完整结果,并在观测不完整时保留先前命令。完整的空结果会清除陈旧补全项。pi-tui 在其提供方被替换时会关闭自动补全,因此如果目录在用户输入斜杠命令名称期间到达,还会触发一次仅用于更新建议的当前草稿重查。 diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index 1fe078d75f..56292a80a0 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -269,7 +269,11 @@ function catalogDigest(toolVisible: boolean, skills: SkillSummary[], description function latestVisibleCatalogDigest(agent: Agent): string | undefined { const visible = new Set(agent.session.surface.nodes) - for (const event of [...agent.session.events].reverse()) { + const events = agent.session.events + for (let index = events.length - 1; index >= 0; index -= 1) { + // The loop bounds prove the read-only event view contains this index. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const event = events[index]! if (!visible.has(event.seq) || event.type !== 'user/message' || event.data.source.kind !== 'plugin' From ffd2da6986edeb06393f07d084595fa72735c9cc Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 27 Jul 2026 17:54:53 +0800 Subject: [PATCH 006/189] feat(ui): add request trajectory details --- .../src/client/TrajectoryTable.module.css | 93 ++++++- .../src/client/TrajectoryTable.tsx | 236 +++++++++++++++++- .../client/ui-trajectory/src/client/layout.ts | 6 +- .../src/client/trajectory-record.ts | 4 + 4 files changed, 327 insertions(+), 12 deletions(-) diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css index 7ef67a9d38..1997faaa6f 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css @@ -32,7 +32,7 @@ } .eventColumn { - width: 96px; + width: 104px; } .contentColumn { @@ -91,6 +91,78 @@ background: var(--dsw-alias-interactive-bg-active); } +.requestBoundaryControl { + position: absolute; + z-index: 6; + top: -8px; + left: 6px; + width: 16px; + height: 16px; + padding: 0; + border: 0; + background: transparent; + cursor: pointer; +} + +.requestBoundaryControl::before { + position: absolute; + top: 6px; + left: 6px; + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--dsw-alias-label-caption); + box-shadow: + 0 0 0 2px var(--dsw-alias-bg-layer-1), + 0 0 0 3px transparent; + content: ''; + transition: + background 120ms var(--ds-ease-in-out), + transform 120ms var(--ds-ease-in-out); +} + +.requestBoundaryControl::after { + position: absolute; + top: 2px; + left: 17px; + width: max-content; + padding: 0 4px; + border-radius: 2px; + color: var(--dsw-alias-label-secondary); + background: var(--dsw-alias-bg-layer-1); + content: attr(data-label); + font: 9px/12px var(--ds-font-family-code); + opacity: 0; + pointer-events: none; + transform: translateX(-2px); + transition: + opacity 120ms var(--ds-ease-in-out), + transform 120ms var(--ds-ease-in-out); + user-select: none; + white-space: nowrap; +} + +.requestBoundaryControl:hover::before, +.requestBoundaryControl:focus-visible::before, +.requestBoundaryControl[aria-pressed='true']::before { + background: var(--dsw-alias-label-primary); + transform: scale(1.25); +} + +.requestBoundaryControl:hover::after, +.requestBoundaryControl:focus-visible::after { + opacity: 1; + transform: translateX(0); +} + +.requestBoundaryControl:focus-visible { + outline: none; +} + +.table tbody tr:has(.requestBoundaryControl:hover):not([data-selected='true']) { + background: transparent; +} + .event { position: relative; } @@ -143,7 +215,7 @@ .event { overflow: visible !important; padding-right: 4px !important; - padding-left: 10px !important; + padding-left: 18px !important; } .turnLabel { @@ -475,6 +547,19 @@ color: var(--dsw-alias-label-primary); } +.requestDetailsDot { + flex: none; + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--dsw-alias-label-secondary); +} + +.requestDetailsName { + flex: none; + font: 500 12px/16px var(--ds-font-family-code); +} + .detailsLocation { min-width: 0; overflow: hidden; @@ -610,6 +695,10 @@ align-items: center; } +.overview > .requestTokenDetail dt { + padding-left: 12px; +} + .overview dt { color: var(--dsw-alias-label-tertiary); } diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 2cd39ab5a8..8f24b1de78 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -59,6 +59,12 @@ interface ToolCallTextParts { args?: string } +interface SelectedRequest { + turn: number + number: number + group: string +} + interface DetailsResizeDrag { pointerId: number startX: number @@ -81,6 +87,10 @@ const SYSTEM_PROMPT_TABS: readonly DetailTabItem[] = [ { id: 'system-prompt', label: 'System Prompt' }, { id: 'tools', label: 'Tools' }, ] +const REQUEST_TABS: readonly DetailTabItem[] = [ + { id: 'overview', label: 'Summary' }, + { id: 'timing', label: 'Timing' }, +] type TrajectorySplitStyle = CSSProperties & { '--trajectory-tool-request-width': string @@ -213,6 +223,12 @@ function flattenRecords(turns: readonly TrajectoryTurnModel[]): TableRecord[] { }) } +function requestNumber(group: string): number | undefined { + if (!group.startsWith('Step ')) return undefined + const value = Number(group.slice('Step '.length)) + return Number.isInteger(value) && value > 0 ? value : undefined +} + function summarizeTurn(records: readonly TableRecord[]): string { const userText = records .filter(record => record.cell.kind === 'user') @@ -811,6 +827,25 @@ function RecordTiming({ record }: { record: TableRecord }) { ) } +function RequestTiming({ + assistant, + anchor, +}: { + assistant: TableRecord | undefined + anchor: TableRecord | undefined +}) { + if (assistant !== undefined) return + return ( +
+
+
Started
+ +
+
Duration
+
+ ) +} + function RecordPayload({ record, direction, @@ -983,6 +1018,7 @@ export function TrajectoryTable({ onToggleAssistant, }: TrajectoryTableProps) { const [selectedIndex, setSelectedIndex] = useState(null) + const [selectedRequest, setSelectedRequest] = useState(null) const [activeTab, setActiveTab] = useState('overview') const [thinkingExpanded, setThinkingExpanded] = useState(true) const [detailsWidth, setDetailsWidth] = useState(null) @@ -1000,9 +1036,46 @@ export function TrajectoryTable({ const promptSelected = selectedIndex === SYSTEM_PROMPT_INDEX const selected = allRecords.find(record => record.cell.index === selectedIndex) const selectedState = selected === undefined ? undefined : stateOf(selected) - const selectedTabs = promptSelected - ? SYSTEM_PROMPT_TABS - : selected === undefined ? [] : detailTabs(selected) + const selectedRequestRecords = selectedRequest === null + ? [] + : allRecords.filter(record => + record.turn === selectedRequest.turn + && record.group === selectedRequest.group, + ) + const selectedRequestAssistant = selectedRequestRecords.find( + record => record.cell.kind === 'message', + ) + const selectedRequestAnchor = selectedRequestAssistant ?? selectedRequestRecords[0] + const selectedRequestState: RecordState | undefined = selectedRequest === null + ? undefined + : selectedRequestAssistant?.cell.assistantMetrics?.completedTime === null + ? 'running' + : selectedRequestAssistant === undefined + && selectedRequestRecords.some(record => stateOf(record) === 'running') + ? 'running' + : 'complete' + const selectedRequestToolCalls = selectedRequestRecords.filter( + record => record.cell.kind === 'tool', + ).length + const selectedRequestSubtoolCalls = selectedRequestRecords.filter( + record => record.cell.kind === 'subtool', + ).length + const selectedRequestInputTotal = selectedRequestAssistant !== undefined + && ( + selectedRequestAssistant.cell.input !== undefined + || selectedRequestAssistant.cell.cacheRead !== undefined + || selectedRequestAssistant.cell.cacheWrite !== undefined + ) + ? (selectedRequestAssistant.cell.input ?? 0) + + (selectedRequestAssistant.cell.cacheRead ?? 0) + + (selectedRequestAssistant.cell.cacheWrite ?? 0) + : undefined + const activeTurn = selectedRequest?.turn ?? selected?.turn + const selectedTabs = selectedRequest !== null + ? REQUEST_TABS + : promptSelected + ? SYSTEM_PROMPT_TABS + : selected === undefined ? [] : detailTabs(selected) const selectedParents: ParentRecords = selected === undefined ? {} : parentRecords(allRecords, selected) @@ -1022,6 +1095,7 @@ export function TrajectoryTable({ const selectRecord = (index: number) => { const record = allRecords.find(candidate => candidate.cell.index === index) + setSelectedRequest(null) setSelectedIndex(index) if (record === undefined) return const available = new Set(detailTabs(record).map(tab => tab.id)) @@ -1030,10 +1104,17 @@ export function TrajectoryTable({ } const selectSystemPrompt = () => { + setSelectedRequest(null) setSelectedIndex(SYSTEM_PROMPT_INDEX) activateTab('system-prompt') } + const selectRequest = (request: SelectedRequest) => { + setSelectedIndex(null) + setSelectedRequest(request) + activateTab('overview') + } + const openRecordSummary = (target: TableRecord) => { const targetAt = allRecords.findIndex(record => record.cell.index === target.cell.index) if (collapsedTurns.has(target.turn)) onToggleTurn(target.turn) @@ -1046,6 +1127,7 @@ export function TrajectoryTable({ break } } + setSelectedRequest(null) setSelectedIndex(target.cell.index) activateTab('overview') } @@ -1101,13 +1183,18 @@ export function TrajectoryTable({ ? displayText : [toolCallText.name, toolCallText.args].filter(Boolean).join(' ') const isCollapsedSummary = record.collapsedSummary !== undefined + const request = record.groupStart + && !isCollapsedSummary + && !collapsedTurns.has(record.turn) + ? requestNumber(record.group) + : undefined return ( - {selected?.turn === record.turn && ( + {request !== undefined && ( + @@ -1384,6 +1507,101 @@ export function TrajectoryTable({ role="tabpanel" aria-labelledby={`trajectory-detail-${activeTab}`} > + {selectedRequest !== null + && selectedRequestState !== undefined + && activeTab === 'overview' && ( + <> +
+
+
Status
+
{statusLabel(selectedRequestState)}
+
+
+
Started
+ +
+
+
Duration
+
+ {formatElapsedSeconds( + selectedRequestAssistant?.cell.timeSeconds ?? null, + )} +
+
+ {selectedRequestInputTotal !== undefined && ( +
+
Input
+
{selectedRequestInputTotal} tok
+
+ )} + {selectedRequestAssistant?.cell.cacheRead !== undefined && ( +
+
Cached
+
{selectedRequestAssistant.cell.cacheRead} tok
+
+ )} + {selectedRequestAssistant?.cell.cacheWrite !== undefined && ( +
+
Cache created
+
{selectedRequestAssistant.cell.cacheWrite} tok
+
+ )} + {selectedRequestAssistant?.cell.input !== undefined && ( +
+
Other
+
{selectedRequestAssistant.cell.input} tok
+
+ )} + {selectedRequestAssistant?.cell.output !== undefined && ( +
+
Output
+
{selectedRequestAssistant.cell.output} tok
+
+ )} +
+
Tool calls
+
{selectedRequestToolCalls}
+
+ {selectedRequestSubtoolCalls > 0 && ( +
+
Subtool calls
+
{selectedRequestSubtoolCalls}
+
+ )} +
+
+ {selectedRequestAssistant !== undefined && ( + { openRecordSummary(selectedRequestAssistant) }} + > + + + )} + { activateTab('timing') }}> + + +
+ + )} + {selectedRequest !== null && activeTab === 'timing' && ( + + )} {promptSelected && activeTab === 'system-prompt' && ( prompt === undefined ?

Request header not recorded

diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts index b217c67bd8..fcf92429ff 100644 --- a/packages/client/ui-trajectory/src/client/layout.ts +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -39,6 +39,8 @@ export interface TrajectoryLayoutInput { interface UsageLike { inputTokens?: number + cacheReadTokens?: number + cacheWriteTokens?: number outputTokens?: number reasoningTokens?: number } @@ -498,7 +500,7 @@ function safeImageSource(value: string): string | undefined { function stringifySourceValue(value: unknown): string { const json = JSON.stringify(value, null, 2) - return json ?? String(value) + return json || String(value) } /** @@ -526,6 +528,8 @@ function enclosingUserTurn( function attachUsage(cell: TrajectoryCellProps, usage: UsageLike | undefined): void { if (usage === undefined) return if (usage.inputTokens !== undefined) cell.input = usage.inputTokens + if (usage.cacheReadTokens !== undefined) cell.cacheRead = usage.cacheReadTokens + if (usage.cacheWriteTokens !== undefined) cell.cacheWrite = usage.cacheWriteTokens if (usage.outputTokens !== undefined) cell.output = usage.outputTokens if (usage.reasoningTokens !== undefined) cell.think = usage.reasoningTokens } diff --git a/packages/client/ui-trajectory/src/client/trajectory-record.ts b/packages/client/ui-trajectory/src/client/trajectory-record.ts index 1469adc77a..140ae8fd53 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-record.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-record.ts @@ -60,6 +60,10 @@ export interface TrajectoryCellProps extends HTMLAttributes { startedAt?: number | null /** Message-only prompt token count. */ input?: number + /** Message-only input tokens served from a provider cache. */ + cacheRead?: number + /** Message-only input tokens written into a provider cache. */ + cacheWrite?: number /** Message-only completion token count. */ output?: number /** Message-only reasoning token count. */ From c5be25781cf4ac151457a966dd533c9420f5c4e6 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 27 Jul 2026 18:40:30 +0800 Subject: [PATCH 007/189] feat(ui): expand request trajectory inspection --- packages/client/runtime/src/client/index.ts | 3 +- .../src/client/sessions/conversation.ts | 21 + .../src/client/sessions/fold-adapter.ts | 22 +- .../src/client/TrajectoryGroupHeader.tsx | 2 +- .../src/client/TrajectoryTable.module.css | 20 + .../src/client/TrajectoryTable.tsx | 363 ++++++++++++++---- .../src/client/TrajectoryView.tsx | 121 +++++- .../src/client/WaterfallView.tsx | 2 +- .../client/ui-trajectory/src/client/layout.ts | 115 +++--- .../src/client/trajectory-record.ts | 4 +- 10 files changed, 522 insertions(+), 151 deletions(-) diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index c5fef9cc2e..a47d20c73c 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -28,7 +28,8 @@ export type { EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore, } from './contract/store.ts' export type { - AssistantBlock, AssistantMessageNode, AssistantTiming, CodeSubCall, ComposerPhase, ContextMessageNode, + AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig, + AssistantTiming, CodeSubCall, ComposerPhase, ContextMessageNode, ConversationContext, ConversationContextOriginKind, ConversationNode, ConversationPromptSnapshot, ConversationSnapshot, QueuedMessage, RunningToolCall, SteeringMessageNode, ToolResultNode, UnknownSurfaceNode, UserMessageNode, diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index bd75f83201..942d40ab0d 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -60,6 +60,23 @@ export interface AssistantTiming { completedTime: number } +/** Request configuration recorded in the effective header for one assistant response. */ +export interface AssistantRequestConfig { + provider: string + model: string + thinking?: string + reasoningEffort?: string + temperature?: number + maxTokens?: number + stop?: readonly string[] +} + +/** Stable provider/model identity attached to one assistant response. */ +export interface AssistantProvenanceView { + provider: string + model: string +} + /** A finalized (or interruption-frozen) assistant message. */ export interface AssistantMessageNode { kind: 'assistant' @@ -70,6 +87,8 @@ export interface AssistantMessageNode { step: number blocks: readonly AssistantBlock[] usage?: unknown + provenance?: AssistantProvenanceView + requestConfig?: AssistantRequestConfig /** Timing derived from the recorded step/chunk/message event sequence. */ timing?: AssistantTiming /** Frozen partial of an aborted turn (no finalize ever arrives): rendered with a 已停止 marker. @@ -211,6 +230,8 @@ export type ConversationContextOriginKind = 'compaction' | 'rewind' | 'rewrite' /** Latest complete model request header in force within one context generation. */ export interface ConversationPromptSnapshot { + /** Provider/model and sampling configuration from the latest effective request header. */ + config?: AssistantRequestConfig /** Rendered system prompt text; empty when the request had no system prompt. */ system: string /** Complete tool catalog sent with the request, including tools that were never called. */ diff --git a/packages/client/runtime/src/client/sessions/fold-adapter.ts b/packages/client/runtime/src/client/sessions/fold-adapter.ts index c7ac094017..6f88c678e7 100644 --- a/packages/client/runtime/src/client/sessions/fold-adapter.ts +++ b/packages/client/runtime/src/client/sessions/fold-adapter.ts @@ -12,7 +12,7 @@ import { } from '@deepseek-ai/dsh-session/surface' import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' import type { - AssistantTiming, ConversationContext, ConversationContextOriginKind, ConversationNode, + AssistantRequestConfig, AssistantTiming, ConversationContext, ConversationContextOriginKind, ConversationNode, ConversationPromptSnapshot, } from './conversation.ts' import { toAssistantBlocks } from './conversation.ts' @@ -43,6 +43,7 @@ function materializeNode( callIndex: ReadonlyMap, resultView: ToolResultView | null, assistantTiming?: AssistantTiming, + requestConfig?: AssistantRequestConfig, ): ConversationNode { switch (event.type) { case 'user/message': @@ -64,6 +65,11 @@ function materializeNode( kind: 'assistant', seq: event.seq, time: event.time, turn: event.data.turn, step: event.data.step, blocks: toAssistantBlocks(event.data.content), usage: event.data.usage, + provenance: { + provider: event.data.provenance.provider, + model: event.data.provenance.model, + }, + ...(requestConfig === undefined ? {} : { requestConfig }), ...(assistantTiming !== undefined ? { timing: assistantTiming } : {}), } case 'steering/message': @@ -204,6 +210,7 @@ export class FoldAdapter { this.callIdx, this.resultViews.get(seq) ?? null, event.type === 'assistant/message' ? this.assistantTiming(event) : undefined, + event.type === 'assistant/message' ? this.assistantRequestConfig(event) : undefined, ) this.nodeCache.set(seq, node) out.push(node) @@ -280,6 +287,7 @@ export class FoldAdapter { this.callIdx, this.resultViews.get(seq) ?? null, event.type === 'assistant/message' ? this.assistantTiming(event) : undefined, + event.type === 'assistant/message' ? this.assistantRequestConfig(event) : undefined, ) this.nodeCache.set(seq, node) return node @@ -312,6 +320,17 @@ export class FoldAdapter { return { stepStartTime, firstTokenTime, completedTime: event.time } } + private assistantRequestConfig( + event: SessionEvent<'assistant/message'>, + ): AssistantRequestConfig | undefined { + for (let i = event.seq; i >= this.baseSeq; i--) { + const candidate = this.padded[i] + if (candidate?.type !== 'request/header') continue + return candidate.data.header.config + } + return undefined + } + private indexCall(event: SessionEvent, view?: ToolEventView): void { if (event.type === 'tool/result') { if (view?.for === 'result') this.resultViews.set(event.seq, view.view) @@ -336,6 +355,7 @@ export class FoldAdapter { } if (event.type !== 'request/header') return this.activePrompt = { + config: event.data.header.config, system: event.data.header.system ?? '', tools: event.data.header.tools ?? [], } diff --git a/packages/client/ui-trajectory/src/client/TrajectoryGroupHeader.tsx b/packages/client/ui-trajectory/src/client/TrajectoryGroupHeader.tsx index 90252ce373..1b7b03e78e 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryGroupHeader.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryGroupHeader.tsx @@ -5,7 +5,7 @@ import css from './TrajectoryGroupHeader.module.css' export interface TrajectoryGroupHeaderProps { /** Group title (`Message`, `Step 1`, …). */ title: string - /** Secondary summary (`49s`, `2.2s skill`, …). */ + /** Secondary summary (`49 s`, `2.2 s skill`, …). */ description?: string } diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css index 1997faaa6f..820f7c482b 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css @@ -699,6 +699,26 @@ padding-left: 12px; } +.usagePanel { + padding: 4px 0 10px; +} + +.usageGroup + .usageGroup { + margin-top: 8px; +} + +.usageHeading { + margin: 0; + padding: 4px 14px 1px; + color: var(--dsw-alias-label-secondary); + font: var(--dsw-font-xs-strong-13); + user-select: none; +} + +.usageGroup .overview { + padding: 0; +} + .overview dt { color: var(--dsw-alias-label-tertiary); } diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 8f24b1de78..251adb0d0a 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -5,7 +5,9 @@ import type { CSSProperties, ReactNode } from 'react' import { extractMarkdownPlainText, IconChevronRightOutline14, JsonTree, MarkdownText, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { ConversationPromptSnapshot } from '@deepseek-ai/dsh-client-runtime/client' +import type { + AssistantRequestConfig, ConversationPromptSnapshot, +} from '@deepseek-ai/dsh-client-runtime/client' import type { AssistantMetricDetail, TrajectoryCellKind, TrajectoryCellProps, TrajectorySourceBlock, } from './trajectory-record.ts' @@ -41,6 +43,8 @@ type DetailTab = | 'input' | 'output' | 'schema' + | 'options' + | 'usage' | 'timing' type RecordState = 'complete' | 'running' | 'error' @@ -89,6 +93,8 @@ const SYSTEM_PROMPT_TABS: readonly DetailTabItem[] = [ ] const REQUEST_TABS: readonly DetailTabItem[] = [ { id: 'overview', label: 'Summary' }, + { id: 'options', label: 'Options' }, + { id: 'usage', label: 'Usage' }, { id: 'timing', label: 'Timing' }, ] @@ -172,14 +178,6 @@ function AssistantTimingPanel({ metrics }: { metrics: AssistantMetricDetail }) {
TTFT
{ttft(metrics)}
Generation
{generationTime(metrics)}
Throughput
{throughput(metrics)}
-
-
Output tokens
-
- {!metrics.usageProvided - ? 'Usage unavailable' - : metrics.outputTokens ?? 'Output tokens unavailable'} -
-
) } @@ -188,6 +186,8 @@ function AssistantTimingPanel({ metrics }: { metrics: AssistantMetricDetail }) { export interface TrajectoryTableProps { /** Latest model request header in force for the selected context. */ prompt?: ConversationPromptSnapshot + /** Session-global request numbers for the request groups visible in this context. */ + requestNumbers?: readonly TrajectoryRequestNumber[] /** Grouped records in display order. */ turns: readonly TrajectoryTurnModel[] /** Turn ids whose rows after the first are folded into a summary. */ @@ -200,6 +200,27 @@ export interface TrajectoryTableProps { onToggleAssistant(index: number): void } +/** One context-local request identity paired with its session-global number. */ +export interface TrajectoryRequestNumber { + turn: number + step: number + number: number + provider?: string + model?: string + requestConfig?: AssistantRequestConfig + usage?: TrajectoryUsage + cumulativeUsage?: TrajectoryUsage +} + +/** Disjoint provider token buckets for one request or a session prefix. */ +export interface TrajectoryUsage { + input?: number + cacheRead?: number + cacheWrite?: number + output?: number + reasoning?: number +} + function flattenRecords(turns: readonly TrajectoryTurnModel[]): TableRecord[] { return turns.flatMap((turn) => { let firstInTurn = true @@ -223,12 +244,35 @@ function flattenRecords(turns: readonly TrajectoryTurnModel[]): TableRecord[] { }) } -function requestNumber(group: string): number | undefined { +function requestStep(group: string): number | undefined { if (!group.startsWith('Step ')) return undefined const value = Number(group.slice('Step '.length)) return Number.isInteger(value) && value > 0 ? value : undefined } +function requestKey(turn: number, group: string): string { + return `${turn}\u0000${group}` +} + +function indexRequestNumbers( + records: readonly TableRecord[], + sessionNumbers: readonly TrajectoryRequestNumber[] | undefined, +): ReadonlyMap { + const numbers = new Map() + for (const request of sessionNumbers ?? []) { + numbers.set(requestKey(request.turn, `Step ${request.step}`), request.number) + } + let next = Math.max(0, ...numbers.values()) + 1 + const boundaries = records + .filter(record => record.groupStart && requestStep(record.group) !== undefined) + .sort((left, right) => left.cell.index - right.cell.index) + for (const record of boundaries) { + const key = requestKey(record.turn, record.group) + if (!numbers.has(key)) numbers.set(key, next++) + } + return numbers +} + function summarizeTurn(records: readonly TableRecord[]): string { const userText = records .filter(record => record.cell.kind === 'user') @@ -381,6 +425,94 @@ function tokenSummary(cell: TrajectoryCellProps): ReactNode { ) } +function inputTotal(usage: TrajectoryUsage): number | undefined { + if ( + usage.input === undefined + && usage.cacheRead === undefined + && usage.cacheWrite === undefined + ) return undefined + return (usage.input ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0) +} + +function UsageRows({ usage }: { usage: TrajectoryUsage | undefined }) { + if (usage === undefined) return

Usage not reported

+ const totalInput = inputTotal(usage) + return ( +
+ {totalInput !== undefined && ( +
Input
{totalInput} tok
+ )} + {usage.cacheRead !== undefined && ( +
+
Cached
+
{usage.cacheRead} tok
+
+ )} + {usage.cacheWrite !== undefined && ( +
+
Cache created
+
{usage.cacheWrite} tok
+
+ )} + {usage.input !== undefined && ( +
+
Other
+
{usage.input} tok
+
+ )} + {usage.output !== undefined && ( +
Output
{usage.output} tok
+ )} + {usage.reasoning !== undefined && ( +
+
Reasoning
+
{usage.reasoning} tok
+
+ )} +
+ ) +} + +function RequestUsagePanel({ + usage, + cumulative, +}: { + usage: TrajectoryUsage | undefined + cumulative: TrajectoryUsage | undefined +}) { + return ( +
+
+

This request

+ +
+
+

Session cumulative

+ +
+
+ ) +} + +function RequestOptions({ + options, + preview = false, +}: { + options: AssistantRequestConfig | undefined + preview?: boolean +}) { + if (options === undefined) { + return

Options not recorded

+ } + return ( + + ) +} + function isMarkdownRecord(record: TableRecord): boolean { return record.cell.kind === 'user' || record.cell.kind === 'context' @@ -435,7 +567,6 @@ function detailTabs(record: TableRecord): readonly DetailTabItem[] { { id: 'overview', label: 'Summary' }, { id: 'rendered', label: 'Preview' }, { id: 'source', label: 'Source' }, - { id: 'timing', label: 'Timing' }, ] } return [ @@ -1011,6 +1142,7 @@ function OverviewSection({ */ export function TrajectoryTable({ prompt, + requestNumbers: sessionRequestNumbers, turns, collapsedTurns, onToggleTurn, @@ -1026,6 +1158,7 @@ export function TrajectoryTable({ const detailsResizeDrag = useRef(null) const tabHistory = useRef>(new Set(['overview'])) const allRecords = flattenRecords(turns) + const requestNumbers = indexRequestNumbers(allRecords, sessionRequestNumbers) const turnRecords = collapseTurnRecords(allRecords, collapsedTurns) const records = collapseAssistantRecords(turnRecords, collapsedAssistants) const systemPromptPreview = prompt === undefined @@ -1060,26 +1193,55 @@ export function TrajectoryTable({ const selectedRequestSubtoolCalls = selectedRequestRecords.filter( record => record.cell.kind === 'subtool', ).length - const selectedRequestInputTotal = selectedRequestAssistant !== undefined - && ( - selectedRequestAssistant.cell.input !== undefined - || selectedRequestAssistant.cell.cacheRead !== undefined - || selectedRequestAssistant.cell.cacheWrite !== undefined - ) - ? (selectedRequestAssistant.cell.input ?? 0) - + (selectedRequestAssistant.cell.cacheRead ?? 0) - + (selectedRequestAssistant.cell.cacheWrite ?? 0) - : undefined + const selectedRequestInfo = selectedRequest === null + ? undefined + : sessionRequestNumbers?.find(request => request.number === selectedRequest.number) + const selectedRequestUsage = selectedRequestInfo?.usage ?? ( + selectedRequestAssistant === undefined + ? undefined + : { + ...(selectedRequestAssistant.cell.input === undefined + ? {} + : { input: selectedRequestAssistant.cell.input }), + ...(selectedRequestAssistant.cell.cacheRead === undefined + ? {} + : { cacheRead: selectedRequestAssistant.cell.cacheRead }), + ...(selectedRequestAssistant.cell.cacheWrite === undefined + ? {} + : { cacheWrite: selectedRequestAssistant.cell.cacheWrite }), + ...(selectedRequestAssistant.cell.output === undefined + ? {} + : { output: selectedRequestAssistant.cell.output }), + ...(selectedRequestAssistant.cell.think === undefined + ? {} + : { reasoning: selectedRequestAssistant.cell.think }), + } + ) + const selectedRequestCumulativeUsage = + selectedRequestInfo?.cumulativeUsage ?? selectedRequestUsage + const selectedRequestOptions = selectedRequestInfo?.requestConfig const activeTurn = selectedRequest?.turn ?? selected?.turn const selectedTabs = selectedRequest !== null - ? REQUEST_TABS + ? REQUEST_TABS.filter(tab => tab.id !== 'options' || selectedRequestOptions !== undefined) : promptSelected ? SYSTEM_PROMPT_TABS : selected === undefined ? [] : detailTabs(selected) const selectedParents: ParentRecords = selected === undefined ? {} : parentRecords(allRecords, selected) - const hasSelectedParents = selectedParents.message !== undefined + const selectedAssistantRequest = selected?.cell.kind === 'message' + ? requestNumbers.get(requestKey(selected.turn, selected.group)) + : undefined + const selectedAssistantRequestTarget: SelectedRequest | undefined = + selected !== undefined && selectedAssistantRequest !== undefined + ? { + turn: selected.turn, + number: selectedAssistantRequest, + group: selected.group, + } + : undefined + const hasSelectedHierarchy = selectedAssistantRequestTarget !== undefined + || selectedParents.message !== undefined || selectedParents.tool !== undefined const splitStyle: TrajectorySplitStyle | undefined = toolRequestOffset === null ? undefined @@ -1109,10 +1271,13 @@ export function TrajectoryTable({ activateTab('system-prompt') } - const selectRequest = (request: SelectedRequest) => { + const selectRequest = ( + request: SelectedRequest, + tab: 'overview' | 'timing' = 'overview', + ) => { setSelectedIndex(null) setSelectedRequest(request) - activateTab('overview') + activateTab(tab) } const openRecordSummary = (target: TableRecord) => { @@ -1186,7 +1351,7 @@ export function TrajectoryTable({ const request = record.groupStart && !isCollapsedSummary && !collapsedTurns.has(record.turn) - ? requestNumber(record.group) + ? requestNumbers.get(requestKey(record.turn, record.group)) : undefined return ( Status
{statusLabel(selectedRequestState)}
-
-
Started
- -
-
-
Duration
-
- {formatElapsedSeconds( - selectedRequestAssistant?.cell.timeSeconds ?? null, - )} -
-
- {selectedRequestInputTotal !== undefined && ( + {(selectedRequestInfo?.provider + ?? selectedRequestInfo?.requestConfig?.provider) !== undefined && (
-
Input
-
{selectedRequestInputTotal} tok
+
Provider
+
+ {selectedRequestInfo?.provider + ?? selectedRequestInfo?.requestConfig?.provider} +
)} - {selectedRequestAssistant?.cell.cacheRead !== undefined && ( -
-
Cached
-
{selectedRequestAssistant.cell.cacheRead} tok
-
- )} - {selectedRequestAssistant?.cell.cacheWrite !== undefined && ( -
-
Cache created
-
{selectedRequestAssistant.cell.cacheWrite} tok
-
- )} - {selectedRequestAssistant?.cell.input !== undefined && ( -
-
Other
-
{selectedRequestAssistant.cell.input} tok
-
- )} - {selectedRequestAssistant?.cell.output !== undefined && ( + {(selectedRequestInfo?.model + ?? selectedRequestInfo?.requestConfig?.model) !== undefined && (
-
Output
-
{selectedRequestAssistant.cell.output} tok
+
Model
+
+ {selectedRequestInfo?.model + ?? selectedRequestInfo?.requestConfig?.model} +
)}
@@ -1570,23 +1711,36 @@ export function TrajectoryTable({
{selectedRequestSubtoolCalls}
)} + {selectedRequestAssistant !== undefined && ( +
+
Result
+
+ +
+
+ )}
- {selectedRequestAssistant !== undefined && ( - { openRecordSummary(selectedRequestAssistant) }} - > - + {selectedRequestOptions !== undefined && ( + { activateTab('options') }}> + )} + { activateTab('usage') }}> + + { activateTab('timing') }}> )} + {selectedRequest !== null && activeTab === 'options' && ( + + )} + {selectedRequest !== null && activeTab === 'usage' && ( + + )} {selectedRequest !== null && activeTab === 'timing' && (
- {hasSelectedParents && ( + {hasSelectedHierarchy && (
Hierarchy
+ {selectedAssistantRequestTarget !== undefined && ( + + )} {selectedParents.message !== undefined && (
)} -
Duration
{formatElapsedSeconds(selected.cell.timeSeconds)}
+ {(selected.cell.kind === 'user' || selected.cell.kind === 'context') && ( +
+
Duration
+
{formatElapsedSeconds(selected.cell.timeSeconds)}
+
+ )}
{isMarkdownRecord(selected) @@ -1696,9 +1879,21 @@ export function TrajectoryTable({ )} - { activateTab('timing') }}> - - + {selectedAssistantRequestTarget !== undefined && ( + { + selectRequest(selectedAssistantRequestTarget, 'timing') + }} + > + + + )} + {(selected.cell.kind === 'tool' || selected.cell.kind === 'subtool') && ( + { activateTab('timing') }}> + + + )}
)} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 12bc9841f0..caced65ba8 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -2,15 +2,65 @@ import { useMemo, useState } from 'react' import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { ConversationContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { + AssistantMessageNode, ConversationContext, +} from '@deepseek-ai/dsh-client-runtime/client' import { ContextsPanel, contextLabel } from './ContextsPanel.tsx' -import { TrajectoryTable } from './TrajectoryTable.tsx' +import { + TrajectoryTable, + type TrajectoryRequestNumber, + type TrajectoryUsage, +} from './TrajectoryTable.tsx' import { TrajectoryToolbar } from './TrajectoryToolbar.tsx' import { deriveTrajectoryLayout } from './layout.ts' import css from './views.module.css' const EMPTY_IDS: ReadonlySet = new Set() +interface UsageLike { + inputTokens?: number + cacheReadTokens?: number + cacheWriteTokens?: number + outputTokens?: number + reasoningTokens?: number +} + +function requestUsage(value: unknown): TrajectoryUsage | undefined { + const usage = value as UsageLike | undefined + if (usage === undefined) return undefined + return { + ...(usage.inputTokens === undefined ? {} : { input: usage.inputTokens }), + ...(usage.cacheReadTokens === undefined ? {} : { cacheRead: usage.cacheReadTokens }), + ...(usage.cacheWriteTokens === undefined ? {} : { cacheWrite: usage.cacheWriteTokens }), + ...(usage.outputTokens === undefined ? {} : { output: usage.outputTokens }), + ...(usage.reasoningTokens === undefined ? {} : { reasoning: usage.reasoningTokens }), + } +} + +function addUsage( + total: TrajectoryUsage | undefined, + usage: TrajectoryUsage | undefined, +): TrajectoryUsage | undefined { + if (usage === undefined) return total + return { + ...(total?.input === undefined && usage.input === undefined + ? {} + : { input: (total?.input ?? 0) + (usage.input ?? 0) }), + ...(total?.cacheRead === undefined && usage.cacheRead === undefined + ? {} + : { cacheRead: (total?.cacheRead ?? 0) + (usage.cacheRead ?? 0) }), + ...(total?.cacheWrite === undefined && usage.cacheWrite === undefined + ? {} + : { cacheWrite: (total?.cacheWrite ?? 0) + (usage.cacheWrite ?? 0) }), + ...(total?.output === undefined && usage.output === undefined + ? {} + : { output: (total?.output ?? 0) + (usage.output ?? 0) }), + ...(total?.reasoning === undefined && usage.reasoning === undefined + ? {} + : { reasoning: (total?.reasoning ?? 0) + (usage.reasoning ?? 0) }), + } +} + function currentContextOf(contexts: readonly ConversationContext[]): ConversationContext { const context = contexts.at(-1) if (context === undefined) throw new Error('trajectory context projection must not be empty') @@ -43,6 +93,72 @@ export function TrajectoryView({ useSession }: ConvViewProps) { : contexts.find(context => context.id === selectedContextId) ?? currentContext const viewingCurrent = selectedContext.id === currentContext.id const selectedNodes = viewingCurrent ? nodes : selectedContext.nodes + const requestNumbers = useMemo(() => { + const requestsBySeq = new Map() + for (const context of contexts) { + for (const node of context.nodes) { + if (node.kind !== 'assistant' || node.step <= 0) continue + requestsBySeq.set(node.seq, node) + } + } + for (const node of nodes) { + if (node.kind !== 'assistant' || node.step <= 0) continue + requestsBySeq.set(node.seq, node) + } + const orderedRequests = [...requestsBySeq.values()] + .sort((left, right) => left.seq - right.seq) + const requestBySeq = new Map() + let cumulativeUsage: TrajectoryUsage | undefined + for (const [index, node] of orderedRequests.entries()) { + const usage = requestUsage(node.usage) + cumulativeUsage = addUsage(cumulativeUsage, usage) + requestBySeq.set(node.seq, { + turn: node.turn, + step: node.step, + number: index + 1, + ...(node.provenance?.provider === undefined + ? {} + : { provider: node.provenance.provider }), + ...(node.provenance?.model === undefined + ? {} + : { model: node.provenance.model }), + ...(node.requestConfig === undefined ? {} : { requestConfig: node.requestConfig }), + ...(usage === undefined ? {} : { usage }), + ...(cumulativeUsage === undefined ? {} : { cumulativeUsage }), + }) + } + + const selected: TrajectoryRequestNumber[] = [] + const selectedKeys = new Set() + for (const node of selectedNodes) { + if (node.kind !== 'assistant' || node.step <= 0) continue + const request = requestBySeq.get(node.seq) + if (request === undefined) continue + selected.push(request) + selectedKeys.add(`${node.turn}\u0000${node.step}`) + } + if (viewingCurrent && partial !== null && partial.step > 0) { + const key = `${partial.turn}\u0000${partial.step}` + if (!selectedKeys.has(key)) { + selected.push({ + turn: partial.turn, + step: partial.step, + number: orderedRequests.length + 1, + ...(currentContext.prompt?.config?.provider === undefined + ? {} + : { provider: currentContext.prompt.config.provider }), + ...(currentContext.prompt?.config?.model === undefined + ? {} + : { model: currentContext.prompt.config.model }), + ...(currentContext.prompt?.config === undefined + ? {} + : { requestConfig: currentContext.prompt.config }), + ...(cumulativeUsage === undefined ? {} : { cumulativeUsage }), + }) + } + } + return selected + }, [contexts, currentContext.prompt, nodes, partial, selectedNodes, viewingCurrent]) const collapsedTurns = collapsedTurnsByContext.get(selectedContext.id) ?? EMPTY_IDS const collapsedAssistants = collapsedAssistantsByContext.get(selectedContext.id) ?? EMPTY_IDS const turns = useMemo( @@ -164,6 +280,7 @@ export function TrajectoryView({ useSession }: ConvViewProps) {
diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts index fcf92429ff..b4e4fca6a1 100644 --- a/packages/client/ui-trajectory/src/client/layout.ts +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -53,6 +53,15 @@ interface LaidCell { callId?: string } +interface LaidGroup { + title: string + laid: LaidCell[] +} + +interface TurnBucket { + groups: LaidGroup[] +} + /** * Fold a snapshot into turn → Message/Step groups with expanded cells. * @param input - nodes plus in-flight partial/runningCalls. @@ -70,7 +79,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T const startedAt = finiteTime(call.time) if (startedAt !== null) callStartById.set(call.callId, startedAt) } - const turns = new Map }>() + const turns = new Map() let index = 0 let prevAbsTime: number | null = null let lastAssistantTurn: number | null = null @@ -78,20 +87,31 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T const bucket = (turn: number) => { let entry = turns.get(turn) if (entry === undefined) { - entry = { message: [], steps: new Map() } + entry = { groups: [] } turns.set(turn, entry) } return entry } const pushMessage = (turn: number, laid: LaidCell) => { - bucket(turn).message.push(laid) + const groups = bucket(turn).groups + const last = groups.at(-1) + if (last?.title === 'Message') { + last.laid.push(laid) + return + } + groups.push({ title: 'Message', laid: [laid] }) } - const pushStep = (turn: number, step: number, laid: LaidCell) => { - const steps = bucket(turn).steps - const list = steps.get(step) ?? [] - list.push(laid) - steps.set(step, list) + const pushStep = (turn: number, step: number, laid: readonly LaidCell[]) => { + if (laid.length === 0) return + const groups = bucket(turn).groups + const title = `Step ${step}` + const existing = groups.find(group => group.title === title) + if (existing !== undefined) { + existing.laid.push(...laid) + return + } + groups.push({ title, laid: [...laid] }) } for (let i = 0; i < nodes.length; i++) { @@ -123,10 +143,8 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T expandAssistant(node, index + 1, prevAbsTime, resultByCall, callStartById), codeDispatches, ) - for (const laid of laidList) { - if (node.step > 0) pushStep(node.turn, node.step, laid) - else pushMessage(node.turn, laid) - } + if (node.step > 0) pushStep(node.turn, node.step, laidList) + else for (const laid of laidList) pushMessage(node.turn, laid) const last = laidList[laidList.length - 1] if (last !== undefined) index = last.cell.index prevAbsTime = finiteTime(node.time) ?? prevAbsTime @@ -153,7 +171,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T if (node.kind === 'tool-result') { if (!callEmittedInAssistant(nodes, node.callId)) { const toolName = node.call?.name - pushStep(0, 1, { + const laidList: LaidCell[] = [{ absTime: finiteTime(node.callTime ?? node.time), ...(toolName !== undefined ? { toolName } : {}), callId: node.callId, @@ -172,11 +190,12 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T timeSeconds: durationSeconds(node.time, node.callTime), startedAt: finiteTime(node.callTime), }, - }) + }] for (const laid of expandSubCalls(codeDispatches.get(node.callId), index)) { - pushStep(0, 1, laid) + laidList.push(laid) index = laid.cell.index } + pushStep(0, 1, laidList) } prevAbsTime = finiteTime(node.time) ?? prevAbsTime } @@ -195,10 +214,8 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T callStartById, { streaming: true }, ) - for (const laid of laidList) { - if (partial.step > 0) pushStep(partial.turn, partial.step, laid) - else pushMessage(partial.turn, laid) - } + if (partial.step > 0) pushStep(partial.turn, partial.step, laidList) + else for (const laid of laidList) pushMessage(partial.turn, laid) const last = laidList[laidList.length - 1] if (last !== undefined) index = last.cell.index } @@ -206,7 +223,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T const seenCalls = collectCallIds(turns) for (const call of runningCalls) { if (seenCalls.has(call.callId)) continue - pushStep(call.turn, call.step > 0 ? call.step : 1, { + const laidList: LaidCell[] = [{ absTime: null, toolName: call.name, callId: call.callId, @@ -219,34 +236,27 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T timeSeconds: null, startedAt: finiteTime(call.time), }, - }) + }] for (const laid of expandSubCalls(codeDispatches.get(call.callId), index)) { - pushStep(call.turn, call.step > 0 ? call.step : 1, laid) + laidList.push(laid) index = laid.cell.index } + pushStep(call.turn, call.step > 0 ? call.step : 1, laidList) } // Orphan turn-0 cells (orphaned tools / steering turn 0) fold into Turn 1. const prologue = turns.get(0) if (prologue !== undefined) { turns.delete(0) - const emptyTurn = (): { message: LaidCell[]; steps: Map } => ({ - message: [], - steps: new Map(), - }) + const emptyTurn = (): TurnBucket => ({ groups: [] }) const first = turns.get(1) ?? emptyTurn() - first.message = [...prologue.message, ...first.message] - for (const [step, cells] of prologue.steps) { - const existing = first.steps.get(step) ?? [] - first.steps.set(step, [...cells, ...existing]) - } + first.groups = [...prologue.groups, ...first.groups] turns.set(1, first) } for (const entry of turns.values()) { - for (const laid of entry.message) attachToolSchema(laid, callSchemas) - for (const laid of entry.steps.values()) { - for (const cell of laid) attachToolSchema(cell, callSchemas) + for (const group of entry.groups) { + for (const laid of group.laid) attachToolSchema(laid, callSchemas) } } @@ -267,30 +277,20 @@ function attachToolSchema( function toTurnModel( turn: number, - entry: { message: LaidCell[]; steps: Map }, + entry: TurnBucket, ): TrajectoryTurnModel { - const groups: TrajectoryGroupModel[] = [] - if (entry.message.length > 0) { - const description = groupDescription(entry.message) - groups.push({ - title: 'Message', - ...(description !== undefined ? { description } : {}), - cells: entry.message.map(l => l.cell), - }) - } - for (const step of [...entry.steps.keys()].sort((a, b) => a - b)) { - const laid = entry.steps.get(step) ?? [] + const groups = entry.groups.map(({ title, laid }): TrajectoryGroupModel => { const description = groupDescription(laid) - groups.push({ - title: `Step ${step}`, + return { + title, ...(description !== undefined ? { description } : {}), cells: laid.map(l => l.cell), - }) - } + } + }) return { turn, groups } } -/** Wall-span duration + tool histogram, e.g. `1.5s bash×6`. */ +/** Wall-span duration + tool histogram, e.g. `1.5 s bash×6`. */ function groupDescription(laid: readonly LaidCell[]): string | undefined { const parts: string[] = [] // Tool rows contribute start (absTime) and end (start + own duration) so a @@ -325,8 +325,8 @@ function groupDescription(laid: readonly LaidCell[]): string | undefined { function formatGroupDuration(seconds: number): string | undefined { if (!Number.isFinite(seconds)) return undefined const rounded = Math.round(seconds * 10) / 10 - if (Number.isInteger(rounded)) return `${rounded}s` - return `${rounded.toFixed(1)}s` + if (Number.isInteger(rounded)) return `${rounded} s` + return `${rounded.toFixed(1)} s` } /** Own-duration seconds from two epoch-ms stamps; null when either is unusable. */ @@ -551,15 +551,12 @@ function callEmittedInAssistant(nodes: ConversationSnapshot['nodes'], callId: st } function collectCallIds( - turns: Map }>, + turns: Map, ): Set { const ids = new Set() for (const entry of turns.values()) { - for (const laid of entry.message) { - if (laid.callId !== undefined) ids.add(laid.callId) - } - for (const list of entry.steps.values()) { - for (const laid of list) { + for (const group of entry.groups) { + for (const laid of group.laid) { if (laid.callId !== undefined) ids.add(laid.callId) } } diff --git a/packages/client/ui-trajectory/src/client/trajectory-record.ts b/packages/client/ui-trajectory/src/client/trajectory-record.ts index 140ae8fd53..9e15f61ba1 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-record.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-record.ts @@ -80,6 +80,6 @@ export interface TrajectoryCellProps extends HTMLAttributes { export function formatElapsedSeconds(seconds: number | null): string { if (seconds === null || !Number.isFinite(seconds)) return '—' const rounded = Math.round(seconds * 10) / 10 - if (Number.isInteger(rounded)) return `${rounded}s` - return `${rounded.toFixed(1)}s` + if (Number.isInteger(rounded)) return `${rounded} s` + return `${rounded.toFixed(1)} s` } From b3ea0d987ec63f55158edba2bfa2a5b69e958109 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 28 Jul 2026 09:53:32 +0800 Subject: [PATCH 008/189] feat(ui): complete trajectory request inspection --- ...-27-trajectory-inspection-ledger.i18n.yaml | 4 +- ...2026-07-27-trajectory-inspection-ledger.md | 17 +- ...6-07-27-trajectory-inspection-ledger.zh.md | 17 +- packages/client/runtime/src/client/index.ts | 3 +- .../src/client/sessions/conversation.ts | 63 ++ .../src/client/sessions/fold-adapter.ts | 2 + .../runtime/src/client/sessions/session.ts | 259 +++++++- packages/client/tsdown.client.ts | 2 + .../ui-layout/src/client/AppFrame.module.css | 37 +- .../ui-primitives/src/JsonTree.module.css | 10 +- .../src/markdown/CodeBlock.module.css | 15 + .../ui-primitives/src/markdown/CodeBlock.tsx | 6 +- .../ui-primitives/src/markdown/plain-text.ts | 7 +- packages/client/ui-trajectory/package.json | 3 + .../src/client/ContextsPanel.module.css | 116 ---- .../src/client/ContextsPanel.tsx | 84 --- .../src/client/TrajectoryCell.module.css | 5 + .../src/client/TrajectoryCell.tsx | 4 + .../src/client/TrajectoryTable.module.css | 295 +++++++-- .../src/client/TrajectoryTable.tsx | 587 +++++++++++++----- .../src/client/TrajectoryToolbar.module.css | 32 +- .../src/client/TrajectoryToolbar.tsx | 15 - .../src/client/TrajectoryView.tsx | 312 ++++++---- .../src/client/context-branches.ts | 123 ++++ .../client/ui-trajectory/src/client/layout.ts | 218 ++++++- .../src/client/trajectory-record.ts | 22 +- .../ui-trajectory/src/client/views.module.css | 10 +- packages/compact/compact-basic/src/region.ts | 7 +- .../compact/compact-basic/src/summarizer.ts | 13 +- packages/compact/compact/src/types.ts | 8 +- pnpm-lock.yaml | 4 + scripts/client-bundle-css.spec.ts | 47 ++ 32 files changed, 1692 insertions(+), 655 deletions(-) delete mode 100644 packages/client/ui-trajectory/src/client/ContextsPanel.module.css delete mode 100644 packages/client/ui-trajectory/src/client/ContextsPanel.tsx create mode 100644 packages/client/ui-trajectory/src/client/context-branches.ts create mode 100644 scripts/client-bundle-css.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml index 035cb14e90..9789c8b850 100644 --- a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md -2026-07-27-trajectory-inspection-ledger.md: 30d1a2b0b43c8ca134f934c7197f85ff03c65742 -2026-07-27-trajectory-inspection-ledger.zh.md: 2c8724160ca25a4bd9ae4cdddd5b1e07bc1d808c +2026-07-27-trajectory-inspection-ledger.md: feefac9098a9ddc15b1d636ac9edc010655bd7a7 +2026-07-27-trajectory-inspection-ledger.zh.md: 50da33604a35be2eefe4d6cb4d7b4321427eae99 diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md index 30d1a2b0b4..feefac9098 100644 --- a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md @@ -12,12 +12,13 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested **Render a compact, turn-aware event ledger with a local record inspector, using the existing DeepSeek design system.** -- Turn boundaries are thick rules between record rows, while each Step appears as a compact inline marker on its first record. Individual User, Assistant, Tool, and Subtool events share stable columns for index, event kind, and content; token usage and duration stay in the inspector, a thin timeline rail preserves sequence, and nested subtools receive a small indentation. -- Product prose continues to use the existing sans stack. Record indexes, token counts, durations, group summaries, tool calls, and raw payloads use the existing code stack because they are machine data. -- Existing semantic theme tokens own both light and dark rendering. Neutral borders and surfaces form the structure; business blue is limited to Assistant identity, selection, links, and focus; warning is limited to running work; error is limited to failed work. User and Tool roles do not impersonate runtime states. -- Entity surfaces stay flat and separated by hairline borders. Shadow appears only when the inspector becomes an overlay at narrow widths. -- Selecting a record opens an inspector inside Trajectory with Overview, Input, Output, and Timing tabs. This state is deliberately independent from the conversation-wide Chat details column: it inspects a trajectory record without changing the user's Chat context. -- The three-column ledger reserves its width for record content. At narrow widths the inspector overlays the ledger and remains dismissible by keyboard or pointer. +- The ledger keeps session events in sequence within rewind-delimited branches. Turn boundaries use a slightly heavier rule, the raw Turn id, and a continuous left rail; Request boundaries appear as small points integrated into that structure and use one chronological numbering space across ordinary and compaction requests. +- Event kind and content form the two stable columns. Role tags align toward the content, nested subtools receive a small indentation, and CSS truncation preserves the available preview width. Token usage and duration stay in the inspector. +- Product prose uses the existing sans stack. Turn ids, token counts, durations, tool calls, raw payloads, and other machine data use the existing code stack. +- Existing theme tokens own both light and dark rendering. Neutral borders and surfaces form the structure; distinct low-emphasis role hues support scanning without carrying success or failure meaning, while business blue identifies selection, links, and focus. +- Selecting a record or Request opens an inspector inside Trajectory. Tabs and Summary sections follow the selected entity: Markdown messages expose rendered, source, provenance, and hierarchy views; tools add JSON payload/result and schema views; Requests add options, usage, timing, and result navigation. Images render as media rather than serialized data. +- Turn folding removes all rows after its first record and replaces them with a compact step/tool-call count; Assistant folding applies the same interaction to its tool-call descendants. Global controls fold or expand both levels. +- This local inspector remains independent from the conversation-wide Chat details column. At narrow widths it overlays the ledger and remains dismissible by keyboard or pointer. ## Alternatives considered @@ -25,7 +26,7 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested **Keep one card per Turn and Step.** Rejected: repeated card chrome reduced the number of visible records and made cross-step comparison slower. -**Flatten every record without turn rules or step markers.** Rejected: a trajectory is not merely a log stream; Turn and Step boundaries are essential causal landmarks even when they do not consume dedicated rows. +**Flatten every record without Turn or Request boundaries.** Rejected: a trajectory is not merely a log stream; those boundaries preserve the causal structure without consuming dedicated rows. **Reuse the global Chat details column.** Rejected: it would couple local inspection to conversation navigation and make a row click unexpectedly change another view's state. @@ -33,4 +34,4 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested ## Consequences -Trajectory shows more useful records per viewport while retaining Turn and Step orientation. The main ledger omits token usage and duration so content receives the available width; the local inspector exposes those facts together with full payload and assistant timing. The inspector floats over the table only when a permanent split would make both panes unusable. Focused component tests pin the ledger, fold control, keyboard selection, payload tabs, timing facts, and running/error semantics; the assembled Web snapshot pins the real seeded session with the local inspector open. +Trajectory shows more useful records per viewport while retaining Turn and Request orientation. Context rewrites and compactions remain inline with their surrounding history, while a rewind begins a successor branch that inherits only the retained prefix. The main ledger omits token usage and duration so content receives the available width; the local inspector exposes those facts together with full payloads, provenance, schemas, and request timing. Focused component tests pin projection, folding, selection, entity-specific tabs, and running/error semantics; the assembled Web snapshot pins the ledger and inspector through the real client composition. diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md index 2c8724160c..50da33604a 100644 --- a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md @@ -12,12 +12,13 @@ Status: implemented **使用现有 DeepSeek 设计系统,渲染保留轮次结构的紧凑事件记录表,并提供局部记录检查器。** -- 轮次边界由记录行之间较粗的分割线表示,每个步骤在其首条记录上以紧凑的行内标记呈现。用户、助手、工具和子工具事件共用稳定的索引、事件类型和内容列;token 用量与耗时留在检查器中,细线时间轴保留事件顺序,嵌套子工具则采用小幅缩进。 -- 产品正文继续使用现有无衬线字体栈。记录索引、token 数、耗时、分组摘要、工具调用和原始载荷属于机器数据,因此使用现有代码字体栈。 -- 现有语义主题 token 同时负责亮色和暗色渲染。中性边框与表面构成整体结构;业务蓝色仅用于助手身份、选择状态、链接和焦点;警告色仅用于运行中的工作;错误色仅用于失败的工作。用户和工具角色不借用运行时状态的视觉语义。 -- 各记录表面保持平面化,并以细线边框分隔。只有在窄屏下检查器变为浮层时才使用阴影。 -- 选择记录后,轨迹视图内部会打开包含概览、输入、输出和计时标签页的检查器。该状态有意与会话级 Chat 详情栏相互独立:检查轨迹记录不会改变用户在 Chat 中的上下文。 -- 三列记录表将宽度留给记录内容。在窄屏下,检查器会覆盖在记录表上,并且仍可通过键盘或指针关闭。 +- 记录表在以 `rewind` 划分的分支内按会话事件顺序展示。轮次边界由稍粗的分割线、原始轮次 id 和连续的左侧竖线表示;请求边界以融入该结构的小圆点表示,普通请求与压缩(compaction)请求在整个时间序列中共用一套编号。 +- 事件类型与内容构成两个稳定列。角色标签朝内容侧对齐,嵌套子工具略微缩进,内容预览使用 CSS 截断以适应可用宽度。token 用量和耗时留在检查器中。 +- 产品正文使用现有无衬线字体栈。轮次 id、token 数、耗时、工具调用、原始载荷和其他机器数据使用现有代码字体栈。 +- 现有主题 token 同时负责亮色和暗色渲染。中性边框与表面构成整体结构;区分度较低的角色色帮助扫读而不表达成功或失败语义,业务蓝色则标识选择状态、链接和焦点。 +- 选择记录或请求后,轨迹视图内部会打开检查器,其标签页和概览区域随实体类型变化:Markdown 消息提供渲染、源码、来源和层级视图;工具提供 JSON 载荷/结果和 schema 视图;请求提供选项、用量、计时和结果跳转。图片以媒体形式渲染,而不是显示为序列化数据。 +- 折叠轮次时保留其第一条记录,将后续行替换为紧凑的步骤和工具调用数量;折叠助手时对其工具调用后代应用相同交互。全局控件可以分别折叠或展开这两个层级。 +- 此局部检查器与会话级 Chat 详情栏相互独立。在窄屏下,检查器会覆盖记录表,并且仍可通过键盘或指针关闭。 ## 曾考虑的替代方案 @@ -25,7 +26,7 @@ Status: implemented **每个轮次和步骤保留一张卡片。** 不予采纳:重复的卡片框架减少了可见记录数量,并降低了跨步骤比较的速度。 -**不使用轮次分割线与步骤标记,将所有记录完全扁平化。** 不予采纳:轨迹并非普通日志流;即使轮次与步骤边界不再占用独立行,它们仍是不可缺少的关键因果标记。 +**不使用轮次或请求边界,将所有记录完全扁平化。** 不予采纳:轨迹并非普通日志流;这些边界无需占用独立行,也能保留因果结构。 **复用全局 Chat 详情栏。** 不予采纳:这会让局部检查与会话导航耦合,还会使行点击意外改变另一个视图的状态。 @@ -33,4 +34,4 @@ Status: implemented ## 后果 -轨迹视图在保留轮次与步骤定位的同时,每个视口可以显示更多有效记录。主记录表省略 token 用量和耗时,让内容获得可用宽度;局部检查器则将这些数据与完整载荷、助手计时一并展示。只有固定分栏会让两个面板都无法使用时,检查器才浮在表格之上。针对性组件测试锁定事件记录表、折叠控制、键盘选择、载荷标签页、计时数据和运行/错误语义;组装后的 Web 快照则锁定真实预置会话在局部检查器打开时的渲染结果。 +轨迹视图在保留轮次与请求定位的同时,每个视口可以显示更多有效记录。上下文 `rewrite` 与压缩保持在周边历史中的原始位置,`rewind` 则建立仅继承保留前缀的后继分支。主记录表省略 token 用量和耗时,让内容获得可用宽度;局部检查器展示这些数据以及完整载荷、来源、schema 和请求计时。针对性组件测试锁定投影、折叠、选择、实体特定标签页和运行/错误语义;组装后的 Web 快照则通过真实客户端组合锁定记录表与检查器。 diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index b096a20697..57a1d09aab 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -30,7 +30,8 @@ export type { export type { AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig, AssistantTiming, CodeSubCall, ComposerPhase, ContextMessageNode, - ConversationContext, ConversationContextOriginKind, ConversationNode, ConversationPromptSnapshot, + CompactionRequestView, ConversationContext, ConversationContextOriginKind, + ConversationNode, ConversationPromptChange, ConversationPromptSnapshot, ModelRequestView, ConversationSnapshot, QueuedMessage, RunningToolCall, SteeringMessageNode, TodoItem, ToolResultNode, UnknownSurfaceNode, UserMessageNode, } from './sessions/conversation.ts' diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 2421fe56f4..1deb456913 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -51,6 +51,7 @@ export interface UserMessageNode { time: number content: readonly ContentBlock[] source: unknown + meta?: unknown } /** Recorded boundaries used to derive assistant latency and throughput. */ @@ -67,6 +68,7 @@ export interface AssistantTiming { export interface AssistantRequestConfig { provider: string model: string + purpose?: string thinking?: string reasoningEffort?: string temperature?: number @@ -108,6 +110,7 @@ export interface SteeringMessageNode { turn: number content: readonly ContentBlock[] source: unknown + meta?: unknown } /** A context/system injection surfaced in the flow. */ @@ -241,6 +244,20 @@ export interface ConversationPromptSnapshot { tools: readonly ToolSchema[] } +/** One system-prompt/tool-catalog state that became effective in the request timeline. */ +export interface ConversationPromptChange { + /** Sequence of the request/header event that introduced this state. */ + seq: number + /** Unix epoch ms from the request/header event. */ + time: number + /** How the model-visible system configuration differs from the prior recorded state. */ + kind: 'initial' | 'system' | 'tools' | 'system-and-tools' + /** Complete state effective from this event onward. */ + prompt: ConversationPromptSnapshot + /** State immediately before this change; absent for the initial header. */ + previous?: ConversationPromptSnapshot +} + /** One immutable model-context generation reconstructed from surface replacements. */ export interface ConversationContext { /** Zero-based generation within the session; stable across later appends. */ @@ -259,6 +276,46 @@ export interface ConversationContext { nodes: readonly ConversationNode[] } +/** One auxiliary compaction model request reconstructed from its durable lifecycle events. */ +export interface CompactionRequestView { + startSeq: number + turn: number + startedAt: number + completedAt: number | null + status: 'running' | 'complete' | 'error' + error?: string + summarySeq?: number + replacementSeq?: number + summary?: readonly ContentBlock[] + rawOutput?: readonly ContentBlock[] + provenance?: AssistantProvenanceView + requestConfig?: AssistantRequestConfig + usage?: unknown +} + +/** One ordinary provider-request attempt reconstructed from a durable step boundary. */ +export interface ModelRequestView { + /** Sequence of the step/start event that opened this attempt. */ + startSeq: number + turn: number + step: number + /** Unix epoch ms from step/start. */ + startedAt: number + /** Assistant completion time, or the failed step/end time when no response completed. */ + completedAt: number | null + status: 'running' | 'complete' | 'error' + error?: string + /** Assistant/message sequence when this attempt completed successfully. */ + resultSeq?: number + provenance?: AssistantProvenanceView + requestConfig?: AssistantRequestConfig + usage?: unknown + /** Retry ordinal scheduled after this failed attempt. */ + retry?: number + maxRetries?: number + retryDelayMs?: number +} + /** Send/stop failure surfaced in the input error strip; op picks the user-facing copy (发送失败 vs 停止失败). */ export interface PromptError { op: 'send' | 'stop' @@ -272,6 +329,12 @@ export interface ConversationSnapshot { nodes: readonly ConversationNode[] /** Append-only context generations split at every model-surface replacement. */ contexts?: readonly ConversationContext[] + /** Auxiliary compaction requests, including those without an assistant/message surface node. */ + compactionRequests?: readonly CompactionRequestView[] + /** Ordinary provider requests, including failed attempts that produced no assistant message. */ + requestAttempts?: readonly ModelRequestView[] + /** System-prompt/tool-catalog changes in request order. */ + promptChanges?: readonly ConversationPromptChange[] /** Fold degradation flag (cross-window replace defense): when true, nodes come from the lenient linear scan. */ foldDegraded: boolean partial: PartialAssistant | null diff --git a/packages/client/runtime/src/client/sessions/fold-adapter.ts b/packages/client/runtime/src/client/sessions/fold-adapter.ts index 6f88c678e7..9f49936e10 100644 --- a/packages/client/runtime/src/client/sessions/fold-adapter.ts +++ b/packages/client/runtime/src/client/sessions/fold-adapter.ts @@ -59,6 +59,7 @@ function materializeNode( return { kind: 'user', seq: event.seq, time: event.time, content: event.data.content, source: event.data.source, + meta: event.data.meta, } case 'assistant/message': return { @@ -76,6 +77,7 @@ function materializeNode( return { kind: 'steering', seq: event.seq, time: event.time, turn: event.data.turn, content: event.data.content, source: event.data.source, + meta: event.data.meta, } case 'tool/result': { const call = callIndex.get(String(event.data.callId)) diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 28acf5a859..cc79727f69 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -12,8 +12,9 @@ import type { import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' import type { ObservableSnapshot } from '../contract/store.ts' import type { - CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot, OpenState, - PromptError, QueuedMessage, RunningToolCall, + CodeSubCall, CompactionRequestView, ComposerPhase, ConversationNode, + ConversationPromptChange, ConversationPromptSnapshot, ConversationSnapshot, + ModelRequestView, OpenState, PromptError, QueuedMessage, RunningToolCall, } from './conversation.ts' import type { PendingInteraction } from './pending.ts' import { PendingWait } from './pending.ts' @@ -113,6 +114,11 @@ export class Session implements ObservableSnapshot { private callSchemas = new Map() private callSchemasRev = 0 private callSchemasCache: { rev: number; value: ReadonlyMap } | null = null + private promptChangesRev = 0 + private promptChangesCache: { + rev: number + value: readonly ConversationPromptChange[] + } | null = null private running = false /** * Sticky send marker, private input of the composerPhase derivation: set @@ -671,6 +677,7 @@ export class Session implements ObservableSnapshot { } switch (event.type) { case 'request/header': { + this.promptChangesRev++ this.activeToolSchemas = new Map( (event.data.header.tools ?? []).map(schema => [schema.name, schema]), ) @@ -776,6 +783,7 @@ export class Session implements ObservableSnapshot { this.activeToolSchemas = new Map() this.callSchemas = new Map() this.callSchemasRev++ + this.promptChangesRev++ for (let i = 0; i < this.events.length; i++) { const event = this.events[i] /* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */ @@ -818,11 +826,23 @@ export class Session implements ObservableSnapshot { if (this.queueCache === null || this.queueCache.rev !== this.queueRev) { this.queueCache = { rev: this.queueRev, value: this.queued.map(entry => entry.row) } } + if ( + this.promptChangesCache === null + || this.promptChangesCache.rev !== this.promptChangesRev + ) { + this.promptChangesCache = { + rev: this.promptChangesRev, + value: derivePromptChanges(this.events), + } + } const partial = this.partial?.toPartial() ?? null return { sessionId: this.sessionId, nodes, contexts, + compactionRequests: deriveCompactionRequests(this.events), + requestAttempts: deriveModelRequests(this.events), + promptChanges: this.promptChangesCache.value, foldDegraded: degraded, partial, runningCalls: this.callsCache.value, @@ -862,3 +882,238 @@ function derivePhase(hasContent: boolean, promptAttempted: boolean): ComposerPha if (hasContent) return 'active' return promptAttempted ? 'engaging' : 'blank' } + +interface RetryEvent { + type: 'llm/retry' + seq: number + time: number + data: { + turn: number + step: number + retry: number + maxRetries: number + delayMs: number + failure: { message: string } + } +} + +function modelRequestKey(turn: number, step: number): string { + return `${turn}\u0000${step}` +} + +/** Project every durable step into one provider request, retaining failed retry attempts. */ +function deriveModelRequests(events: readonly SessionEvent[]): readonly ModelRequestView[] { + const requests: ModelRequestView[] = [] + const byStep = new Map() + let activeStep: string | undefined + let activeConfig: ConversationPromptSnapshot['config'] + + const update = (key: string, change: Partial): void => { + const index = byStep.get(key) + if (index === undefined) return + const request = requests[index] + if (request !== undefined) requests[index] = { ...request, ...change } + } + + for (const sourceEvent of events) { + if (sourceEvent.type === 'request/header') { + activeConfig = sourceEvent.data.header.config + if (activeStep !== undefined) update(activeStep, { requestConfig: activeConfig }) + continue + } + if (sourceEvent.type === 'step/start') { + const { turn, step } = sourceEvent.data + const key = modelRequestKey(turn, step) + byStep.set(key, requests.length) + requests.push({ + startSeq: sourceEvent.seq, + turn, + step, + startedAt: sourceEvent.time, + completedAt: null, + status: 'running', + ...(activeConfig === undefined ? {} : { requestConfig: activeConfig }), + }) + activeStep = key + continue + } + if (sourceEvent.type === 'assistant/message') { + const key = modelRequestKey(sourceEvent.data.turn, sourceEvent.data.step) + update(key, { + completedAt: sourceEvent.time, + status: 'complete', + resultSeq: sourceEvent.seq, + provenance: { + provider: sourceEvent.data.provenance.provider, + model: sourceEvent.data.provenance.model, + }, + ...(sourceEvent.data.usage === undefined ? {} : { usage: sourceEvent.data.usage }), + }) + continue + } + if (sourceEvent.type === 'step/end') { + const key = modelRequestKey(sourceEvent.data.turn, sourceEvent.data.step) + const index = byStep.get(key) + const request = index === undefined ? undefined : requests[index] + if (request !== undefined && request.status === 'running') { + requests[index] = { + ...request, + completedAt: sourceEvent.time, + status: 'error', + } + } + if (activeStep === key) activeStep = undefined + continue + } + if ((sourceEvent.type as string) === 'llm/retry') { + const event = sourceEvent as unknown as RetryEvent + update(modelRequestKey(event.data.turn, event.data.step), { + status: 'error', + error: event.data.failure.message, + retry: event.data.retry, + maxRetries: event.data.maxRetries, + retryDelayMs: event.data.delayMs, + }) + continue + } + if (sourceEvent.type !== 'turn/end' || sourceEvent.data.reason.kind !== 'error') continue + const reason = sourceEvent.data.reason + update(modelRequestKey(sourceEvent.data.turn, reason.step), { + status: 'error', + error: 'failure' in reason ? reason.failure.message : reason.message, + }) + } + return requests +} + +interface CompactionStartEvent { + type: 'compact/start' + seq: number + time: number + data: { turn: number } +} + +interface CompactionSummaryEvent { + type: 'compact/summary' + seq: number + time: number + data: { + summary: readonly ContentBlock[] + rawOutput?: readonly ContentBlock[] + provider: string + model: string + maxTokens?: number + usage?: unknown + } +} + +interface CompactionEndEvent { + type: 'compact/end' + seq: number + time: number + data: { turn: number; error?: string } +} + +/** Project log-only compaction request brackets without coupling the client runtime to one backend package. */ +function deriveCompactionRequests(events: readonly SessionEvent[]): readonly CompactionRequestView[] { + const requests: CompactionRequestView[] = [] + let active: CompactionRequestView | undefined + for (const sourceEvent of events) { + const type = sourceEvent.type as string + if (type === 'compact/start') { + const event = sourceEvent as unknown as CompactionStartEvent + active = { + startSeq: event.seq, + turn: event.data.turn, + startedAt: event.time, + completedAt: null, + status: 'running', + } + continue + } + if (type === 'compact/summary' && active !== undefined) { + const event = sourceEvent as unknown as CompactionSummaryEvent + active = { + ...active, + summarySeq: event.seq, + summary: event.data.summary, + ...(event.data.rawOutput === undefined ? {} : { rawOutput: event.data.rawOutput }), + provenance: { + provider: event.data.provider, + model: event.data.model, + }, + requestConfig: { + provider: event.data.provider, + model: event.data.model, + purpose: 'compaction', + ...(event.data.maxTokens === undefined ? {} : { maxTokens: event.data.maxTokens }), + }, + ...(event.data.usage === undefined ? {} : { usage: event.data.usage }), + } + continue + } + if ( + sourceEvent.type === 'user/message' + && active?.summarySeq !== undefined + && isCompactionSource(sourceEvent.data.source) + ) { + active = { ...active, replacementSeq: sourceEvent.seq } + continue + } + if (type !== 'compact/end' || active === undefined) continue + const event = sourceEvent as unknown as CompactionEndEvent + active = { + ...active, + completedAt: event.time, + status: event.data.error === undefined ? 'complete' : 'error', + ...(event.data.error === undefined ? {} : { error: event.data.error }), + } + requests.push(active) + active = undefined + } + if (active !== undefined) requests.push(active) + return requests +} + +/** Project request headers into model-visible system/tool changes only. */ +function derivePromptChanges(events: readonly SessionEvent[]): readonly ConversationPromptChange[] { + const changes: ConversationPromptChange[] = [] + let previous: ConversationPromptSnapshot | undefined + for (const event of events) { + if (event.type !== 'request/header') continue + const prompt: ConversationPromptSnapshot = { + config: event.data.header.config, + system: event.data.header.system ?? '', + tools: event.data.header.tools ?? [], + } + const systemChanged = previous !== undefined && previous.system !== prompt.system + const toolsChanged = previous !== undefined + && JSON.stringify(previous.tools) !== JSON.stringify(prompt.tools) + if (previous === undefined || systemChanged || toolsChanged) { + changes.push({ + seq: event.seq, + time: event.time, + kind: previous === undefined + ? 'initial' + : systemChanged && toolsChanged + ? 'system-and-tools' + : systemChanged + ? 'system' + : 'tools', + prompt, + ...(previous === undefined ? {} : { previous }), + }) + } + previous = prompt + } + return changes +} + +function isCompactionSource(source: unknown): boolean { + return typeof source === 'object' + && source !== null + && 'kind' in source + && source.kind === 'plugin' + && 'plugin' in source + && source.plugin === 'compact' +} diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 9b93feae8b..bf560d4d53 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -6,6 +6,7 @@ * lightningcss inside the bundle: importing `x.module.css` yields the * hashed class map, and the css text auto-injects a