From 6d09b3168d035f7668ea73b745a115be1dc7eaac Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:48:40 +0800 Subject: [PATCH] refactor(ui-conversation): register business node definitions --- packages/client/ui-conversation/package.json | 2 + .../ui-conversation/src/client/apply.ts | 36 +- .../src/client/chat/AssistantMarkdown.tsx | 72 +-- .../src/client/chat/AssistantNodeView.tsx | 32 ++ .../src/client/chat/ChatNodeSeat.tsx | 60 +++ .../src/client/chat/ChatView.module.css | 14 +- .../src/client/chat/ChatView.tsx | 303 ++---------- .../src/client/chat/CommandNodeView.tsx | 40 ++ .../src/client/chat/MessageItem.tsx | 126 ++--- .../src/client/chat/StatsLine.tsx | 4 +- .../client/chat/TurnTailNodeView.module.css | 9 + .../src/client/chat/TurnTailNodeView.tsx | 43 ++ .../src/client/chat/chat-flow.ts | 214 -------- .../client/chat/register-node-renderers.ts | 46 ++ .../src/client/chat/tool-node-reader.ts | 46 ++ .../src/client/chat/turn-assistant.ts | 10 + .../src/client/chat/turn-metrics.ts | 6 +- .../src/client/contract/chat-nodes.ts | 82 ++++ .../src/client/contract/slots.ts | 93 ++-- .../client/conversation-nodes/assistant.ts | 316 ++++++++++++ .../chat-snapshot-builder.ts | 456 ++++++++++++++++++ .../src/client/conversation-nodes/command.ts | 243 ++++++++++ .../src/client/conversation-nodes/common.ts | 55 +++ .../client/conversation-nodes/compaction.ts | 63 +++ .../src/client/conversation-nodes/fallback.ts | 40 ++ .../src/client/conversation-nodes/inbox.ts | 71 +++ .../src/client/conversation-nodes/message.ts | 83 ++++ .../src/client/conversation-nodes/register.ts | 30 ++ .../src/client/conversation-nodes/retry.ts | 116 +++++ .../src/client/conversation-nodes/tool.ts | 271 +++++++++++ .../client/conversation-nodes/turn-error.ts | 112 +++++ .../client/conversation-nodes/turn-tail.ts | 180 +++++++ .../ui-conversation/src/client/index.ts | 19 +- .../src/client/skeleton/ApprovalPanel.tsx | 9 +- .../src/client/skeleton/DetailsPanel.tsx | 27 +- .../client/ui-conversation/src/invariant.ts | 2 +- .../ui-deliverables/src/client/index.ts | 7 +- .../src/client/turn-deliverables.ts | 139 ++++-- packages/client/ui-tool/src/client/apply.ts | 5 +- .../ui-tool/src/client/contract/slots.ts | 4 +- .../ui-tool/src/client/tool/ToolCallTree.tsx | 3 +- 41 files changed, 2744 insertions(+), 745 deletions(-) create mode 100644 packages/client/ui-conversation/src/client/chat/AssistantNodeView.tsx create mode 100644 packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx create mode 100644 packages/client/ui-conversation/src/client/chat/CommandNodeView.tsx create mode 100644 packages/client/ui-conversation/src/client/chat/TurnTailNodeView.module.css create mode 100644 packages/client/ui-conversation/src/client/chat/TurnTailNodeView.tsx delete mode 100644 packages/client/ui-conversation/src/client/chat/chat-flow.ts create mode 100644 packages/client/ui-conversation/src/client/chat/register-node-renderers.ts create mode 100644 packages/client/ui-conversation/src/client/chat/tool-node-reader.ts create mode 100644 packages/client/ui-conversation/src/client/chat/turn-assistant.ts create mode 100644 packages/client/ui-conversation/src/client/contract/chat-nodes.ts create mode 100644 packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts create mode 100644 packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts create mode 100644 packages/client/ui-conversation/src/client/conversation-nodes/command.ts create mode 100644 packages/client/ui-conversation/src/client/conversation-nodes/common.ts create mode 100644 packages/client/ui-conversation/src/client/conversation-nodes/compaction.ts create mode 100644 packages/client/ui-conversation/src/client/conversation-nodes/fallback.ts create mode 100644 packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts create mode 100644 packages/client/ui-conversation/src/client/conversation-nodes/message.ts create mode 100644 packages/client/ui-conversation/src/client/conversation-nodes/register.ts create mode 100644 packages/client/ui-conversation/src/client/conversation-nodes/retry.ts create mode 100644 packages/client/ui-conversation/src/client/conversation-nodes/tool.ts create mode 100644 packages/client/ui-conversation/src/client/conversation-nodes/turn-error.ts create mode 100644 packages/client/ui-conversation/src/client/conversation-nodes/turn-tail.ts diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 2ff87cc0e2..3f5bd8364c 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -44,6 +44,7 @@ "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", "@deepseek-ai/dsh-client-ui-slash": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", + "@deepseek-ai/dsh-compact": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-token-meter": "^0.0.1", "cordis": "^4.0.0-rc.7", @@ -53,6 +54,7 @@ "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-compact": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 0e486e9154..6d8e8e8c67 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -7,8 +7,9 @@ import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type {} from '@deepseek-ai/dsh-client-locale/client' import type { ViewTab } from './contract/views.ts' import type { - ApprovalWait, ChatScrollPosition, ChatViewInjected, ComposerBarInjected, ComposerChainProps, ConversationInjected, - ConversationSessionHeaderInjected, ConversationSessionInjected, DetailsInjected, + ApprovalWait, ChatNodeTurnDataInjected, ChatScrollPosition, ChatViewInjected, ComposerBarInjected, + ComposerChainProps, ConversationInjected, ConversationSessionHeaderInjected, ConversationSessionInjected, + DetailsInjected, } from './contract/slots.ts' import type { InputNotice } from './input/contract.ts' import { createChatStore } from './stores.ts' @@ -30,6 +31,8 @@ import { ConversationRoot } from './skeleton/ConversationRoot.tsx' import { ConversationSession, ConversationSessionHeader } from './skeleton/ConversationSession.tsx' import { DetailsPanel } from './skeleton/DetailsPanel.tsx' import { en, NS, zh, type ConversationKey } from './locales.ts' +import { registerConversationNodes } from './conversation-nodes/register.ts' +import { registerChatNodeRenderers } from './chat/register-node-renderers.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { @@ -39,7 +42,10 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } /** Services required by the conversation plugin. */ -export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale'] +export const inject = [ + 'slots', 'layout', 'sessions', 'workspaces', 'locale', + 'conversationEvents', 'conversationViews', +] // Static no-session sources for the composer-bar hooks compartment: module // constants so the render side's per-source hook cache (observableHook) keeps @@ -63,6 +69,19 @@ const ABSENT_MENU_LAUNCHER = { subscribe: () => () => {}, } +const CHAT_NODE_INJECT: ChatNodeTurnDataInjected = { + hooks: { + turnData: ({ useSession }, nodeKey) => function useTurnData(key) { + return useSession((snapshot) => { + const location = snapshot.chat.nodes.get(nodeKey)?.location + return location?.kind === 'turn' || location?.kind === 'step' + ? location.turn.data.get(key) + : undefined + }) + }, + }, +} + /** Resolve the session-scoped conversation face (scope-addressed send/cancel), failing loud. */ function scopedConversation(sessions: ISessions, id: SessionId): IConversation { const scoped = sessions.scope(id) @@ -86,6 +105,9 @@ export function apply(ctx: Context): void { const layout = ctx.layout const slots = ctx.slots + registerConversationNodes(ctx) + registerChatNodeRenderers(ctx) + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-conversation: dictionaries') // Registration-time text (the view tab label) reads through the bound @@ -296,8 +318,8 @@ export function apply(ctx: Context): void { slots.register({ name: 'conversation.composer', select: selectApproval, priority: 1, locale: NS }, ApprovalPanel) // The chat view: first entry of the ring this package just declared. - // ChatView owns ordered Tool placement but delegates each whole root call - // to ui-tool, which owns root/subcall composition and atomic dispatch. + // ChatView owns only the stable ordered Node list. Business renderers are + // independently keyed behind its one Node seat. slots.register({ name: 'conversation.view', id: 'chat', @@ -305,9 +327,7 @@ export function apply(ctx: Context): void { label: () => t('view.chat'), locale: NS, children: { - 'conversation.chat.tool': { kind: 'single', scope: 'session' }, - 'conversation.chat.commandview': { kind: 'keyed', scope: 'session' }, - 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' }, + 'conversation.chat.node': { kind: 'keyed', scope: 'session', inject: CHAT_NODE_INJECT }, }, store: chatStore, inject: (sessionId: SessionId, actions: BoundActions): ChatViewInjected => { diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index d8883be4cd..6dd9f09b4d 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -11,12 +11,9 @@ import { memo, useMemo } from 'react' import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client' -import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' -import type { ChatViewSlotProps, ChatViewInjected, TurnTailOwnerProps } from '../contract/slots.ts' -import { hasContentText } from './chat-flow.ts' -import { MessageIconActions } from './MessageIconActions.tsx' +import type { ChatViewSlotProps } from '../contract/slots.ts' import { ReasoningRow } from './ReasoningRow.tsx' import css from './AssistantMarkdown.module.css' @@ -25,61 +22,19 @@ export interface AssistantMarkdownProps { streaming: boolean /** Frozen partial of an aborted turn: rendered with a stopped marker. */ interrupted?: boolean | undefined - /** Unix epoch ms for the IconActions clock; omitted while streaming or when - * the parent withholds chrome (mid-turn content assistants and every node - * of a turn that has not ended). */ - time?: number | undefined - /** Turn wall time in ms for the IconActions run-time label; omitted when the - * turn's triggering input is outside the loaded window. */ - runMs?: number | undefined - /** Turn first-step TTFT in ms for the IconActions label; omitted when unrecorded. */ - ttftMs?: number | undefined - /** Turn decode throughput for the IconActions label; omitted when unrecorded. */ - tokensPerSecond?: number | undefined - /** Event sequence used as the fork boundary; omitted while streaming. */ - seq?: number | undefined - /** Fork the session through this finalized message's completed turn when eligible. */ - onFork?: ((seq: number) => void) | undefined - /** Turn-tail slot dispatch share and owner currency; omitted for a mid-turn assistant. */ - turnTail?: (Pick, 'renderSlotChain'> & { owner: TurnTailOwnerProps }) | undefined - /** Prose file-mention factory (the injected face); omitted wherever `turnTail` is. */ - fileMentions?: ChatViewInjected['fileMentions'] | undefined - /** The message is not the transcript tail of a completed turn. */ - forkUnavailable?: boolean | undefined + /** Resolved prose file mentions for this Assistant's closing turn. */ + mentions?: MarkdownFileMentions | undefined /** The owning view's locale seat, passed down as a plain prop. */ t: ChatViewSlotProps['t'] } -/** Joined text blocks for the copy action (reasoning / tool heads stay out). */ -function copyText(blocks: readonly AssistantBlock[]): string { - const parts: string[] = [] - for (const block of blocks) { - if (block.kind === 'text') parts.push(block.text) - } - return parts.join('') -} - /** Reasoning block as the Think variant summary row (figma 39:28304). */ export const AssistantMarkdown = memo(function AssistantMarkdown({ - blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, turnTail, - fileMentions, t, + blocks, streaming, interrupted, mentions, t, }: AssistantMarkdownProps) { // Stable per locale revision (t identity changes on switch): a fresh object // per render would rebuild MarkdownText's component table every chunk. const codeLabels = useMemo(() => ({ copyLabel: t('copy'), copiedLabel: t('copied') }), [t]) - // Mention vocabulary for the closing prose. Keyed on the anchor seq, not the - // growing transcript: a settled turn's produced files are final, and a - // fresh identity per append would discard MarkdownText's cached parse for - // every settled closing message on every stream chunk. The window-prepend - // edge (a mid-turn window start later gaining earlier same-turn writes) - // leaves a mention unlinked until remount — never a wrong link. - const owner = turnTail?.owner - const mentions: MarkdownFileMentions | undefined = useMemo( - () => (owner === undefined ? undefined : fileMentions?.(owner)), - // Deliberately not `owner`: its identity changes per append while the - // seq-addressed vocabulary it yields does not. - [fileMentions, owner?.seq], - ) const last = blocks.length - 1 // Tool-call heads render as tool rows in the chat view's grouping pass, so // a node that is only those heads (or empty) would paint an empty root @@ -88,10 +43,8 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ || interrupted === true || blocks.some(block => block.kind !== 'tool-call') if (!hasVisible) return null - // Footer only under settled content text; Think-only / streaming omit it. - const showActions = !streaming && time !== undefined && hasContentText(blocks) return ( -
+
{blocks.map((block, i) => { switch (block.kind) { @@ -119,21 +72,6 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ })} {interrupted && {t('message.stopped')}}
- {showActions && turnTail?.renderSlotChain('conversation.chat.turnTail', turnTail.owner)} - {showActions && ( - { onFork(seq) }} - branchUnavailable={forkUnavailable} - className={css.actions} - t={t} - /> - )}
) }) diff --git a/packages/client/ui-conversation/src/client/chat/AssistantNodeView.tsx b/packages/client/ui-conversation/src/client/chat/AssistantNodeView.tsx new file mode 100644 index 0000000000..d0f8b33e6f --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/AssistantNodeView.tsx @@ -0,0 +1,32 @@ +import { memo, useMemo } from 'react' +import type { ChatNodeViewProps, TurnTailOwnerProps } from '../contract/slots.ts' +import { AssistantMarkdown } from './AssistantMarkdown.tsx' + +/** Streaming, settled, and interrupted Assistant states share one keyed renderer instance. */ +export const AssistantNodeView = memo(function AssistantNodeView({ + node, useTurnData, openFile, fileMentions, t, +}: ChatNodeViewProps<'assistant-step'>) { + const data = node.data + const turn = node.location.kind === 'turn' || node.location.kind === 'step' + ? node.location.turn + : undefined + const tail = useTurnData('turn-tail') + const owner = useMemo(() => { + if (turn?.status !== 'closed' || data.finalNode === undefined) return undefined + if (tail?.closing?.finalNode.seq !== data.finalNode.seq) return undefined + return { turn, seq: data.finalNode.seq, openFile } + }, [data.finalNode, openFile, tail, turn]) + const mentions = useMemo( + () => owner === undefined ? undefined : fileMentions(owner), + [fileMentions, owner], + ) + return ( + + ) +}) diff --git a/packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx b/packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx new file mode 100644 index 0000000000..1f7a2f7451 --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx @@ -0,0 +1,60 @@ +import { memo, useMemo } from 'react' +import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../contract/slots.ts' +import type { ChatNode } from '../contract/chat-nodes.ts' +import css from './ChatView.module.css' + +interface ChatNodeSeatProps extends ChatNodeOwnerProps { + readonly nodeKey: string + readonly useSession: ChatViewSlotProps['useSession'] + readonly renderSlot: ChatViewSlotProps['renderSlot'] + readonly t: ChatViewSlotProps['t'] +} + +type RoutedChatNodeOwner = { + [Kind in ChatNode['kind']]: ChatNodeOwnerProps & { readonly node: ChatNode } +}[ChatNode['kind']] + +/** Subscribe and dispatch one stable Context key without observing sibling Nodes. */ +export const ChatNodeSeat = memo(function ChatNodeSeat({ + nodeKey, selectedCallId, cwd, openFile, inspectCall, forkAt, + fileMentions, useSession, renderSlot, t, +}: ChatNodeSeatProps) { + const node = useSession(snapshot => snapshot.chat.nodes.get(nodeKey)) + const routedNode = node as ChatNode | undefined + const owner = useMemo(() => node === undefined + ? null + : { + selectedCallId, + cwd, + openFile, + inspectCall, + forkAt, + fileMentions, + }, [node, selectedCallId, cwd, openFile, inspectCall, forkAt, fileMentions]) + if (routedNode === undefined || owner === null) return null + // Runtime dispatch owns the correlation: every Node's discriminant is the + // keyed-slot entry passed alongside that same Node. TypeScript does not + // distribute an object containing a union into a union of objects itself. + const routedOwner = { ...owner, node: routedNode } as RoutedChatNodeOwner + return ( +
+ {renderSlot('conversation.chat.node', routedOwner, { + entryKey: routedNode.kind, + hookContext: nodeKey, + fallback: ( + t('json.truncated', { total })} + /> + ), + })} +
+ ) +}) diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.module.css b/packages/client/ui-conversation/src/client/chat/ChatView.module.css index c600b3b0aa..d16608c856 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.module.css +++ b/packages/client/ui-conversation/src/client/chat/ChatView.module.css @@ -1,6 +1,5 @@ -/* Chat flow: one 16px rhythm everywhere — between blocks (prose <-> tool - runs) via the column gap and between consecutive tool rows via the group - gap. Input padding cap rides the skeleton. Under +/* Chat flow: one 16px rhythm everywhere through the column gap. Input + padding cap rides the skeleton. Under `[data-conversation-scroll]` the column host owns overflow and this view is ordinary flow (see ConversationRoot active-phase rules). */ @@ -51,10 +50,11 @@ min-width: 0; } -.toolGroup { - display: flex; - flex-direction: column; - gap: 16px; +/* A keyed renderer may intentionally decline its row after dispatch (the + completed-turn tail does this when it owns neither actions nor extensions). + An empty flex item must not consume the column gap. */ +.flowItem:empty { + display: none; } .callRow { diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 3636a2a971..44ae6f345a 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -1,41 +1,24 @@ -// ChatView: the default conversation view — message flow with user bubbles, -// assistant narration, tool summary rows grouped into step runs, pending -// cards, paging, and bottom-follow. Session stats live on -// 'conversation.composer.dock' (sticky with the composer). Pure component -// registered directly; its registration declares the whole-Tool -// 'conversation.chat.tool' seat. ui-tool owns root/subcall composition and -// keyed per-tool dispatch behind that boundary. +// ChatView: the default conversation view — one stable keyed parent list over +// final business Nodes, plus paging, pending steering and bottom-follow. +// Each row dispatches through 'conversation.chat.node'; ui-tool owns the +// tool-call renderer and its recursive root/subcall composition. // // Scroll: when nested under `[data-conversation-scroll]` (active conversation // column), that host is the scrollport and this view is flow content; when // mounted alone (unit tests), `.scroll` owns overflow. Bottom-follow and // prepend anchoring always target the resolved scrollport. // -// Render economics (architecture RFC performance model): the list parent -// subscribes to snapshot segments that do NOT change per streaming chunk -// (nodes/runningCalls/pending keep their references across chunk batches), so -// during a token storm only StreamingTail re-renders; history rows hold via -// memo on cache-stable node slices. Selection changes re-render the parent -// map but only rows whose own selected bit flipped. renderSlot is -// entry-identity-stable (framework binding cache), so passing it through -// memoized rows never churns them. +// Render economics: order changes only when rows enter, leave or move. Each +// ChatNodeSeat subscribes to one Node key, so Assistant deltas and Tool +// lifecycle updates replace only their own row without remounting it. -import { - memo, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, -} from 'react' -import type { - CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, ToolCallBlock, ToolResultNode, -} from '@deepseek-ai/dsh-client-runtime/client' -import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import type { ConversationTimelineSnapshot } from '@deepseek-ai/dsh-client-runtime/client' import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' -import { assistantActionsSeqs, assistantBranchSeqs, deriveChatFlow, runningTurnStartTime, type ChatFlowItem } from './chat-flow.ts' -import { AssistantMarkdown } from './AssistantMarkdown.tsx' -import { CompactionCommandCard } from './CompactionCommandCard.tsx' -import { GenericCommandCard } from './GenericCommandCard.tsx' -import { MessageItem, PendingSteeringBubble } from './MessageItem.tsx' +import { PendingSteeringBubble } from './MessageItem.tsx' +import { ChatNodeSeat } from './ChatNodeSeat.tsx' import { formatRunDuration } from './message-chrome.ts' -import { deriveTurnMetrics } from './turn-metrics.ts' import css from './ChatView.module.css' const FOLLOW_THRESHOLD = 24 @@ -98,35 +81,8 @@ function pagingAnchor(list: HTMLElement, scrollport: HTMLElement): HTMLElement | return visibleRows[0] ?? rows[0] ?? null } -type OpenFile = (path: string) => void - -type InspectCall = (callId: string) => void - -/** Declared child-slot render share (stable framework binding). */ -type RenderChatSlot = ChatViewSlotProps['renderSlot'] - type ChatScrollPosition = NonNullable> -/** ui-slots' UseSession is deliberately wide (dependency direction); the - * chat view narrows once to the runtime snapshot the binding actually feeds. */ -type UseConversation = SnapshotSelectorHook - -function treeContainsCall(block: ToolCallBlock, callId: string | undefined): boolean { - return callId !== undefined - && (block.callId === callId || block.subCalls.some(child => treeContainsCall(child, callId))) -} - -function activeRetrySeq(nodes: readonly ConversationNode[], running: boolean): number | null { - if (!running) return null - for (let index = nodes.length - 1; index >= 0; index -= 1) { - const node = nodes[index] - if (node === undefined) continue - if (node.kind === 'model-retry') return node.retryState === 'cancelled' ? null : node.seq - if (node.kind === 'assistant' || node.kind === 'user') return null - } - return null -} - /** Capture a reflow-resistant reader position from the current rendered window. */ function scrollPosition(list: HTMLElement, scrollport: HTMLElement): ChatScrollPosition | null { const row = pagingAnchor(list, scrollport) @@ -139,77 +95,13 @@ function scrollPosition(list: HTMLElement, scrollport: HTMLElement): ChatScrollP } } -/** One ordered root Tool call handed intact to the Tool presentation plugin. */ -const ToolSeat = memo(function ToolSeat({ - renderSlot, callId, toolName, block, openFile, selectedCallId, cwd, inspectCall, -}: { - renderSlot: RenderChatSlot - callId: string - toolName: string - block: ToolResultNode | RunningToolCall - openFile: OpenFile - selectedCallId?: string | undefined - cwd: string | undefined - inspectCall: InspectCall -}) { - const owner = useMemo(() => ({ - callId, toolName, block, selectedCallId, cwd, openFile, inspectCall, - }), [callId, toolName, block, selectedCallId, cwd, openFile, inspectCall]) - return renderSlot('conversation.chat.tool', owner) -}) - -/** Consecutive tool results as one step-run group (uniform 16px rhythm). */ -const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selectedCallId, cwd, inspectCall }: { - renderSlot: RenderChatSlot - results: readonly ToolResultNode[] - openFile: OpenFile - /** Tool ownership resolves whether the selection is this root or one of its children. */ - selectedCallId: string | undefined - /** Session workspace root for path-relative summaries. */ - cwd: string | undefined - inspectCall: InspectCall -}) { - return ( -
- {results.map(node => ( - - ))} -
- ) -}) - -/** One command lifecycle row: keyed dispatch on the command name with the - * generic card as the render-site fallback (zero registration required). A - * run-less cross-window node has no name and always lands on the fallback. */ -const CommandRow = memo(function CommandRow({ renderSlot, node, compaction, t }: { - renderSlot: RenderChatSlot - node: CommandNode - compaction?: Extract - t: ChatViewSlotProps['t'] -}) { - const owner = useMemo(() => ({ node, ...compaction === undefined ? {} : { compaction } }), [compaction, node]) - const fallback = node.name === 'compact' - ? - : - return ( -
- {renderSlot('conversation.chat.commandview', owner, { - entryKey: node.name ?? '', - fallback, - })} -
- ) -}) +function runningTurnStartTime(timeline: ConversationTimelineSnapshot): number | null { + let latest: number | null = null + for (const turn of timeline.turns.values()) { + if (turn.status === 'open' && turn.start !== undefined) latest = turn.start.time + } + return latest +} /** Turn-level model activity label retained across first-token, tool, and streaming phases. */ function TurnStatus({ startTime, t }: { @@ -247,52 +139,32 @@ function TurnStatus({ startTime, t }: { ) } -/** The streaming partial, isolated so chunk batches re-render only this tail; - * the column ResizeObserver owns bottom-follow when its box grows. */ -function StreamingTail({ useSession, t }: { - useSession: UseConversation - t: ChatViewSlotProps['t'] -}) { - const partial = useSession(s => s.partial) - if (partial === null) return null - return -} - /** * The chat view slot entry: pure component over the composed props; each - * ordered root Tool call crosses the declared whole-Tool render seat. + * ordered business Node crosses the keyed renderer seat. */ export function ChatView({ - useSession, useSessions, useStore, renderSlot, renderSlotChain, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, + useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, fileMentions, t, }: ChatViewSlotProps) { - const nodes = useSession(s => s.nodes) - const turnTimings = useSession(s => s.turnTimings) - const turnEnds = useSession(s => s.turnEnds) + const order = useSession(s => s.chat.order) + const nodeStore = useSession(s => s.chat.nodes) + const timeline = useSession(s => s.chat.timeline) const inbox = useSession(s => s.queue) // Workspace root off the session list row: path summaries display relative to it. const cwd = useSessions(s => s.byId[sessionId]?.cwd) const running = useSession(s => s.running) - const runningCalls = useSession(s => s.runningCalls) const openState = useSession(s => s.openState) const openError = useSession(s => s.openError) const hasMore = useSession(s => s.hasMore) const loadingOlder = useSession(s => s.loadingOlder) const selectedCallId = useStore(s => s.selection?.callId) - const items = useMemo(() => deriveChatFlow(nodes), [nodes]) const pendingSteering = useMemo( () => inbox.filter(item => item.placement === 'steering'), [inbox], ) - const activeRetry = useMemo(() => activeRetrySeq(nodes, running), [nodes, running]) - // Only the last content assistant of each completed turn owns IconActions; - // mid-turn text and every node of a running turn omit `time`, so - // AssistantMarkdown stays chrome-free until the answer settles. - const actionSeqs = useMemo(() => assistantActionsSeqs(nodes, turnEnds), [nodes, turnEnds]) - const branchSeqs = useMemo(() => assistantBranchSeqs(nodes, turnEnds), [nodes, turnEnds]) - const runningTurnStart = useMemo(() => runningTurnStartTime(turnTimings), [turnTimings]) - const turnMetrics = useMemo(() => deriveTurnMetrics(nodes), [nodes]) + const runningTurnStart = useMemo(() => runningTurnStartTime(timeline), [timeline]) const listRef = useRef(null) const columnRef = useRef(null) @@ -312,11 +184,12 @@ export function ChatView({ * scrolls the rest of the way to the floor). */ const followSigRef = useRef(null) - const firstSeq = nodes[0]?.seq ?? null - const lastItem = items[items.length - 1] - const lastKey = lastItem?.key ?? null + const firstKey = order[0] + const firstSeq = firstKey === undefined ? null : nodeStore.get(firstKey)?.anchorSeq ?? null + const lastKey = order.at(-1) ?? null + const lastNode = lastKey === null ? undefined : nodeStore.get(lastKey) const lastSteeringId = pendingSteering[pendingSteering.length - 1]?.id ?? null - const followSig = `${openState}:${firstSeq}:${lastKey}:${nodes.length}:${running ? 1 : 0}:${runningCalls.length}:${lastSteeringId ?? ''}` + const followSig = `${openState}:${firstSeq}:${lastKey}:${order.length}:${running ? 1 : 0}:${lastSteeringId ?? ''}` const toBottom = (el: HTMLElement): void => { anchorRef.current = null @@ -377,8 +250,7 @@ export function ChatView({ firstSeqRef.current = firstSeq // Own words must be visible: a new trailing user node force-scrolls // (send lives in the composer, so arrival is detected here, not armed there). - const appendedUser = lastKey !== lastKeyRef.current - && lastItem !== undefined && lastItem.kind === 'node' && lastItem.node.kind === 'user' + const appendedUser = lastKey !== lastKeyRef.current && lastNode?.kind === 'user' const appendedSteering = lastSteeringId !== null && lastSteeringId !== lastSteeringIdRef.current const tipMoved = followSigRef.current !== followSig lastKeyRef.current = lastKey @@ -490,71 +362,6 @@ export function ChatView({ loadOlder() } - const renderItem = (item: ChatFlowItem): ReactNode => { - if (item.kind === 'tool-group') { - return ( - - ) - } - if (item.kind === 'command-compaction') { - return ( - - ) - } - const node: ConversationNode = item.node - if (node.kind === 'assistant') { - const timing = actionSeqs.has(node.seq) ? turnTimings.get(node.turn) : undefined - // Metrics gate on the settled in-window timing: turn/start loaded means - // every step of the turn is loaded, so first-step TTFT is genuine. - const metrics = timing?.endTime === undefined ? undefined : turnMetrics.get(node.turn) - return ( - - ) - } - if (node.kind === 'command') { - return - } - /* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */ - if (node.kind === 'tool-result') return null - return ( - - ) - } - return (
@@ -572,43 +379,21 @@ export function ChatView({
)} - {items.map(item => ( -
- {renderItem(item)} -
+ {order.map(nodeKey => ( + ))} - - {runningCalls.length > 0 && ( -
- {runningCalls.map(call => ( - - ))} -
- )} {/* No pending placeholders: questions (ui-question) and approvals (ApprovalPanel) both take over the composer, so a flow card would double-render the same wait. */} diff --git a/packages/client/ui-conversation/src/client/chat/CommandNodeView.tsx b/packages/client/ui-conversation/src/client/chat/CommandNodeView.tsx new file mode 100644 index 0000000000..a1fc9f197a --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/CommandNodeView.tsx @@ -0,0 +1,40 @@ +import { memo, useMemo } from 'react' +import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' +import type { + ChatNodeViewProps, CommandRowOwnerProps, +} from '../contract/slots.ts' +import { CompactionCommandCard } from './CompactionCommandCard.tsx' +import { GenericCommandCard } from './GenericCommandCard.tsx' +import css from './ChatView.module.css' + +type CommandNodeViewProps = ChatNodeViewProps<'command'> & PropsRenderSlots<'conversation.chat.commandview'> + +/** Ordinary command lifecycle renderer with command-name keyed specialization. */ +export const CommandNodeView = memo(function CommandNodeView({ node, renderSlot, t }: CommandNodeViewProps) { + const command = node.data + const owner = useMemo(() => ({ node: command }), [command]) + return ( +
+ {renderSlot('conversation.chat.commandview', owner, { + entryKey: command.name ?? '', + fallback: , + })} +
+ ) +}) + +/** One integrated `/compact` command and compaction transaction renderer. */ +export const ManualCompactionNodeView = memo(function ManualCompactionNodeView({ + node, t, +}: ChatNodeViewProps<'manual-compaction'>) { + const data = node.data + return ( +
+ +
+ ) +}) diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 30b51b3870..972fba8710 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -7,30 +7,15 @@ import { memo, useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' import type { - CompactionSummaryNode, ContextMessageNode, ModelRetryNode, SteeringMessageNode, - TurnErrorNode, UnknownSurfaceNode, UserMessageNode, + ModelRetryNode, TurnErrorNode, } from '@deepseek-ai/dsh-client-runtime/client' import { JsonBlock, MessageText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' -import type { ChatViewSlotProps } from '../contract/slots.ts' +import type { ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts' import { CompactionItem } from './CompactionItem.tsx' import { ContextInjectionRow } from './ContextInjectionRow.tsx' import { MessageIconActions } from './MessageIconActions.tsx' import css from './MessageItem.module.css' -export interface MessageItemProps { - node: - | UserMessageNode - | SteeringMessageNode - | ContextMessageNode - | CompactionSummaryNode - | ModelRetryNode - | TurnErrorNode - | UnknownSurfaceNode - retryActive?: boolean - /** The owning view's locale seat, passed down as a plain prop. */ - t: ChatViewSlotProps['t'] -} - function contentText(content: readonly unknown[]): { text: string; rest: unknown[] } { const texts: string[] = [] const rest: unknown[] = [] @@ -219,50 +204,69 @@ export function PendingSteeringBubble({ content, t }: { ) } -export const MessageItem = memo(function MessageItem({ - node, retryActive = false, t, -}: MessageItemProps) { - const truncated = (total: number): string => t('json.truncated', { total }) - switch (node.kind) { - case 'user': - case 'steering': - return ( - ( - - )} - /> - ) - case 'context': - return ( - ) { + const data = node.data + return ( + ( + - ) - case 'compaction': - return - case 'model-retry': - return - case 'turn-error': - return - default: - return ( -
- -
- ) - } + )} + /> + ) +}) + +/** Injected-context keyed Chat renderer. */ +export const ContextMessageNodeView = memo(function ContextMessageNodeView({ node, t }: ChatNodeViewProps<'context'>) { + const data = node.data + return ( + + ) +}) + +/** Automatic compaction keyed Chat renderer. */ +export const CompactionNodeView = memo(function CompactionNodeView({ node, t }: ChatNodeViewProps<'compaction'>) { + return +}) + +/** Correlated retry-chain keyed Chat renderer. */ +export const RetryNodeView = memo(function RetryNodeView({ node, t }: ChatNodeViewProps<'model-retry'>) { + const data = node.data + return +}) + +/** Terminal turn-error keyed Chat renderer. */ +export const TurnErrorNodeView = memo(function TurnErrorNodeView({ node, t }: ChatNodeViewProps<'turn-error'>) { + return +}) + +/** Explicit unknown-surface keyed Chat renderer. */ +export const UnknownNodeView = memo(function UnknownNodeView({ node, t }: ChatNodeViewProps<'unknown'>) { + const data = node.data + return ( +
+ t('json.truncated', { total })} + /> +
+ ) }) diff --git a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx index 8e672740cf..8e43f3c0b4 100644 --- a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx +++ b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx @@ -157,9 +157,9 @@ export interface StatsLineProps { } export const StatsLine = memo(function StatsLine({ useSession, useProjection, t }: StatsLineProps) { - const nodes = useSession(s => s.nodes) + const settledNodes = useSession(s => s.chat.legacy.nodes) + const stats = useMemo(() => deriveStats(settledNodes), [settledNodes]) const usage = useProjection('tokenUsage') - const stats = useMemo(() => deriveStats(nodes), [nodes]) // Pipe-separated groups (figma stats strip); a group with no data drops out whole. const groups: string[] = [] if (stats.steps > 0) { diff --git a/packages/client/ui-conversation/src/client/chat/TurnTailNodeView.module.css b/packages/client/ui-conversation/src/client/chat/TurnTailNodeView.module.css new file mode 100644 index 0000000000..831e6e212b --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/TurnTailNodeView.module.css @@ -0,0 +1,9 @@ +.root { + display: flex; + flex-direction: column; + gap: 16px; +} + +.actions { + margin-left: -6px; +} diff --git a/packages/client/ui-conversation/src/client/chat/TurnTailNodeView.tsx b/packages/client/ui-conversation/src/client/chat/TurnTailNodeView.tsx new file mode 100644 index 0000000000..bf48fc75c6 --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/TurnTailNodeView.tsx @@ -0,0 +1,43 @@ +import { memo } from 'react' +import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' +import type { ChatNodeViewProps, TurnTailOwnerProps } from '../contract/slots.ts' +import { MessageIconActions } from './MessageIconActions.tsx' +import { assistantText } from './turn-assistant.ts' +import css from './TurnTailNodeView.module.css' + +type TurnTailNodeViewProps = ChatNodeViewProps<'turn-tail'> & PropsRenderSlots<'conversation.chat.turnTail'> + +/** Turn-local actions and feature tail over the Location index, independent of Assistant placement. */ +export const TurnTailNodeView = memo(function TurnTailNodeView({ + node, openFile, forkAt, renderSlotChain, t, +}: TurnTailNodeViewProps) { + const data = node.data + const turn = node.location.kind === 'turn' || node.location.kind === 'step' + ? node.location.turn + : undefined + if (turn === undefined) return null + const closing = data.closing + const owner: TurnTailOwnerProps = { turn, seq: closing?.finalNode.seq ?? data.seq, openFile } + const tail = renderSlotChain('conversation.chat.turnTail', owner) + if (closing === null) return tail === null ? null :
{tail}
+ const runMs = turn.start === undefined || turn.end === undefined + ? undefined + : Math.max(0, turn.end.time - turn.start.time) + return ( +
+ {tail} + { forkAt(closing.finalNode.seq) }} + branchUnavailable={data.branchUnavailable} + className={css.actions} + t={t} + /> +
+ ) +}) diff --git a/packages/client/ui-conversation/src/client/chat/chat-flow.ts b/packages/client/ui-conversation/src/client/chat/chat-flow.ts deleted file mode 100644 index a9b12cbe2d..0000000000 --- a/packages/client/ui-conversation/src/client/chat/chat-flow.ts +++ /dev/null @@ -1,214 +0,0 @@ -/** - * Chat flow derivation: ConversationSnapshot nodes -> render items. Tool - * results group into consecutive-run tool groups (figma step-summary flow, - * VERTICAL gap10) alternating with narration. Consecutive retry notices - * reuse the first notice's row while projecting the latest retry turn. - * Item identity keys are stable across snapshots so the list parent can - * subscribe to keys only while rows subscribe to content. IconActions ownership - * and completed-turn branch points are derived here too so ChatView and the - * flow share their gates. - */ -import type { - AssistantBlock, CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot, ToolResultNode, -} from '@deepseek-ai/dsh-client-runtime/client' - -/** One renderable flow item; key is the React key and the parent's identity unit. */ -export type ChatFlowItem = - | { kind: 'node'; key: string; node: ConversationNode } - | { kind: 'tool-group'; key: string; results: readonly ToolResultNode[] } - | { - kind: 'command-compaction' - key: string - command: CommandNode - compaction: CompactionSummaryNode - } - -/** Match explicit command outcome references to exactly one compaction checkpoint. */ -function commandCompactionPairs(nodes: readonly ConversationNode[]): { - readonly byCommandId: ReadonlyMap - readonly byCompactionSeq: ReadonlyMap -} { - const commandsBySource = new Map() - for (const node of nodes) { - if (node.kind !== 'command' || node.name !== 'compact' || node.outcome?.kind !== 'success') continue - const source = node.outcome.sourceEventSeq - if (source === undefined) continue - commandsBySource.set(source, commandsBySource.has(source) ? null : node) - } - const compactionsBySummary = new Map() - for (const node of nodes) { - if (node.kind !== 'compaction' || node.summaryEventSeq === null) continue - const summary = node.summaryEventSeq - compactionsBySummary.set(summary, compactionsBySummary.has(summary) ? null : node) - } - const byCommandId = new Map() - const byCompactionSeq = new Map() - for (const [source, command] of commandsBySource) { - const compaction = compactionsBySummary.get(source) - if (command === null || compaction === undefined || compaction === null) continue - byCommandId.set(command.commandId, compaction) - byCompactionSeq.set(compaction.seq, command) - } - return { byCommandId, byCompactionSeq } -} - -/** - * True when the node has model-visible text content worth IconActions chrome. - * Shared with {@link AssistantMarkdown}'s mount gate so ownership and mounting - * cannot diverge. - * @param blocks - assistant blocks of one finalized node. - * @returns Whether any text block carries non-blank content. - */ -export function hasContentText(blocks: readonly AssistantBlock[]): boolean { - return blocks.some(block => block.kind === 'text' && block.text.trim() !== '') -} - -/** An assistant node that renders nothing: only tool-call heads (rows render - * via the grouping pass) and blank text/reasoning. Skipped by the flow so it - * neither costs column gaps nor splits a tool-row run. Interrupted nodes - * always render (the 已停止 marker). */ -function rendersNothing(node: ConversationNode): boolean { - return node.kind === 'assistant' && node.interrupted !== true - && node.blocks.every(b => b.kind === 'tool-call' - || ((b.kind === 'text' || b.kind === 'reasoning') && b.text.trim() === '')) -} - -/** - * Seq set of assistants that own IconActions: the last content-text assistant - * of each *completed* turn. A turn without a `turn/end` in the window is still - * producing steps, so its latest narration is not the settled answer and owns - * nothing; mid-turn narration of a completed turn stays chrome-free too. - * @param nodes - snapshot nodes (surface order). - * @param turnEnds - completed turn boundaries retained from the event window. - * @returns Seq values ChatView may pass as `time` into AssistantMarkdown. - */ -export function assistantActionsSeqs( - nodes: readonly ConversationNode[], - turnEnds: ReadonlyMap, -): ReadonlySet { - const lastByTurn = new Map() - for (const node of nodes) { - if (node.kind !== 'assistant' || !turnEnds.has(node.turn) || !hasContentText(node.blocks)) continue - lastByTurn.set(node.turn, node.seq) - } - return new Set(lastByTurn.values()) -} - -/** - * Exact start time of the latest in-window turn without a matching end time. - * @param turnTimings - In-window turn timings in event order. - * @returns Unix epoch ms, or null when the running turn started outside the window. - */ -export function runningTurnStartTime( - turnTimings: ConversationSnapshot['turnTimings'], -): number | null { - let latest: number | null = null - for (const timing of turnTimings.values()) { - if (timing.endTime === undefined) latest = timing.startTime - } - return latest -} - -/** - * Seq set of assistant answers that may fork: the completed turn's transcript - * tail, when that tail is the turn's own content-text assistant. A later tool, - * reasoning, error, or other transcript node leaves the answer's branch action - * unavailable because the Host would include the whole turn. User and steering - * bubbles carry no branch action at all: a fork at their seq cuts at the same - * `turn/end` as the answer's, so the affordance lives only under the settled - * answer. - * @param nodes - snapshot nodes in event order. - * @param turnEnds - completed turn boundaries retained from the event window. - * @returns Assistant seq values whose visible position matches the fork boundary. - */ -export function assistantBranchSeqs( - nodes: readonly ConversationNode[], - turnEnds: ReadonlyMap, -): ReadonlySet { - const result = new Set() - const boundaries = [...turnEnds].sort((a, b) => a[1] - b[1]) - let nodeIndex = 0 - for (const [turn, endSeq] of boundaries) { - let tail: ConversationNode | undefined - while (nodeIndex < nodes.length) { - const candidate = nodes[nodeIndex] - if (candidate === undefined || candidate.seq > endSeq) break - tail = candidate - nodeIndex++ - } - if (tail?.kind === 'assistant' && tail.turn === turn && hasContentText(tail.blocks)) { - result.add(tail.seq) - } - } - return result -} - -/** - * Group finalized nodes into the step-summary flow. - * @param nodes - snapshot nodes in human-transcript and durable-notice order. - * @returns flow items; consecutive tool results group and retry notices reuse their first key. - */ -export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem[] { - const items: ChatFlowItem[] = [] - const pairs = commandCompactionPairs(nodes) - let group: ToolResultNode[] | null = null - for (const node of nodes) { - if (rendersNothing(node)) continue - if (node.kind === 'command' && pairs.byCommandId.has(node.commandId)) { - continue - } - if (node.kind === 'compaction') { - group = null - const command = pairs.byCompactionSeq.get(node.seq) - if (command !== undefined) { - items.push({ - kind: 'command-compaction', - key: `c${command.commandId}`, - command, - compaction: node, - }) - } else { - items.push({ kind: 'node', key: `n${node.seq}`, node }) - } - continue - } - if (node.kind === 'tool-result') { - if (group === null) { - group = [node] - items.push({ kind: 'tool-group', key: `g${node.seq}`, results: group }) - } else { - group.push(node) - } - } else if (node.kind === 'model-retry') { - group = null - const previous = items[items.length - 1] - if ( - previous?.kind === 'node' - && previous.node.kind === 'model-retry' - ) { - items[items.length - 1] = { ...previous, node } - } else { - items.push({ kind: 'node', key: `n${node.seq}`, node }) - } - } else { - group = null - items.push({ - kind: 'node', - key: node.kind === 'command' && node.name === 'compact' - ? `c${node.commandId}` - : `n${node.seq}`, - node, - }) - } - } - return items -} - -/** - * Key projection for the list parent's selector (content-blind identity). - * @param items - derived flow items. - * @returns joined key string usable with Object.is short-circuiting. - */ -export function flowKeys(items: readonly ChatFlowItem[]): string { - return items.map(i => i.key).join('|') -} diff --git a/packages/client/ui-conversation/src/client/chat/register-node-renderers.ts b/packages/client/ui-conversation/src/client/chat/register-node-renderers.ts new file mode 100644 index 0000000000..8926fc2a8e --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/register-node-renderers.ts @@ -0,0 +1,46 @@ +import type { Context } from 'cordis' +import { NS } from '../locales.ts' +import { AssistantNodeView } from './AssistantNodeView.tsx' +import { CommandNodeView, ManualCompactionNodeView } from './CommandNodeView.tsx' +import { + CompactionNodeView, ContextMessageNodeView, RetryNodeView, TurnErrorNodeView, + UnknownNodeView, UserMessageNodeView, +} from './MessageItem.tsx' +import { TurnTailNodeView } from './TurnTailNodeView.tsx' + +/** + * Register this package's business renderers behind the keyed Chat Node seat. + * @param ctx - owning UI Conversation context. + */ +export function registerChatNodeRenderers(ctx: Context): void { + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( + { name: 'conversation.chat.node', key: 'user', locale: NS }, UserMessageNodeView)) + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( + { name: 'conversation.chat.node', key: 'steering', locale: NS }, UserMessageNodeView)) + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( + { name: 'conversation.chat.node', key: 'context', locale: NS }, ContextMessageNodeView)) + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( + { name: 'conversation.chat.node', key: 'assistant-step', locale: NS }, AssistantNodeView)) + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ + name: 'conversation.chat.node', + key: 'command', + locale: NS, + children: { 'conversation.chat.commandview': { kind: 'keyed', scope: 'session' } }, + }, CommandNodeView)) + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( + { name: 'conversation.chat.node', key: 'manual-compaction', locale: NS }, ManualCompactionNodeView)) + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( + { name: 'conversation.chat.node', key: 'compaction', locale: NS }, CompactionNodeView)) + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( + { name: 'conversation.chat.node', key: 'model-retry', locale: NS }, RetryNodeView)) + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( + { name: 'conversation.chat.node', key: 'turn-error', locale: NS }, TurnErrorNodeView)) + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ + name: 'conversation.chat.node', + key: 'turn-tail', + locale: NS, + children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' } }, + }, TurnTailNodeView)) + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( + { name: 'conversation.chat.node', key: 'unknown', locale: NS }, UnknownNodeView)) +} diff --git a/packages/client/ui-conversation/src/client/chat/tool-node-reader.ts b/packages/client/ui-conversation/src/client/chat/tool-node-reader.ts new file mode 100644 index 0000000000..dca9992b8d --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/tool-node-reader.ts @@ -0,0 +1,46 @@ +import type { + ConversationSnapshot, ToolCallBlock, +} from '@deepseek-ai/dsh-client-runtime/client' +import { conversationContextKey } from '@deepseek-ai/dsh-client-runtime/client' +import type { ChatNode } from '../contract/chat-nodes.ts' + +function toolNode(node: ReturnType): ChatNode<'tool-call'> | undefined { + return node?.kind === 'tool-call' ? node as ChatNode<'tool-call'> : undefined +} + +/** + * Read one root Tool lifecycle through the internal Chat Node index. + * @param snapshot - current Conversation snapshot. + * @param rootCallId - root call identity and Tool Context identity. + * @returns root lifecycle when it is materialized in the current window. + */ +export function rootToolCall( + snapshot: ConversationSnapshot, + rootCallId: string, +): ToolCallBlock | undefined { + return toolNode(snapshot.chat.nodes.get(conversationContextKey('tool-call', rootCallId)))?.data.root +} + +/** + * Find any root or nested Tool lifecycle through the internal Node store. + * @param snapshot - current Conversation snapshot. + * @param callId - root or nested call identity. + * @returns current Tool lifecycle when materialized in the loaded window. + */ +export function findToolCall(snapshot: ConversationSnapshot, callId: string): ToolCallBlock | undefined { + const visit = (block: ToolCallBlock): ToolCallBlock | undefined => { + if (block.callId === callId) return block + for (const child of block.subCalls) { + const found = visit(child) + if (found !== undefined) return found + } + return undefined + } + for (const node of snapshot.chat.nodes.values()) { + const root = toolNode(node)?.data.root + if (root === undefined) continue + const found = visit(root) + if (found !== undefined) return found + } + return undefined +} diff --git a/packages/client/ui-conversation/src/client/chat/turn-assistant.ts b/packages/client/ui-conversation/src/client/chat/turn-assistant.ts new file mode 100644 index 0000000000..2abfff56b3 --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/turn-assistant.ts @@ -0,0 +1,10 @@ +import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client' + +/** + * Collect visible prose from one Assistant lifecycle. + * @param blocks - Assistant content blocks. + * @returns concatenated text blocks. + */ +export function assistantText(blocks: readonly AssistantBlock[]): string { + return blocks.flatMap(block => block.kind === 'text' ? [block.text] : []).join('') +} diff --git a/packages/client/ui-conversation/src/client/chat/turn-metrics.ts b/packages/client/ui-conversation/src/client/chat/turn-metrics.ts index b7cc5ddb72..b93bbfc8eb 100644 --- a/packages/client/ui-conversation/src/client/chat/turn-metrics.ts +++ b/packages/client/ui-conversation/src/client/chat/turn-metrics.ts @@ -1,6 +1,6 @@ // Latency/throughput folds shared by the settled turn footer and StatsLine. -import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client' +import type { AssistantMessageNode, ConversationNode } from '@deepseek-ai/dsh-client-runtime/client' /** Latency and decode-throughput readings for one turn's footer. */ export interface TurnMetrics { @@ -24,7 +24,7 @@ interface UsageLike { outputTokens?: number } -type AssistantNode = Extract +type AssistantNode = AssistantMessageNode function usageOutputTokens(usage: unknown): number | null { if (typeof usage !== 'object' || usage === null) return null @@ -67,7 +67,7 @@ interface TurnFold { * @param nodes - Snapshot nodes of the loaded window. * @returns Turn number → available metrics; turns with none are absent. */ -export function deriveTurnMetrics(nodes: ConversationSnapshot['nodes']): Map { +export function deriveTurnMetrics(nodes: readonly ConversationNode[]): Map { const folds = new Map() for (const node of nodes) { if (node.kind !== 'assistant') continue diff --git a/packages/client/ui-conversation/src/client/contract/chat-nodes.ts b/packages/client/ui-conversation/src/client/contract/chat-nodes.ts new file mode 100644 index 0000000000..3415c502a5 --- /dev/null +++ b/packages/client/ui-conversation/src/client/contract/chat-nodes.ts @@ -0,0 +1,82 @@ +import type { + AssistantBlock, AssistantMessageNode, ChatConversationViewNode, CommandNode, + CompactionSummaryNode, ModelRetryNode, RunningToolCall, ToolCallBlock, +} from '@deepseek-ai/dsh-client-runtime/client' + +/** Merge-extensible payload registry keyed by final Chat renderer kind. */ +export interface ChatNodeDataMap {} + +/** Renderer kinds contributed by the currently installed Chat business modules. */ +export type ChatNodeKind = keyof ChatNodeDataMap & string + +/** Final Chat Node narrowed to one registered renderer kind and payload. */ +export type ChatNode = { + [RegisteredKind in Kind]: ChatConversationViewNode & { + readonly kind: RegisteredKind + readonly data: ChatNodeDataMap[RegisteredKind] + } +}[Kind] + +/** Final Assistant row payload shared by streaming and settled states. */ +export interface AssistantChatData { + readonly status: 'running' | 'settled' | 'interrupted' + readonly turn: number + readonly step: number + readonly blocks: readonly AssistantBlock[] + readonly time: number + readonly usage?: unknown + readonly finalNode?: AssistantMessageNode +} + +/** Settled or interrupted Assistant payload with its durable presentation node. */ +export type FinalAssistantChatData = AssistantChatData & { + readonly finalNode: AssistantMessageNode +} + +/** Root Tool row payload; the root lifecycle owns all recursive subcalls. */ +export interface ToolChatData { + readonly root: ToolCallBlock +} + +/** One manual command and its correlated compaction transaction. */ +export interface ManualCompactionChatData { + readonly command: CommandNode + readonly compaction: CompactionSummaryNode | null +} + +/** One durable retry chain rendered as a single row. */ +export interface RetryChatData { + readonly attempts: readonly ModelRetryNode[] + readonly current: ModelRetryNode +} + +/** Turn-local footer row that owns actions and optional feature contributions. */ +export interface TurnTailChatData { + readonly turn: number + readonly seq: number + readonly time: number + /** Last finalized content-bearing Assistant in this Turn. */ + readonly closing: FinalAssistantChatData | null + /** Whether later Assistant/Step material makes the closing seq non-tail. */ + readonly branchUnavailable: boolean + readonly ttftMs?: number + readonly tokensPerSecond?: number +} + +/** + * Test whether a Tool root has settled. + * @param block - Tool root lifecycle value. + * @returns whether the root carries its final result. + */ +export function isSettledTool(block: ToolCallBlock): block is Extract { + return 'kind' in block +} + +/** + * Test whether a Tool root is still running. + * @param block - Tool root lifecycle value. + * @returns whether the root lacks a final result. + */ +export function isRunningTool(block: ToolCallBlock): block is RunningToolCall { + return !isSettledTool(block) +} diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index a57bcbd5a7..474e7f9db5 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -1,15 +1,21 @@ /** Conversation slot declarations and their composed component props. */ import type { ReactNode, RefObject } from 'react' import type { - InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook, + InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, + SlotHookFactory, SnapshotSelectorHook, } from '@deepseek-ai/dsh-client-ui-slots' -import type { CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' +import type { + CommandNode, CompactionSummaryNode, ConversationSnapshot, ConversationTurnDataMap, + ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, + TurnLocation, WorkspaceId, +} from '@deepseek-ai/dsh-client-runtime/client' import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type { ComposerBlock } from '../input/blocks.ts' import type { ComposerKeyboard, EditSelection, InputActions, InputNotice, InputState } from '../input/contract.ts' import type { createChatStore } from '../stores.ts' import type { ComposerSubmitGesture, InputSubmitMode } from './composer-submission.ts' +import type { ChatNode, ChatNodeKind } from './chat-nodes.ts' import type { CallId, SelectionTarget, ViewTab } from './views.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { @@ -31,13 +37,15 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * conversation snapshot through the standard kit. */ 'conversation.view': { kind: 'list'; scope: 'session'; owner: ConvViewOwnerProps } - /** - * One root Tool call at its ordered ChatFlow position. The chat view owns - * placement; ui-tool owns root/subcall composition and keyed dispatch. - * The filler preserves the call-anchor DOM contract documented by - * {@link ToolTreeOwnerProps} for every root and child wrapper. - */ - 'conversation.chat.tool': { kind: 'single'; scope: 'session'; owner: ToolTreeOwnerProps } + /** Final business node renderer, dispatched by `ChatConversationViewNode.kind`. */ + 'conversation.chat.node': { + kind: 'keyed' + scope: 'session' + owner: ChatNodeOwnerProps + keyProps: { [Kind in ChatNodeKind]: { node: ChatNode } } + hookContext: string + inject: ChatNodeTurnDataInjected + } /** * The chat view's per-command row hole: keyed dispatch on the command * name (`command/run.name`; a run-less cross-window node has none and @@ -171,7 +179,7 @@ export interface ConvViewOwnerProps { export interface ChatFileMentions { /** * Mention vocabulary for the closing message the owner currency names. - * @param owner - Turn-tail owner currency (nodes, closing seq, opener). + * @param owner - Turn-tail owner currency (Turn data, closing seq, opener). * @returns The resolver MarkdownText consumes, or undefined when the turn * produced nothing worth linking. */ @@ -186,14 +194,13 @@ declare module 'cordis' { } /** - * Owner currency of the chat view's turn-tail hole: the finalized snapshot - * and the closing assistant's anchor. Registrants derive their own facts - * from the nodes (the owner never pre-chews a feature's vocabulary), and - * open files through the same opener the tool rows use. + * Owner currency of the chat view's turn-tail hole: the engine-owned Turn and + * the closing assistant's anchor. Registrants read their own typed Turn data + * and open files through the same opener the tool rows use. */ export interface TurnTailOwnerProps { - /** Finalized snapshot nodes in surface order. */ - nodes: readonly ConversationNode[] + /** Engine-owned closing Turn boundary. */ + turn: TurnLocation /** The closing assistant's seq — the anchor the tail renders under. */ seq: number /** @@ -203,34 +210,34 @@ export interface TurnTailOwnerProps { openFile: (path: string) => void } -/** - * Owner currency of the chat view's whole-Tool rendering seat. The filler - * wraps every rendered root and child with `data-chat-anchor-key="call:"` - * and `data-chat-call-id=""`, plus `data-selected="true"` for the selected - * call. ChatView consumes those anchors to restore prepend/paging position. - */ -export interface ToolTreeOwnerProps { - /** Root Tool call identity, stable across running → settled. */ - callId: CallId - /** Root wire Tool name. */ - toolName: string - /** Frozen root call slice: running call or settled result node. */ - block: ToolCallBlock - /** Selected call id; the Tool owner resolves whether it is root or child. */ - selectedCallId?: CallId | undefined - /** Session workspace root; path summaries display relative to it. */ - cwd?: string | undefined - /** - * Open a tool-arg filesystem path with the host OS default application. - * The conversation owner resolves relative paths against the session cwd. - */ - openFile: (path: string) => void - /** - * Jump to any call in this tree in the trajectory view. - */ - inspectCall: (callId: CallId) => void +/** Hook constrained to business data published on the current Chat Node's Turn. */ +export type UseChatNodeTurnData = ( + key: Key, +) => Readonly | undefined + +/** Slot-level Hook factory used by renderers reading their Node's Turn data. */ +export interface ChatNodeTurnDataInjected { + hooks: { + turnData: SlotHookFactory<'conversation.chat.node', UseChatNodeTurnData> + } } +/** Stable owner currency delivered to one keyed Chat business renderer. */ +export interface ChatNodeOwnerProps { + /** Selected Tool call, when the shared details store names one. */ + selectedCallId?: CallId | undefined + /** Session workspace root; Tool summaries display paths relative to it. */ + cwd?: string | undefined + openFile: (path: string) => void + inspectCall: (callId: CallId) => void + forkAt: (seq: number) => void + fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined +} + +/** Full props of one registered keyed Chat business renderer. */ +export type ChatNodeViewProps = + PropsRuntime<'conversation.chat.node', Kind> & PropsLocale<'conversation'> + /** Owner currency of the details panel's Tool output renderer. */ export interface DetailsToolOwnerProps { /** Frozen selected call slice. */ @@ -555,7 +562,7 @@ export interface ChatViewInjected { /** Full chat-view component props: runtime & its Tool/command/tail render shares & store & injected & locale seat. */ export type ChatViewSlotProps = - PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.tool' | 'conversation.chat.commandview' | 'conversation.chat.turnTail'> + PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.node'> & PropsStore & ChatViewInjected & PropsLocale<'conversation'> /** diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts b/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts new file mode 100644 index 0000000000..2bdf960226 --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts @@ -0,0 +1,316 @@ +import type { Context } from 'cordis' +import type { + AssistantBlock, AssistantMessageNode, ConversationLocation, ConversationMatch, + ConversationNodeContext, ConversationNodeDefinition, +} from '@deepseek-ai/dsh-client-runtime/client' +import { + emptyAssistantBlock, isAppendSurfaceEvent, isTokenDelta, toAssistantBlock, toAssistantBlocks, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { AssistantChatData } from '../contract/chat-nodes.ts' +import { chatNode } from './common.ts' + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ChatNodeDataMap { + /** Streaming, settled, or interrupted Assistant step. */ + 'assistant-step': AssistantChatData + } +} + +declare module '@deepseek-ai/dsh-client-runtime/client' { + interface ConversationStepDataMap { + /** Streaming, settled, or interrupted Assistant material for this Step. */ + 'assistant-step': AssistantChatData + } +} + +interface AssistantState { + readonly turn: number + readonly step: number + readonly blocks: readonly (AssistantBlock | undefined)[] + readonly firstVisibleSeq: number | undefined + readonly firstVisibleTime: number | undefined + readonly firstTokenTime: number | undefined + readonly hidden: boolean + readonly final: ConversationMatch | undefined + readonly usage: unknown +} + +function initialState(turn: number, step: number): AssistantState { + return { + turn, + step, + blocks: [], + firstVisibleSeq: undefined, + firstVisibleTime: undefined, + firstTokenTime: undefined, + hidden: false, + final: undefined, + usage: undefined, + } +} + +function compactBlocks(blocks: readonly (AssistantBlock | undefined)[]): AssistantBlock[] { + return blocks.filter((block): block is AssistantBlock => block !== undefined) +} + +function hasVisibleContent(blocks: readonly AssistantBlock[]): boolean { + return blocks.some((block) => { + if (block.kind === 'tool-call') return false + if (block.kind === 'text' || block.kind === 'reasoning') return block.text.trim() !== '' + return true + }) +} + +function hasInterruptionEvidence(blocks: readonly AssistantBlock[]): boolean { + return blocks.some((block) => { + if (block.kind === 'text' || block.kind === 'reasoning') return block.text.trim() !== '' + return true + }) +} + +function resetForRetry(state: AssistantState): AssistantState { + return { + ...initialState(state.turn, state.step), + firstTokenTime: state.firstTokenTime, + hidden: true, + } +} + +function updateChunk(state: AssistantState, match: ConversationMatch): AssistantState { + if (match.event.type !== 'assistant/chunk') return state + const chunk = match.event.data.chunk + const blocks = [...state.blocks] + switch (chunk.type) { + case 'block-start': + blocks[chunk.index] = emptyAssistantBlock(chunk.blockType) + break + case 'text-delta': { + const previous = blocks[chunk.index] + blocks[chunk.index] = { kind: 'text', text: (previous?.kind === 'text' ? previous.text : '') + chunk.text } + break + } + case 'reasoning-delta': { + const previous = blocks[chunk.index] + blocks[chunk.index] = { kind: 'reasoning', text: (previous?.kind === 'reasoning' ? previous.text : '') + chunk.text } + break + } + case 'tool-call-delta': { + const previous = blocks[chunk.index] + const base = previous?.kind === 'tool-call' + ? previous + : { kind: 'tool-call' as const, callId: '', name: '', argsRaw: '' } + blocks[chunk.index] = { + kind: 'tool-call', + callId: base.callId || String(chunk.id), + name: chunk.name ?? base.name, + argsRaw: base.argsRaw + chunk.argumentsDelta, + } + break + } + case 'block-end': + blocks[chunk.index] = toAssistantBlock(chunk.block) + break + case 'usage': + return { ...state, usage: chunk.usage } + default: + return state + } + const visible = hasVisibleContent(compactBlocks(blocks)) + const firstToken = isTokenDelta(chunk) + return { + ...state, + blocks, + hidden: visible ? false : state.hidden, + ...visible && state.firstVisibleSeq === undefined + ? { firstVisibleSeq: match.event.seq, firstVisibleTime: match.event.time } + : {}, + ...firstToken && state.firstTokenTime === undefined + ? { firstTokenTime: match.event.time } + : {}, + } +} + +function closedBoundary(location: ConversationLocation): { seq: number; time: number } | undefined { + if (location.kind === 'step' && location.step.status === 'closed' && location.step.end !== undefined) { + return location.step.end + } + if ((location.kind === 'step' || location.kind === 'turn') + && location.turn.status === 'closed' && location.turn.end !== undefined) { + return location.turn.end + } + return undefined +} + +function finalNode( + state: AssistantState, + context: ConversationNodeContext, +): AssistantMessageNode | undefined { + const final = state.final + if (final?.event.type === 'assistant/message') { + const event = final.event + return { + kind: 'assistant', + seq: event.seq, + time: event.time, + turn: state.turn, + step: state.step, + blocks: toAssistantBlocks(event.data.message.content), + usage: event.data.usage, + timing: { + stepStartTime: context.start?.event.time ?? null, + firstTokenTime: state.firstTokenTime ?? null, + completedTime: event.time, + }, + } + } + const location = context.start?.location ?? context.matches.at(-1)?.location + const boundary = location === undefined ? undefined : closedBoundary(location) + const blocks = compactBlocks(state.blocks) + if (boundary === undefined || !hasInterruptionEvidence(blocks)) return undefined + return { + kind: 'assistant', + seq: boundary.seq - 0.9, + time: boundary.time, + turn: state.turn, + step: state.step, + blocks, + interrupted: true, + } +} + +function fallbackState(context: ConversationNodeContext): AssistantState | undefined { + let state: AssistantState | undefined + for (const match of context.matches) { + if (match.event.type === 'assistant/chunk') { + state ??= initialState(match.event.data.turn, match.event.data.step) + state = updateChunk(state, match) + continue + } + if (match.event.type === 'assistant/message') { + state ??= initialState(match.event.data.turn, match.event.data.step) + state = { + ...state, + blocks: toAssistantBlocks(match.event.data.message.content), + hidden: false, + final: match, + usage: match.event.data.usage, + } + continue + } + if ((match.event.type as string) === 'llm/retry' && state !== undefined) { + state = resetForRetry(state) + } + } + return state +} + +interface AssistantProjection { + readonly data: AssistantChatData + readonly anchorSeq: number + readonly visible: boolean + readonly settled: AssistantMessageNode | undefined +} + +function projectAssistant(context: ConversationNodeContext): AssistantProjection | undefined { + const state = context.state ?? fallbackState(context) + if (state === undefined) return undefined + const settled = finalNode(state, context) + const blocks = settled?.blocks ?? compactBlocks(state.blocks) + const visible = hasVisibleContent(blocks) + const status = settled?.interrupted === true + ? 'interrupted' + : settled === undefined ? 'running' : 'settled' + const anchorSeq = settled?.seq ?? state.firstVisibleSeq ?? context.matches[0]?.event.seq ?? 0 + const time = settled?.time ?? state.firstVisibleTime ?? context.matches[0]?.event.time ?? 0 + return { + anchorSeq, + visible, + settled, + data: { + status, + turn: state.turn, + step: state.step, + blocks, + time, + ...state.usage === undefined ? {} : { usage: state.usage }, + ...settled === undefined ? {} : { finalNode: settled }, + }, + } +} + +/** Per-step Assistant streaming/final/interruption Definition. */ +export const assistantDefinition: ConversationNodeDefinition = { + kind: 'assistant-step', + match: (event) => { + if (event.type === 'step/start') return { id: `${event.data.turn}:${event.data.step}`, role: 'start' } + if (event.type === 'assistant/chunk' + || (event.type === 'assistant/message' && isAppendSurfaceEvent(event))) { + return { id: `${event.data.turn}:${event.data.step}`, role: 'update' } + } + if ((event.type as string) === 'llm/retry') { + const data = event.data as unknown as { turn: number; step: number } + return { id: `${data.turn}:${data.step}`, role: 'update' } + } + return null + }, + start: (_context, match) => { + if (match.event.type !== 'step/start') throw new Error('assistant-step start requires step/start') + return initialState(match.event.data.turn, match.event.data.step) + }, + update: (context, match) => { + if (match.event.type === 'assistant/chunk') return updateChunk(context.state, match) + if (match.event.type === 'assistant/message') { + return { + ...context.state, + blocks: toAssistantBlocks(match.event.data.message.content), + hidden: false, + final: match, + usage: match.event.data.usage, + } + } + if ((match.event.type as string) === 'llm/retry') { + return resetForRetry(context.state) + } + return context.state + }, + publication: (match) => { + if (match.event.type === 'step/start') return 'none' + if (match.event.type !== 'assistant/chunk') return 'immediate' + const type = match.event.data.chunk.type + return type === 'usage' || type === 'finish' ? 'none' : 'animation-frame' + }, + buildLocationData: (context, scope) => { + if (scope !== 'step') return null + const projected = projectAssistant(context) + if (projected === undefined) return null + return { + kind: 'step', + turn: projected.data.turn, + step: projected.data.step, + key: 'assistant-step', + value: projected.data, + } + }, + buildViewNode: (context, target) => { + if (target !== 'chat') return null + const projected = projectAssistant(context) + if (projected === undefined) return null + if (projected.settled === undefined && !projected.visible) { + const state = context.state ?? fallbackState(context) + if (state === undefined) return null + const current = context.current.get('chat') + if (!state.hidden || current === undefined || current === null) return null + } + return chatNode(context, 'assistant-step', projected.anchorSeq, projected.data, { + visibility: projected.settled?.interrupted === true || projected.visible ? 'visible' : 'hidden', + }) + }, +} + +/** + * Register the Assistant lifecycle business contribution. + * @param ctx - owning UI Conversation context. + */ +export function registerAssistantConversationNode(ctx: Context): void { + ctx.conversationEvents.register(assistantDefinition) +} diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts b/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts new file mode 100644 index 0000000000..f9491d2d16 --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts @@ -0,0 +1,456 @@ +import type { Context } from 'cordis' +import type { + ChatConversationViewNode, ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot, + ConversationLocation, ConversationNode, ConversationTimelineSnapshot, + ConversationViewBuilder, ConversationViewDefinition, LegacyConversationSlice, + PartialAssistant, RunningToolCall, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { ChatNode } from '../contract/chat-nodes.ts' +import { isRunningTool } from '../contract/chat-nodes.ts' + +const EMPTY_KEYS: readonly string[] = [] +const EMPTY_TURNS: readonly number[] = [] +const EMPTY_LIST: readonly never[] = [] + +function sameReferences(left: readonly T[], right: readonly T[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]) +} + +class MutableChatNodeStore implements ChatNodeStore { + private readonly byKey = new Map() + private valuesCache: readonly ChatConversationViewNode[] = EMPTY_LIST + private valuesDirty = false + + get(key: string): ChatConversationViewNode | undefined { + return this.byKey.get(key) + } + + values(): readonly ChatConversationViewNode[] { + if (this.valuesDirty) { + this.valuesCache = [...this.byKey.values()] + this.valuesDirty = false + } + return this.valuesCache + } + + replace(nodes: readonly ChatConversationViewNode[]): void { + this.byKey.clear() + for (const node of nodes) this.byKey.set(node.key, node) + this.valuesCache = [...this.byKey.values()] + this.valuesDirty = false + } + + upsert(nodes: readonly ChatConversationViewNode[]): void { + let changed = false + for (const node of nodes) { + if (this.byKey.get(node.key) === node) continue + this.byKey.set(node.key, node) + changed = true + } + if (changed) this.valuesDirty = true + } +} + +class MutableChatLocationIndex implements ChatLocationNodeIndex { + private turns = new Map() + private steps = new Map() + + getTurn(turn: number): readonly string[] { + return this.turns.get(turn) ?? EMPTY_KEYS + } + + getStep(turn: number, step: number): readonly string[] { + return this.steps.get(stepKey(turn, step)) ?? EMPTY_KEYS + } + + rebuild(order: readonly string[], store: ChatNodeStore): void { + const turns = new Map() + const steps = new Map() + for (const key of order) { + const location = store.get(key)?.location + if (location === undefined) continue + const coordinates = locationCoordinates(location) + if (coordinates.turn === undefined) continue + const turnKeys = turns.get(coordinates.turn) ?? [] + turnKeys.push(key) + turns.set(coordinates.turn, turnKeys) + if (coordinates.step === undefined) continue + const step = stepKey(coordinates.turn, coordinates.step) + const stepKeys = steps.get(step) ?? [] + stepKeys.push(key) + steps.set(step, stepKeys) + } + this.turns = updateIndex(this.turns, turns) + this.steps = updateIndex(this.steps, steps) + } + + /** Invalidate aggregate readers when member data changes without moving. */ + touch(nodes: readonly ChatConversationViewNode[]): void { + const turns = new Set() + const steps = new Set() + for (const node of nodes) { + const coordinates = locationCoordinates(node.location) + if (coordinates.turn === undefined || !this.turns.get(coordinates.turn)?.includes(node.key)) continue + turns.add(coordinates.turn) + if (coordinates.step !== undefined) steps.add(stepKey(coordinates.turn, coordinates.step)) + } + for (const turn of turns) { + const keys = this.turns.get(turn) + if (keys === undefined) continue + this.turns.set(turn, [...keys]) + } + for (const step of steps) { + const keys = this.steps.get(step) + if (keys === undefined) continue + this.steps.set(step, [...keys]) + } + } +} + +function updateIndex( + previous: ReadonlyMap, + nextMutable: ReadonlyMap, +): Map { + const next = new Map() + const keys = new Set([...previous.keys(), ...nextMutable.keys()]) + for (const key of keys) { + const before = previous.get(key) ?? EMPTY_KEYS + const candidate = nextMutable.get(key) ?? EMPTY_KEYS + const value = sameReferences(before, candidate) ? before : candidate + if (candidate.length > 0) next.set(key, value) + } + return next +} + +function stepKey(turn: number, step: number): string { + return `${turn}:${step}` +} + +function locationCoordinates(location: ConversationLocation): { turn?: number; step?: number } { + if (location.kind === 'step') return { turn: location.turn.turn, step: location.step.step } + if (location.kind === 'turn') return { turn: location.turn.turn } + return {} +} + +function orderedVisible(nodes: readonly ChatConversationViewNode[]): ChatConversationViewNode[] { + return nodes + .filter(node => node.visibility === 'visible') + .sort((left, right) => left.anchorSeq - right.anchorSeq || left.key.localeCompare(right.key)) +} + +interface LegacyContribution { + readonly anchorSeq: number + readonly nodes: readonly ConversationNode[] + readonly partial: PartialAssistant | null + readonly running: RunningToolCall | null +} + +const EMPTY_CONTRIBUTION: LegacyContribution = { + anchorSeq: 0, + nodes: EMPTY_LIST, + partial: null, + running: null, +} + +function legacyContribution(raw: ChatConversationViewNode): LegacyContribution { + const node = raw as ChatNode + if (raw.visibility !== 'visible' && node.kind !== 'assistant-step') return EMPTY_CONTRIBUTION + switch (node.kind) { + case 'user': + case 'steering': + case 'context': + case 'command': + case 'compaction': + case 'turn-error': + case 'unknown': + return { anchorSeq: node.anchorSeq, nodes: [node.data], partial: null, running: null } + case 'assistant-step': { + const data = node.data + if (data.status === 'running') { + if (raw.visibility !== 'visible') return EMPTY_CONTRIBUTION + return { + anchorSeq: node.anchorSeq, + nodes: EMPTY_LIST, + partial: { turn: data.turn, step: data.step, blocks: data.blocks }, + running: null, + } + } + return { + anchorSeq: node.anchorSeq, + nodes: data.finalNode === undefined ? EMPTY_LIST : [data.finalNode], + partial: null, + running: null, + } + } + case 'tool-call': { + const root = node.data.root + return isRunningTool(root) + ? { anchorSeq: node.anchorSeq, nodes: EMPTY_LIST, partial: null, running: root } + : { anchorSeq: node.anchorSeq, nodes: [root], partial: null, running: null } + } + case 'manual-compaction': { + const data = node.data + return { + anchorSeq: node.anchorSeq, + nodes: data.compaction === null ? [data.command] : [data.command, data.compaction], + partial: null, + running: null, + } + } + case 'model-retry': + return { + anchorSeq: node.anchorSeq, + nodes: node.data.attempts, + partial: null, + running: null, + } + case 'turn-tail': + return EMPTY_CONTRIBUTION + default: + return EMPTY_CONTRIBUTION + } +} + +function sameContribution(left: LegacyContribution | undefined, right: LegacyContribution): boolean { + return left !== undefined + && left.anchorSeq === right.anchorSeq + && left.partial?.blocks === right.partial?.blocks + && left.partial?.turn === right.partial?.turn + && left.partial?.step === right.partial?.step + && left.running === right.running + && sameReferences(left.nodes, right.nodes) +} + +/** Incremental compatibility projection retained solely for unmigrated Trajectory consumers. */ +class LegacySliceBuilder { + private readonly contributions = new Map() + private readonly finalizedContributions = new Map() + private readonly runningContributions = new Map() + private readonly partialContributions = new Map() + private finalized: readonly ConversationNode[] = EMPTY_LIST + private runningCalls: readonly RunningToolCall[] = EMPTY_LIST + private partial: PartialAssistant | null = null + private timeline: ConversationTimelineSnapshot | undefined + private turnTimings: LegacyConversationSlice['turnTimings'] = new Map() + private turnEnds: LegacyConversationSlice['turnEnds'] = new Map() + + replace( + nodes: readonly ChatConversationViewNode[], + timeline: ConversationTimelineSnapshot, + ): LegacyConversationSlice { + this.contributions.clear() + this.finalizedContributions.clear() + this.runningContributions.clear() + this.partialContributions.clear() + for (const node of nodes) { + const contribution = legacyContribution(node) + this.contributions.set(node.key, contribution) + this.indexContribution(node.key, contribution) + } + this.rebuildFinalized() + this.rebuildRunning() + this.rebuildPartial() + this.updateTimeline(timeline) + return this.snapshot() + } + + apply( + upserts: readonly ChatConversationViewNode[], + timeline: ConversationTimelineSnapshot, + ): LegacyConversationSlice { + let finalizedChanged = false + let runningChanged = false + let partialChanged = false + for (const node of upserts) { + const contribution = legacyContribution(node) + const previous = this.contributions.get(node.key) + if (sameContribution(previous, contribution)) continue + finalizedChanged ||= finalizedContributionChanged(previous, contribution) + runningChanged ||= runningContributionChanged(previous, contribution) + partialChanged ||= partialContributionChanged(previous, contribution) + this.contributions.set(node.key, contribution) + this.indexContribution(node.key, contribution) + } + if (finalizedChanged) this.rebuildFinalized() + if (runningChanged) this.rebuildRunning() + if (partialChanged) this.rebuildPartial() + this.updateTimeline(timeline) + return this.snapshot() + } + + private indexContribution(key: string, contribution: LegacyContribution): void { + updateContributionIndex(this.finalizedContributions, key, contribution, contribution.nodes.length > 0) + updateContributionIndex(this.runningContributions, key, contribution, contribution.running !== null) + updateContributionIndex(this.partialContributions, key, contribution, contribution.partial !== null) + } + + private rebuildFinalized(): void { + const finalized = [...this.finalizedContributions.values()] + .flatMap(value => value.nodes) + .sort((left, right) => left.seq - right.seq) + if (!sameReferences(this.finalized, finalized)) this.finalized = finalized + } + + private rebuildRunning(): void { + const runningCalls = [...this.runningContributions.values()] + .sort((left, right) => left.anchorSeq - right.anchorSeq) + .flatMap(value => value.running === null ? [] : [value.running]) + if (!sameReferences(this.runningCalls, runningCalls)) this.runningCalls = runningCalls + } + + private rebuildPartial(): void { + const partial = [...this.partialContributions.values()] + .sort((left, right) => left.anchorSeq - right.anchorSeq) + .findLast(value => value.partial !== null)?.partial ?? null + if (this.partial?.blocks !== partial?.blocks + || this.partial?.turn !== partial?.turn + || this.partial?.step !== partial?.step) this.partial = partial + } + + private updateTimeline(timeline: ConversationTimelineSnapshot): void { + if (this.timeline === timeline) return + this.timeline = timeline + const turnTimings = new Map() + const turnEnds = new Map() + for (const turn of timeline.turns.values()) { + if (turn.start !== undefined) { + turnTimings.set(turn.turn, { + startTime: turn.start.time, + ...turn.end === undefined ? {} : { endTime: turn.end.time }, + }) + } + if (turn.end !== undefined) turnEnds.set(turn.turn, turn.end.seq) + } + this.turnTimings = turnTimings + this.turnEnds = turnEnds + } + + private snapshot(): LegacyConversationSlice { + return { + nodes: this.finalized, + turnTimings: this.turnTimings, + turnEnds: this.turnEnds, + partial: this.partial, + runningCalls: this.runningCalls, + } + } +} + +function updateContributionIndex( + index: Map, + key: string, + contribution: LegacyContribution, + present: boolean, +): void { + if (present) index.set(key, contribution) + else index.delete(key) +} + +function finalizedContributionChanged( + previous: LegacyContribution | undefined, + next: LegacyContribution, +): boolean { + const previousNodes = previous?.nodes ?? EMPTY_LIST + return !sameReferences(previousNodes, next.nodes) + || ((previousNodes.length > 0 || next.nodes.length > 0) && previous?.anchorSeq !== next.anchorSeq) +} + +function runningContributionChanged( + previous: LegacyContribution | undefined, + next: LegacyContribution, +): boolean { + return previous?.running !== next.running + || ((previous.running !== null || next.running !== null) + && previous.anchorSeq !== next.anchorSeq) +} + +function partialContributionChanged( + previous: LegacyContribution | undefined, + next: LegacyContribution, +): boolean { + return previous?.partial?.blocks !== next.partial?.blocks + || previous?.partial?.turn !== next.partial?.turn + || previous?.partial?.step !== next.partial?.step + || (((previous?.partial ?? null) !== null || next.partial !== null) + && previous?.anchorSeq !== next.anchorSeq) +} + +/** Incremental keyed Chat builder registered under the `chat` target. */ +export class ChatSnapshotBuilder implements ConversationViewBuilder { + private readonly store = new MutableChatNodeStore() + private readonly locations = new MutableChatLocationIndex() + private readonly legacy = new LegacySliceBuilder() + private order: readonly string[] = EMPTY_KEYS + readonly empty: ChatSnapshot + + constructor() { + this.empty = this.snapshot({ turnOrder: EMPTY_TURNS, turns: new Map() }) + } + + replace(input: { + readonly nodes: readonly ChatConversationViewNode[] + readonly timeline: ConversationTimelineSnapshot + }): ChatSnapshot { + this.store.replace(input.nodes) + this.order = orderedVisible(input.nodes).map(node => node.key) + this.locations.rebuild(this.order, this.store) + return this.snapshot(input.timeline, this.legacy.replace(input.nodes, input.timeline)) + } + + apply(input: { + readonly upserts: readonly ChatConversationViewNode[] + readonly timeline: ConversationTimelineSnapshot + }): ChatSnapshot { + let structural = false + const contentOnly: ChatConversationViewNode[] = [] + for (const node of input.upserts) { + const previous = this.store.get(node.key) + const nodeStructural = previous === undefined + || previous.anchorSeq !== node.anchorSeq + || previous.visibility !== node.visibility + || locationIdentity(previous.location) !== locationIdentity(node.location) + structural ||= nodeStructural + if (!nodeStructural) contentOnly.push(node) + } + this.store.upsert(input.upserts) + if (structural) { + const next = orderedVisible(this.store.values()).map(node => node.key) + this.order = sameReferences(this.order, next) ? this.order : next + this.locations.rebuild(this.order, this.store) + } + this.locations.touch(contentOnly) + return this.snapshot(input.timeline, this.legacy.apply(input.upserts, input.timeline)) + } + + private snapshot( + timeline: ConversationTimelineSnapshot, + legacy = this.legacy.replace(EMPTY_LIST, timeline), + ): ChatSnapshot { + return { + order: this.order, + nodes: this.store, + locations: this.locations, + timeline, + legacy, + } + } +} + +function locationIdentity(location: ConversationLocation): string { + const coordinates = locationCoordinates(location) + return `${location.kind}:${coordinates.turn ?? ''}:${coordinates.step ?? ''}` +} + +/** Chat target factory contributed to the Runtime view registry. */ +export const chatViewDefinition: ConversationViewDefinition = { + target: 'chat', + create: () => new ChatSnapshotBuilder(), +} + +/** + * Register the incremental Chat target builder. + * @param ctx - owning UI Conversation context. + */ +export function registerChatConversationView(ctx: Context): void { + ctx.conversationViews.register(chatViewDefinition) +} diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/command.ts b/packages/client/ui-conversation/src/client/conversation-nodes/command.ts new file mode 100644 index 0000000000..3ca7976e32 --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation-nodes/command.ts @@ -0,0 +1,243 @@ +import type { Context } from 'cordis' +import type { + CommandNode, CompactionSummaryNode, ConversationMatch, ConversationNodeContext, + ConversationNodeDefinition, +} from '@deepseek-ai/dsh-client-runtime/client' +import { isReplacementSurfaceEvent } from '@deepseek-ai/dsh-client-runtime/client' +import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpoint' +import type { ManualCompactionChatData } from '../contract/chat-nodes.ts' +import { chatNode } from './common.ts' + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ChatNodeDataMap { + /** Ordinary slash-command lifecycle. */ + command: CommandNode + /** Manual compact command combined with its compaction transaction. */ + 'manual-compaction': ManualCompactionChatData + } +} + +type CommandId = CommandNode['commandId'] + +const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact' + +interface CommandState { + readonly command: CommandNode + readonly summary?: ConversationMatch + readonly checkpoint?: ConversationMatch +} + +interface CompactionEvidence { + readonly summary?: ConversationMatch + readonly checkpoint?: ConversationMatch +} + +interface CommandRunData { + readonly commandId: CommandId + readonly name: string + readonly args?: string +} + +interface CommandDoneData { + readonly commandId: CommandId + readonly kind: 'success' | 'error' + readonly text?: string + readonly sourceEventSeq?: number +} + +function commandFromRun(match: ConversationMatch): CommandNode { + const data = match.event.data as unknown as CommandRunData + return { + kind: 'command', + seq: match.event.seq, + time: match.event.time, + commandId: data.commandId, + name: data.name, + args: data.args ?? null, + outcome: null, + } +} + +function commandFromDone(match: ConversationMatch, previous?: CommandNode): CommandNode { + const data = match.event.data as unknown as CommandDoneData + const sourceEventSeq = data.kind === 'success' + && Number.isSafeInteger(data.sourceEventSeq) && (data.sourceEventSeq as number) >= 0 + ? data.sourceEventSeq as number + : undefined + return { + kind: 'command', + seq: previous?.seq ?? match.event.seq, + time: previous?.time ?? match.event.time, + commandId: data.commandId, + name: previous?.name ?? null, + args: previous?.args ?? null, + outcome: { + kind: data.kind, + ...data.text === undefined ? {} : { text: data.text }, + ...sourceEventSeq === undefined ? {} : { sourceEventSeq }, + }, + } +} + +/** + * Read correlation identity from a compaction replacement checkpoint. + * @param event - candidate Session event. + * @returns correlated compaction and optional command identity. + */ +function compactSource(event: Parameters[0]): { + compactionId: string + sourceCommandId?: CommandId +} | undefined { + if (event.type !== 'user/message' || !isReplacementSurfaceEvent(event)) return undefined + const source = event.data.source as unknown as { + kind?: unknown + plugin?: unknown + compactionId?: unknown + sourceCommandId?: CommandId + } + if (source.kind !== 'plugin' || source.plugin !== COMPACT_PLUGIN || typeof source.compactionId !== 'string') return undefined + return { + compactionId: source.compactionId, + ...source.sourceCommandId === undefined ? {} : { sourceCommandId: source.sourceCommandId }, + } +} + +/** + * Build the visible summary marker from optional lifecycle evidence. + * @param match - compact/summary Match, when loaded. + * @param checkpoint - replacement checkpoint Match. + * @returns final compaction summary Node data. + */ +function compactSummary(match: ConversationMatch | undefined, checkpoint: ConversationMatch): CompactionSummaryNode { + let summary: string | null = null + let shadowedItemCount: number | null = null + let shadowedTokenCount: number | null = null + if (match !== undefined) { + const data = match.event.data as unknown as { + summary?: unknown + shadowedSeqs?: unknown + shadowedTokenCount?: unknown + } + if (Array.isArray(data.summary)) { + const text = data.summary + .map((block: unknown) => { + const value = block as { type?: unknown; text?: unknown } + return value.type === 'text' && typeof value.text === 'string' ? value.text : '' + }) + .join('') + summary = text.trim() === '' ? null : text + } + shadowedItemCount = Array.isArray(data.shadowedSeqs) + && data.shadowedSeqs.every(seq => Number.isSafeInteger(seq) && (seq as number) >= 0) + ? data.shadowedSeqs.length + : null + shadowedTokenCount = Number.isSafeInteger(data.shadowedTokenCount) + && (data.shadowedTokenCount as number) >= 0 + ? data.shadowedTokenCount as number + : null + } + return { + kind: 'compaction', + seq: checkpoint.event.seq, + time: checkpoint.event.time, + summary, + summaryEventSeq: match?.event.seq ?? null, + shadowedItemCount, + shadowedTokenCount, + } +} + +function fallbackState(context: ConversationNodeContext): CommandState | undefined { + const done = context.matches.find(match => (match.event.type as string) === 'command/done') + const checkpoint = context.matches.find(match => compactSource(match.event) !== undefined) + const summary = context.matches.find(match => (match.event.type as string) === 'compact/summary') + if (checkpoint === undefined) return done === undefined ? undefined : { command: commandFromDone(done) } + const source = compactSource(checkpoint.event) + if (source?.sourceCommandId === undefined) return done === undefined ? undefined : { command: commandFromDone(done) } + const fallbackCommand = done === undefined + ? { + kind: 'command' as const, + seq: checkpoint.event.seq, + time: checkpoint.event.time, + commandId: source.sourceCommandId, + name: 'compact', + args: null, + outcome: null, + } + : { ...commandFromDone(done), name: 'compact' } + return { + command: fallbackCommand, + checkpoint, + ...summary === undefined ? {} : { summary }, + } +} + +/** + * Fold shared compaction evidence into a Definition-owned State. + * @param state - current business State carrying optional compaction evidence. + * @param match - next compaction lifecycle Match. + * @returns adopted State, preserving reference identity when the Match adds no evidence. + */ +export function updateCompactionState( + state: State, + match: ConversationMatch, +): State { + if ((match.event.type as string) === 'compact/summary') return { ...state, summary: match } + if (compactSource(match.event) !== undefined) return { ...state, checkpoint: match } + return state +} + +/** Slash-command lifecycle, including integrated manual compaction, Definition. */ +export const commandDefinition: ConversationNodeDefinition = { + kind: 'command', + match: (event) => { + if ((event.type as string) === 'command/run') { + return { id: String((event.data as unknown as CommandRunData).commandId), role: 'start' } + } + if ((event.type as string) === 'command/done') { + return { id: String((event.data as unknown as CommandDoneData).commandId), role: 'update' } + } + const checkpoint = compactSource(event) + if (checkpoint?.sourceCommandId !== undefined) { + return { id: String(checkpoint.sourceCommandId), role: 'update' } + } + if ((event.type as string) === 'compact/start' + || (event.type as string) === 'compact/summary' + || (event.type as string) === 'compact/end') { + const data = event.data as unknown as { sourceCommandId?: CommandId } + if (data.sourceCommandId !== undefined) return { id: String(data.sourceCommandId), role: 'update' } + } + return null + }, + start: (_context, match) => ({ command: commandFromRun(match) }), + update: (context, match) => { + if ((match.event.type as string) === 'command/done') { + return { ...context.state, command: commandFromDone(match, context.state.command) } + } + return updateCompactionState(context.state, match) + }, + buildViewNode: (context, target) => { + if (target !== 'chat') return null + const state = context.state ?? fallbackState(context) + if (state === undefined) return null + if (state.command.name !== 'compact') { + return chatNode(context, 'command', state.command.seq, state.command) + } + const compaction = state.checkpoint === undefined + ? null + : compactSummary(state.summary, state.checkpoint) + const data: ManualCompactionChatData = { command: state.command, compaction } + return chatNode(context, 'manual-compaction', compaction?.seq ?? state.command.seq, data) + }, +} + +/** + * Register the command lifecycle business contribution. + * @param ctx - owning UI Conversation context. + */ +export function registerCommandConversationNode(ctx: Context): void { + ctx.conversationEvents.register(commandDefinition) +} + +/** Shared structural checkpoint recognizer for automatic compaction. */ +export { compactSource, compactSummary } diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/common.ts b/packages/client/ui-conversation/src/client/conversation-nodes/common.ts new file mode 100644 index 0000000000..8e1d9d7bd4 --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation-nodes/common.ts @@ -0,0 +1,55 @@ +import type { + ConversationLocation, ConversationNodeContext, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { + ChatNode, ChatNodeDataMap, ChatNodeKind, +} from '../contract/chat-nodes.ts' + +/** + * Resolve one Context's best currently loaded event Location. + * @param context - assembled business Context. + * @returns start or first-match Location, otherwise unresolved. + */ +export function contextLocation(context: ConversationNodeContext): ConversationLocation { + return context.start?.location ?? context.matches[0]?.location ?? { kind: 'unresolved' } +} + +/** + * Build one final Chat target Node with the engine-owned stable key. + * @param context - assembled business Context. + * @param kind - Chat renderer dispatch key. + * @param anchorSeq - sortable render position. + * @param data - renderer-owned payload. + * @param options - optional Location and visibility overrides. + * @returns final Chat view Node. + */ +export function chatNode( + context: ConversationNodeContext, + kind: Kind, + anchorSeq: number, + data: ChatNodeDataMap[Kind], + options: { + readonly location?: ConversationLocation + readonly visibility?: 'visible' | 'hidden' + } = {}, +): ChatNode { + return { + key: context.key, + kind, + id: context.id, + target: 'chat', + anchorSeq, + location: options.location ?? contextLocation(context), + visibility: options.visibility ?? 'visible', + data, + } +} + +/** + * Read a finite non-negative integer from a structurally narrowed payload. + * @param value - untrusted payload field. + * @returns valid coordinate, otherwise undefined. + */ +export function coordinate(value: unknown): number | undefined { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined +} diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/compaction.ts b/packages/client/ui-conversation/src/client/conversation-nodes/compaction.ts new file mode 100644 index 0000000000..0742ab6539 --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation-nodes/compaction.ts @@ -0,0 +1,63 @@ +import type { Context } from 'cordis' +import type { + CompactionSummaryNode, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, +} from '@deepseek-ai/dsh-client-runtime/client' +import { chatNode } from './common.ts' +import { compactSource, compactSummary, updateCompactionState } from './command.ts' + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ChatNodeDataMap { + /** Automatic compaction checkpoint marker. */ + compaction: CompactionSummaryNode + } +} + +interface CompactionState { + readonly summary?: ConversationMatch + readonly checkpoint?: ConversationMatch +} + +function fallbackState(context: ConversationNodeContext): CompactionState { + const summary = context.matches.find(match => (match.event.type as string) === 'compact/summary') + const checkpoint = context.matches.find(match => compactSource(match.event) !== undefined) + return { + ...summary === undefined ? {} : { summary }, + ...checkpoint === undefined ? {} : { checkpoint }, + } +} + +/** Automatic compaction lifecycle and landed checkpoint Definition. */ +export const compactionDefinition: ConversationNodeDefinition = { + kind: 'compaction', + match: (event) => { + const checkpoint = compactSource(event) + if (checkpoint !== undefined && checkpoint.sourceCommandId === undefined) { + return { id: checkpoint.compactionId, role: 'update' } + } + if ((event.type as string) === 'compact/start' + || (event.type as string) === 'compact/summary' + || (event.type as string) === 'compact/end') { + const data = event.data as unknown as { compactionId?: unknown; sourceCommandId?: unknown } + if (typeof data.compactionId !== 'string' || data.sourceCommandId !== undefined) return null + return { id: data.compactionId, role: (event.type as string) === 'compact/start' ? 'start' : 'update' } + } + return null + }, + start: () => ({}), + update: (context, match) => updateCompactionState(context.state, match), + buildViewNode: (context, target) => { + if (target !== 'chat') return null + const state = context.state ?? fallbackState(context) + if (state.checkpoint === undefined) return null + const marker = compactSummary(state.summary, state.checkpoint) + return chatNode(context, 'compaction', marker.seq, marker) + }, +} + +/** + * Register the automatic-compaction business contribution. + * @param ctx - owning UI Conversation context. + */ +export function registerCompactionConversationNode(ctx: Context): void { + ctx.conversationEvents.register(compactionDefinition) +} diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/fallback.ts b/packages/client/ui-conversation/src/client/conversation-nodes/fallback.ts new file mode 100644 index 0000000000..a93fc8fdd4 --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation-nodes/fallback.ts @@ -0,0 +1,40 @@ +import type { Context } from 'cordis' +import type { + ConversationNodeDefinition, UnknownSurfaceNode, +} from '@deepseek-ai/dsh-client-runtime/client' +import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-client-runtime/client' +import { chatNode } from './common.ts' + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ChatNodeDataMap { + /** Generic presentation of an unclaimed append-surface event. */ + unknown: UnknownSurfaceNode + } +} + +/** Unclaimed append-surface fallback Definition. */ +export const unknownFallbackDefinition: ConversationNodeDefinition = { + kind: 'unknown-surface', + match: event => isAppendSurfaceEvent(event) + ? { id: String(event.seq), role: 'start' } + : null, + start: (_context, match) => ({ + kind: 'unknown', + seq: match.event.seq, + time: match.event.time, + type: match.event.type, + data: match.event.data, + }), + update: context => context.state, + buildViewNode: (context, target) => target !== 'chat' || context.state === undefined + ? null + : chatNode(context, 'unknown', context.state.seq, context.state), +} + +/** + * Register the unmatched append-surface fallback contribution. + * @param ctx - owning UI Conversation context. + */ +export function registerUnknownConversationFallback(ctx: Context): void { + ctx.conversationEvents.registerFallback(unknownFallbackDefinition) +} diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts b/packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts new file mode 100644 index 0000000000..092bcaaa59 --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts @@ -0,0 +1,71 @@ +import type { Context } from 'cordis' +import type { + ConversationNodeDefinition, ConversationPreviousContext, +} from '@deepseek-ai/dsh-client-runtime/client' + +type InboxTarget = 'next-turn' | 'next-step' + +interface InboxIdentity { + readonly id: string +} + +interface InboxSplice { + readonly target: InboxTarget + readonly start: number + readonly removedCount?: number + readonly inserted: readonly InboxIdentity[] + readonly outcome?: 'canceled' +} + +/** Cumulative state after one durable inbox splice. */ +export interface InboxState { + readonly pending: readonly InboxIdentity[] + readonly claimed: ReadonlySet +} + +function applySplice( + previous: ConversationPreviousContext | undefined, + splice: InboxSplice, +): InboxState { + const pending = [...(previous?.state.pending ?? [])] + const claimed = new Set(previous?.state.claimed ?? []) + const removed = pending.splice(splice.start, splice.removedCount ?? 0, ...splice.inserted) + for (const identity of splice.inserted) claimed.delete(identity.id) + if (splice.target === 'next-step' && splice.outcome !== 'canceled') { + for (const identity of removed) claimed.add(identity.id) + } + return { pending, claimed } +} + +function inboxDefinition(target: InboxTarget): ConversationNodeDefinition { + const kind = `inbox-${target}` + return { + kind, + match: event => (event.type as string) === 'agent/inbox/spliced' + && (event.data as unknown as { target?: unknown }).target === target + ? { id: String(event.seq), role: 'start' } + : null, + start: (_context, match, reader) => applySplice( + reader.previous(kind), + match.event.data as unknown as InboxSplice, + ), + update: context => context.state, + publication: () => 'none', + buildViewNode: () => null, + } +} + +/** Cumulative next-turn inbox splice Definition. */ +export const nextTurnInboxDefinition = inboxDefinition('next-turn') + +/** Cumulative next-step inbox splice Definition used to classify steering. */ +export const nextStepInboxDefinition = inboxDefinition('next-step') + +/** + * Register the two durable Inbox-state contributions. + * @param ctx - owning UI Conversation context. + */ +export function registerInboxConversationNodes(ctx: Context): void { + ctx.conversationEvents.register(nextTurnInboxDefinition) + ctx.conversationEvents.register(nextStepInboxDefinition) +} diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/message.ts b/packages/client/ui-conversation/src/client/conversation-nodes/message.ts new file mode 100644 index 0000000000..91300944d7 --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation-nodes/message.ts @@ -0,0 +1,83 @@ +import type { Context } from 'cordis' +import type { + ContextMessageNode, ConversationNodeDefinition, SteeringMessageNode, UserMessageNode, +} from '@deepseek-ai/dsh-client-runtime/client' +import { + contextForm, contextProvenance, isAppendSurfaceEvent, isReplacementSurfaceEvent, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { InboxState } from './inbox.ts' +import { chatNode } from './common.ts' + +type MessageNode = UserMessageNode | SteeringMessageNode | ContextMessageNode + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ChatNodeDataMap { + /** Ordinary turn-opening user message. */ + user: UserMessageNode + /** User message admitted into an active turn. */ + steering: SteeringMessageNode + /** Non-user context injected into model history. */ + context: ContextMessageNode + } +} + +function isCompactionCheckpoint(event: Parameters[0]): boolean { + if (event.type !== 'user/message' || !isReplacementSurfaceEvent(event)) return false + const source = event.data.source + return source.kind === 'plugin' && source.plugin === 'compact' +} + +/** User, steering, and injected-context message classification Definition. */ +export const messageDefinition: ConversationNodeDefinition = { + kind: 'input-message', + match: event => event.type === 'user/message' + && isAppendSurfaceEvent(event) + && !isCompactionCheckpoint(event) + ? { id: String(event.data.id), role: 'start' } + : null, + start: (_context, match, reader) => { + if (match.event.type !== 'user/message') throw new Error('input-message start requires user/message') + const event = match.event + if (event.data.source.kind !== 'user') { + return { + kind: 'context', + seq: event.seq, + time: event.time, + content: event.data.content, + source: event.data.source, + provenance: contextProvenance(event.data.source), + form: contextForm(event.data.source), + } + } + const claimed = reader.previous('inbox-next-step')?.state.claimed.has(String(event.data.id)) === true + return claimed + ? { + kind: 'steering', + messageId: event.data.id, + seq: event.seq, + time: event.time, + content: event.data.content, + source: event.data.source, + } + : { + kind: 'user', + seq: event.seq, + time: event.time, + content: event.data.content, + source: event.data.source, + } + }, + update: context => context.state, + buildViewNode: (context, target) => { + if (target !== 'chat' || context.state === undefined) return null + return chatNode(context, context.state.kind, context.state.seq, context.state) + }, +} + +/** + * Register the user, steering, and injected-context message contribution. + * @param ctx - owning UI Conversation context. + */ +export function registerMessageConversationNode(ctx: Context): void { + ctx.conversationEvents.register(messageDefinition) +} diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/register.ts b/packages/client/ui-conversation/src/client/conversation-nodes/register.ts new file mode 100644 index 0000000000..9102b1f2f4 --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation-nodes/register.ts @@ -0,0 +1,30 @@ +import type { Context } from 'cordis' +import { registerAssistantConversationNode } from './assistant.ts' +import { registerChatConversationView } from './chat-snapshot-builder.ts' +import { registerCommandConversationNode } from './command.ts' +import { registerCompactionConversationNode } from './compaction.ts' +import { registerUnknownConversationFallback } from './fallback.ts' +import { registerInboxConversationNodes } from './inbox.ts' +import { registerMessageConversationNode } from './message.ts' +import { registerRetryConversationNode } from './retry.ts' +import { registerToolConversationNode } from './tool.ts' +import { registerTurnErrorConversationNode } from './turn-error.ts' +import { registerTurnTailConversationNode } from './turn-tail.ts' + +/** + * Register the Chat business Definitions and target builder contributed by this package. + * @param ctx - owning UI Conversation context. + */ +export function registerConversationNodes(ctx: Context): void { + registerInboxConversationNodes(ctx) + registerMessageConversationNode(ctx) + registerAssistantConversationNode(ctx) + registerToolConversationNode(ctx) + registerCommandConversationNode(ctx) + registerCompactionConversationNode(ctx) + registerRetryConversationNode(ctx) + registerTurnErrorConversationNode(ctx) + registerTurnTailConversationNode(ctx) + registerUnknownConversationFallback(ctx) + registerChatConversationView(ctx) +} diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts b/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts new file mode 100644 index 0000000000..fe95c0052d --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts @@ -0,0 +1,116 @@ +import type { Context } from 'cordis' +import type { + ConversationLocation, ConversationNodeDefinition, ModelRetryNode, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { RetryChatData } from '../contract/chat-nodes.ts' +import { chatNode } from './common.ts' + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ChatNodeDataMap { + /** Producer-correlated model retry chain. */ + 'model-retry': RetryChatData + } +} + +type WithoutRetryProjection = Node extends unknown + ? Omit + : never +type RetryEventData = WithoutRetryProjection + +/** Accumulated retry attempts sharing one producer-owned RetryId. */ +export interface RetryState { + readonly turn: number + readonly step: number + readonly attempts: readonly ModelRetryNode[] +} + +function retryData(value: unknown): RetryEventData | undefined { + if (value === null || typeof value !== 'object') return undefined + const data = value as Record + if (typeof data.retryId !== 'string' || data.retryId === '' + || !Number.isSafeInteger(data.turn) || (data.turn as number) < 0 + || !Number.isSafeInteger(data.step) || (data.step as number) < 0 + || !Number.isSafeInteger(data.retry) || (data.retry as number) <= 0 + || typeof data.delayMs !== 'number' || !Number.isFinite(data.delayMs) || data.delayMs < 0 + || typeof data.provider !== 'string' || typeof data.policyKey !== 'string' + || (data.mode !== 'normal' && data.mode !== 'always') + || data.failure === null || typeof data.failure !== 'object') return undefined + if (data.mode === 'normal' && (!Number.isSafeInteger(data.maxRetries) || (data.maxRetries as number) <= 0)) { + return undefined + } + return data as unknown as RetryEventData +} + +function scheduledNode(event: { seq: number; time: number; data: unknown }): ModelRetryNode | undefined { + const data = retryData(event.data) + return data === undefined ? undefined : { + kind: 'model-retry', + seq: event.seq, + time: event.time, + retryState: 'scheduled', + ...data, + } +} + +function isClosed(location: ConversationLocation): boolean { + return (location.kind === 'step' && location.step.status === 'closed') + || ((location.kind === 'step' || location.kind === 'turn') && location.turn.status === 'closed') +} + +/** Producer-correlated model retry chain Definition. */ +export const retryDefinition: ConversationNodeDefinition = { + kind: 'model-retry', + match: (event) => { + if ((event.type as string) === 'llm/retry') { + const data = retryData(event.data) + if (data === undefined) return null + return { id: String(data.retryId), role: data.retry === 1 ? 'start' : 'update' } + } + if ((event.type as string) === 'llm/retry-started') { + const data = event.data as unknown as { retryId?: unknown } + return typeof data.retryId === 'string' ? { id: data.retryId, role: 'update' } : null + } + return null + }, + start: (_context, match) => { + const node = scheduledNode(match.event) + if (node === undefined) throw new Error('model-retry start requires a valid llm/retry event') + return { turn: node.turn, step: node.step, attempts: [node] } + }, + update: (context, match) => { + if ((match.event.type as string) === 'llm/retry') { + const node = scheduledNode(match.event) + return node === undefined ? context.state : { ...context.state, attempts: [...context.state.attempts, node] } + } + if ((match.event.type as string) !== 'llm/retry-started') return context.state + const data = match.event.data as unknown as { retry: number } + return { + ...context.state, + attempts: context.state.attempts.map(attempt => + attempt.retry === data.retry ? { ...attempt, retryState: 'started' } : attempt), + } + }, + buildViewNode: (context, target) => { + if (target !== 'chat' || context.state === undefined || context.state.attempts.length === 0) return null + const location = context.start?.location ?? context.matches[0]?.location ?? { kind: 'unresolved' as const } + const stateAttempts = context.state.attempts + const attempts = stateAttempts.map((attempt, index) => + index === stateAttempts.length - 1 + && attempt.retryState === 'scheduled' + && isClosed(location) + ? { ...attempt, retryState: 'cancelled' as const } + : attempt) + const current = attempts.at(-1) + if (current === undefined) return null + const data: RetryChatData = { attempts, current } + return chatNode(context, 'model-retry', attempts[0]?.seq ?? current.seq, data) + }, +} + +/** + * Register the correlated model-retry business contribution. + * @param ctx - owning UI Conversation context. + */ +export function registerRetryConversationNode(ctx: Context): void { + ctx.conversationEvents.register(retryDefinition) +} diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/tool.ts b/packages/client/ui-conversation/src/client/conversation-nodes/tool.ts new file mode 100644 index 0000000000..04a5770f5a --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation-nodes/tool.ts @@ -0,0 +1,271 @@ +import type { Context } from 'cordis' +import type { + ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, + RunningToolCall, ToolCallBlock, ToolResultNode, +} from '@deepseek-ai/dsh-client-runtime/client' +import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-client-runtime/client' +import type { ToolChatData } from '../contract/chat-nodes.ts' +import { chatNode } from './common.ts' + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ChatNodeDataMap { + /** Root Tool lifecycle with recursively nested subcalls. */ + 'tool-call': ToolChatData + } +} + +const MAX_DEPTH = 256 + +interface ToolState { + readonly root: ToolCallBlock + readonly children: ReadonlyMap + readonly parents: ReadonlyMap +} + +interface ProjectedBlockCache { + readonly children: readonly ToolCallBlock[] + readonly interruptionSeq: number | undefined + readonly interruptionTime: number | undefined + readonly value: ToolCallBlock +} + +const projectedBlocks = new WeakMap() + +function jsonArguments(value: unknown): string { + return JSON.stringify(value) +} + +function rootCall(match: ConversationMatch): RunningToolCall { + if (match.event.type !== 'tool/call') throw new Error('tool-call start requires tool/call') + return { + callId: String(match.event.data.callId), + name: match.event.data.name, + argsRaw: match.event.data.arguments, + turn: match.event.data.turn, + step: match.event.data.step, + time: match.event.time, + callView: match.view?.for === 'call' ? match.view.view : null, + subCalls: [], + } +} + +function rootResult(match: ConversationMatch, previous?: RunningToolCall): ToolResultNode | undefined { + if (match.event.type !== 'tool/result') return undefined + const result = match.event.data.message.content[0] + return { + kind: 'tool-result', + seq: match.event.seq, + time: match.event.time, + callId: String(match.event.data.message.source.callId), + call: previous === undefined ? null : { name: previous.name, argsRaw: previous.argsRaw }, + callTime: previous?.time ?? null, + content: result.content, + isError: result.isError === true, + ...match.event.data.error === undefined ? {} : { error: match.event.data.error }, + meta: match.event.data.meta, + callView: previous?.callView ?? null, + resultView: match.view?.for === 'result' ? match.view.view : null, + subCalls: [], + } +} + +interface DispatchData { + readonly parentCallId: string + readonly subCallId: string + readonly name: string + readonly arguments: unknown + readonly isError?: boolean + readonly content?: ToolResultNode['content'] +} + +function childCall(match: ConversationMatch, data: DispatchData): RunningToolCall { + return { + callId: data.subCallId, + name: data.name, + argsRaw: jsonArguments(data.arguments), + turn: locationTurn(match), + step: locationStep(match), + time: match.event.time, + callView: null, + subCalls: [], + } +} + +function childResult(match: ConversationMatch, data: DispatchData, previous?: ToolCallBlock): ToolResultNode { + return { + kind: 'tool-result', + seq: match.event.seq, + time: match.event.time, + callId: data.subCallId, + call: { name: data.name, argsRaw: jsonArguments(data.arguments) }, + callTime: previous?.time ?? null, + content: data.content ?? [], + isError: data.isError === true, + callView: null, + resultView: null, + subCalls: [], + } +} + +function locationTurn(match: ConversationMatch): number { + return match.location.kind === 'step' || match.location.kind === 'turn' ? match.location.turn.turn : 0 +} + +function locationStep(match: ConversationMatch): number { + return match.location.kind === 'step' ? match.location.step.step : 0 +} + +function acceptsEdge(state: ToolState, parent: string, child: string): boolean { + if (parent === child || state.parents.has(child)) return false + let cursor: string | undefined = parent + let parentDepth = 0 + const ancestors = new Set() + while (cursor !== undefined) { + if (cursor === child || ancestors.has(cursor)) return false + ancestors.add(cursor) + parentDepth++ + cursor = state.parents.get(cursor) + } + const pending = [{ callId: child, depth: 1 }] + const descendants = new Set() + let subtreeDepth = 0 + for (const candidate of pending) { + if (descendants.has(candidate.callId)) return false + descendants.add(candidate.callId) + subtreeDepth = Math.max(subtreeDepth, candidate.depth) + for (const nested of state.children.get(candidate.callId) ?? []) { + pending.push({ callId: nested.callId, depth: candidate.depth + 1 }) + } + } + return parentDepth + subtreeDepth <= MAX_DEPTH +} + +function updateDispatch(state: ToolState, match: ConversationMatch): ToolState { + const data = match.event.data as unknown as DispatchData + const siblings = state.children.get(data.parentCallId) ?? [] + const index = siblings.findIndex(candidate => candidate.callId === data.subCallId) + if ((match.event.type as string) === 'tool/code-dispatch-start') { + if (index >= 0 || !acceptsEdge(state, data.parentCallId, data.subCallId)) return state + const children = new Map(state.children) + children.set(data.parentCallId, [...siblings, childCall(match, data)]) + const parents = new Map(state.parents) + parents.set(data.subCallId, data.parentCallId) + return { ...state, children, parents } + } + if ((match.event.type as string) !== 'tool/code-dispatch') return state + if (index < 0 && !acceptsEdge(state, data.parentCallId, data.subCallId)) return state + const previous = index < 0 ? undefined : siblings[index] + const settled = childResult(match, data, previous) + const children = new Map(state.children) + children.set(data.parentCallId, index < 0 + ? [...siblings, settled] + : siblings.map((child, at) => at === index ? settled : child)) + const parents = new Map(state.parents) + if (index < 0) parents.set(data.subCallId, data.parentCallId) + return { ...state, children, parents } +} + +function projectBlock( + block: ToolCallBlock, + state: ToolState, + interruptedAt: { seq: number; time: number } | undefined, + visited = new Set(), + depth = 1, +): ToolCallBlock { + if (visited.has(block.callId) || depth > MAX_DEPTH) return { ...block, subCalls: [] } + const nextVisited = new Set(visited) + nextVisited.add(block.callId) + const children = (state.children.get(block.callId) ?? block.subCalls) + .map(child => projectBlock(child, state, interruptedAt, nextVisited, depth + 1)) + const interruptionSeq = 'kind' in block ? undefined : interruptedAt?.seq + const interruptionTime = 'kind' in block ? undefined : interruptedAt?.time + const cached = projectedBlocks.get(block) + if (cached !== undefined + && cached.interruptionSeq === interruptionSeq + && cached.interruptionTime === interruptionTime + && sameReferences(cached.children, children)) { + return cached.value + } + const projected: ToolCallBlock = 'kind' in block || interruptedAt === undefined + ? sameReferences(block.subCalls, children) ? block : { ...block, subCalls: children } + : { + kind: 'tool-result', + seq: interruptedAt.seq - 0.8, + time: interruptedAt.time, + callId: block.callId, + call: { name: block.name, argsRaw: block.argsRaw }, + callTime: block.time, + content: [], + isError: true, + error: { name: 'Interrupted', code: 'interrupted' }, + callView: block.callView, + resultView: null, + subCalls: children, + } + projectedBlocks.set(block, { children, interruptionSeq, interruptionTime, value: projected }) + return projected +} + +function sameReferences(left: readonly T[], right: readonly T[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]) +} + +function interruption(context: ConversationNodeContext): { seq: number; time: number } | undefined { + const location = context.start?.location + if (location?.kind === 'step' && location.step.status === 'closed') return location.step.end + if ((location?.kind === 'step' || location?.kind === 'turn') && location.turn.status === 'closed') { + return location.turn.end + } + return undefined +} + +function fallbackState(context: ConversationNodeContext): ToolState | undefined { + const match = context.matches.find(candidate => candidate.event.type === 'tool/result') + const root = match === undefined ? undefined : rootResult(match) + if (root === undefined) return undefined + let state: ToolState = { root, children: new Map(), parents: new Map() } + for (const candidate of context.matches) state = updateDispatch(state, candidate) + return state +} + +/** Root Tool lifecycle and nested Code Dispatch Definition. */ +export const toolDefinition: ConversationNodeDefinition = { + kind: 'tool-call', + match: (event) => { + if (event.type === 'tool/call') return { id: String(event.data.callId), role: 'start' } + if (event.type === 'tool/result' && isAppendSurfaceEvent(event)) { + return { id: String(event.data.message.source.callId), role: 'update' } + } + if ((event.type as string) === 'tool/code-dispatch-start' || (event.type as string) === 'tool/code-dispatch') { + const data = event.data as unknown as { rootCallId: string } + return { id: data.rootCallId, role: 'update' } + } + return null + }, + start: (_context, match) => ({ root: rootCall(match), children: new Map(), parents: new Map() }), + update: (context, match) => { + if (match.event.type === 'tool/result') { + const running = 'kind' in context.state.root ? undefined : context.state.root + const result = rootResult(match, running) + return result === undefined ? context.state : { ...context.state, root: result } + } + return updateDispatch(context.state, match) + }, + buildViewNode: (context, target) => { + if (target !== 'chat') return null + const state = context.state ?? fallbackState(context) + if (state === undefined) return null + const projected = projectBlock(state.root, state, interruption(context)) + const anchor = context.start?.event.seq + ?? ('kind' in state.root ? state.root.seq : context.matches[0]?.event.seq ?? 0) + return chatNode(context, 'tool-call', anchor, { root: projected } satisfies ToolChatData) + }, +} + +/** + * Register the root Tool lifecycle and nested-subcall contribution. + * @param ctx - owning UI Conversation context. + */ +export function registerToolConversationNode(ctx: Context): void { + ctx.conversationEvents.register(toolDefinition) +} diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/turn-error.ts b/packages/client/ui-conversation/src/client/conversation-nodes/turn-error.ts new file mode 100644 index 0000000000..58725d8723 --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation-nodes/turn-error.ts @@ -0,0 +1,112 @@ +import type { Context } from 'cordis' +import type { + ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnErrorNode, +} from '@deepseek-ai/dsh-client-runtime/client' +import { displayFailureMessage } from '@deepseek-ai/dsh-client-runtime/client' +import { chatNode } from './common.ts' + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ChatNodeDataMap { + /** Terminal turn failure not superseded by retry. */ + 'turn-error': TurnErrorNode + } +} + +interface TurnErrorState { + readonly turn: number + readonly hidden: boolean + readonly failure?: { + readonly seq: number + readonly time: number + readonly message: string + readonly code?: string + } +} + +function lastStep(context: ConversationNodeContext): number { + const location = context.start?.location ?? context.matches[0]?.location + if (location?.kind !== 'turn' && location?.kind !== 'step') return 0 + return location.turn.steps.at(-1)?.step ?? 0 +} + +function retryTurn(event: Parameters[0]): number | undefined { + if ((event.type as string) !== 'llm/retry' && (event.type as string) !== 'llm/retry-started') return undefined + const turn = (event.data as unknown as { turn?: unknown }).turn + return Number.isSafeInteger(turn) && (turn as number) >= 0 ? turn as number : undefined +} + +function failureFrom(match: ConversationMatch): TurnErrorState['failure'] | undefined { + if (match.event.type !== 'turn/end' || match.event.data.reason.kind !== 'error') return undefined + const failure = match.event.data.reason.error + return { + seq: match.event.seq, + time: match.event.time, + message: displayFailureMessage(failure), + code: failure.code, + } +} + +function fallbackState(context: ConversationNodeContext): TurnErrorState | undefined { + const end = context.matches.find(match => failureFrom(match) !== undefined) + if (end?.event.type !== 'turn/end') return undefined + const failure = failureFrom(end) + if (failure === undefined) return undefined + const turn = end.event.data.turn + return { + turn, + hidden: context.matches.some(match => retryTurn(match.event) === turn), + failure, + } +} + +/** Terminal turn failure Definition, suppressed when the turn owns a retry chain. */ +export const turnErrorDefinition: ConversationNodeDefinition = { + kind: 'turn-error', + match: (event) => { + if (event.type === 'turn/start') return { id: String(event.data.turn), role: 'start' } + if (event.type === 'turn/end' && event.data.reason.kind === 'error') { + return { id: String(event.data.turn), role: 'update' } + } + const turn = retryTurn(event) + return turn === undefined ? null : { id: String(turn), role: 'update' } + }, + start: (_context, match) => { + if (match.event.type !== 'turn/start') throw new Error('turn-error start requires turn/start') + return { turn: match.event.data.turn, hidden: false } + }, + update: (context, match) => { + const failure = failureFrom(match) + if (failure !== undefined) return { ...context.state, failure } + return retryTurn(match.event) === context.state.turn + ? { ...context.state, hidden: true } + : context.state + }, + buildViewNode: (context, target) => { + if (target !== 'chat') return null + const state = context.state ?? fallbackState(context) + if (state?.failure === undefined) return null + const failure = state.failure + const node: TurnErrorNode = { + kind: 'turn-error', + seq: failure.seq, + time: failure.time, + turn: state.turn, + step: lastStep(context), + message: failure.message, + ...failure.code === undefined ? {} : { code: failure.code }, + } + if (!state.hidden) return chatNode(context, 'turn-error', node.seq, node) + const current = context.current.get('chat') + return current === undefined || current === null + ? null + : chatNode(context, 'turn-error', node.seq, node, { visibility: 'hidden' }) + }, +} + +/** + * Register the terminal Turn-error business contribution. + * @param ctx - owning UI Conversation context. + */ +export function registerTurnErrorConversationNode(ctx: Context): void { + ctx.conversationEvents.register(turnErrorDefinition) +} diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/turn-tail.ts b/packages/client/ui-conversation/src/client/conversation-nodes/turn-tail.ts new file mode 100644 index 0000000000..2a842a005d --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation-nodes/turn-tail.ts @@ -0,0 +1,180 @@ +import type { Context } from 'cordis' +import type { + ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnLocation, +} from '@deepseek-ai/dsh-client-runtime/client' +import { isAppendSurfaceEvent, toAssistantBlocks } from '@deepseek-ai/dsh-client-runtime/client' +import type { + AssistantChatData, FinalAssistantChatData, TurnTailChatData, +} from '../contract/chat-nodes.ts' +import { deriveTurnMetrics } from '../chat/turn-metrics.ts' +import { chatNode } from './common.ts' + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ChatNodeDataMap { + /** Completed-turn actions and extension tail. */ + 'turn-tail': TurnTailChatData + } +} + +declare module '@deepseek-ai/dsh-client-runtime/client' { + interface ConversationTurnDataMap { + /** Closing Assistant and footer facts derived for this completed Turn. */ + 'turn-tail': TurnTailChatData + } +} + +interface TurnTailState { + readonly turn: number + readonly end?: ConversationMatch +} + +interface StepEvidence { + readonly streamedText: boolean + readonly finalized: boolean +} + +function hasTextAssistant(event: Parameters[0]): boolean { + return event.type === 'assistant/message' + && isAppendSurfaceEvent(event) + && toAssistantBlocks(event.data.message.content) + .some(block => block.kind === 'text' && block.text.trim() !== '') +} + +function chunkHasText(event: Parameters[0]): boolean { + if (event.type !== 'assistant/chunk') return false + const chunk = event.data.chunk + if (chunk.type === 'text-delta') return chunk.text.trim() !== '' + return chunk.type === 'block-end' + && chunk.block.type === 'text' + && chunk.block.text.trim() !== '' +} + +function turnCoordinates(event: Parameters[0]): { + readonly turn: number + readonly step?: number +} | undefined { + if (event.type === 'assistant/message' + || event.type === 'assistant/chunk' + || event.type === 'step/end') { + return { turn: event.data.turn, step: event.data.step } + } + if ((event.type as string) === 'llm/retry') { + return event.data as unknown as { turn: number; step: number } + } + return undefined +} + +function closingAnchor(context: ConversationNodeContext): number { + let anchor = context.matches.find(match => match.event.type === 'turn/end')?.event.seq + ?? context.start?.event.seq + ?? context.matches[0]?.event.seq + ?? 0 + const steps = new Map() + for (const match of context.matches) { + const event = match.event + if (event.type === 'turn/end') continue + const coordinates = turnCoordinates(event) + if (coordinates?.step === undefined) continue + const previous = steps.get(coordinates.step) ?? { streamedText: false, finalized: false } + if (event.type === 'assistant/chunk') { + steps.set(coordinates.step, { + ...previous, + streamedText: previous.streamedText || chunkHasText(event), + }) + continue + } + if (event.type === 'assistant/message') { + steps.set(coordinates.step, { streamedText: false, finalized: true }) + if (hasTextAssistant(event)) anchor = event.seq + 0.1 + continue + } + if ((event.type as string) === 'llm/retry') { + steps.set(coordinates.step, { streamedText: false, finalized: false }) + continue + } + if (event.type === 'step/end' && previous.streamedText && !previous.finalized) { + anchor = event.seq - 0.8 + } + } + return anchor +} + +function turnLocation(context: ConversationNodeContext): TurnLocation | undefined { + const location = context.start?.location ?? context.matches[0]?.location + return location?.kind === 'turn' || location?.kind === 'step' ? location.turn : undefined +} + +function hasText(data: AssistantChatData): data is FinalAssistantChatData { + return data.finalNode !== undefined + && data.blocks.some(block => block.kind === 'text' && block.text.trim() !== '') +} + +function tailData(context: ConversationNodeContext): TurnTailChatData | null { + const end = context.state?.end + ?? context.matches.find(match => match.event.type === 'turn/end') + if (end?.event.type !== 'turn/end') return null + const turn = turnLocation(context) + if (turn === undefined) return null + const assistants = turn.steps + .map(step => step.data.get('assistant-step')) + .filter((candidate): candidate is Readonly => candidate !== undefined) + const finalized = assistants + .filter((candidate): candidate is Readonly => candidate.finalNode !== undefined) + .sort((left, right) => left.finalNode.seq - right.finalNode.seq) + const closing = finalized.findLast(hasText) ?? null + const latest = finalized.at(-1) + const metrics = deriveTurnMetrics(finalized.map(candidate => candidate.finalNode)).get(end.event.data.turn) + return { + turn: end.event.data.turn, + seq: end.event.seq, + time: end.event.time, + closing, + branchUnavailable: closing === null || latest?.finalNode.seq !== closing.finalNode.seq, + ...metrics?.ttftMs === undefined ? {} : { ttftMs: metrics.ttftMs }, + ...metrics?.tokensPerSecond === undefined ? {} : { tokensPerSecond: metrics.tokensPerSecond }, + } +} + +/** Completed-turn footer Definition independent of any Assistant row. */ +export const turnTailDefinition: ConversationNodeDefinition = { + kind: 'turn-tail', + match: (event) => { + if (event.type === 'turn/start') return { id: String(event.data.turn), role: 'start' } + if (event.type === 'turn/end') return { id: String(event.data.turn), role: 'update' } + const coordinates = turnCoordinates(event) + if (coordinates !== undefined) return { id: String(coordinates.turn), role: 'update' } + return null + }, + start: (_context, match) => { + if (match.event.type !== 'turn/start') throw new Error('turn-tail start requires turn/start') + return { turn: match.event.data.turn } + }, + update: (context, match) => match.event.type === 'turn/end' + ? { ...context.state, end: match } + : context.state, + publication: match => match.event.type === 'turn/end' ? 'immediate' : 'none', + buildLocationData: (context, scope) => { + if (scope !== 'turn') return null + const value = tailData(context) + return value === null ? null : { + kind: 'turn', + turn: value.turn, + key: 'turn-tail', + value, + } + }, + buildViewNode: (context, target) => { + if (target !== 'chat') return null + const turn = turnLocation(context) + const data = turn?.data.get('turn-tail') + return data === undefined ? null : chatNode(context, 'turn-tail', closingAnchor(context), data) + }, +} + +/** + * Register completed-Turn footer data and its Chat node contribution. + * @param ctx - owning UI Conversation context. + */ +export function registerTurnTailConversationNode(ctx: Context): void { + ctx.conversationEvents.register(turnTailDefinition) +} diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index a19cd77753..b4d7467170 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -5,6 +5,17 @@ */ export { apply, inject } from './apply.ts' export { ConversationService } from './service.ts' +export { registerAssistantConversationNode } from './conversation-nodes/assistant.ts' +export { registerChatConversationView } from './conversation-nodes/chat-snapshot-builder.ts' +export { registerCommandConversationNode } from './conversation-nodes/command.ts' +export { registerCompactionConversationNode } from './conversation-nodes/compaction.ts' +export { registerUnknownConversationFallback } from './conversation-nodes/fallback.ts' +export { registerInboxConversationNodes } from './conversation-nodes/inbox.ts' +export { registerMessageConversationNode } from './conversation-nodes/message.ts' +export { registerRetryConversationNode } from './conversation-nodes/retry.ts' +export { registerToolConversationNode } from './conversation-nodes/tool.ts' +export { registerTurnErrorConversationNode } from './conversation-nodes/turn-error.ts' +export { registerTurnTailConversationNode } from './conversation-nodes/turn-tail.ts' export type { IConversation } from './service.ts' export type { @@ -12,12 +23,16 @@ export type { } from './contract/views.ts' export type { ConversationKey } from './locales.ts' export type { - ChatFileMentions, + AssistantChatData, ChatNode, ChatNodeDataMap, ChatNodeKind, ManualCompactionChatData, + RetryChatData, ToolChatData, TurnTailChatData, +} from './contract/chat-nodes.ts' +export type { + ChatFileMentions, ChatNodeOwnerProps, ChatNodeViewProps, ChatStore, ChatViewInjected, ChatViewSlotProps, CommandRowOwnerProps, CommandRowProps, ComposerBarInjected, ComposerChainProps, ConversationInjected, ConversationSessionHeaderInjected, ConversationSessionInjected, ConversationSlotProps, ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps, DetailsToolOwnerProps, EmptyWorkspaceOwnerProps, - ToolTreeOwnerProps, TurnTailOwnerProps, + TurnTailOwnerProps, UseChatNodeTurnData, } from './contract/slots.ts' // Export discipline: packages/client/AGENTS.md. diff --git a/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.tsx index a1a0a120f5..98e98f0f01 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.tsx @@ -17,6 +17,7 @@ import { useMemo, useState } from 'react' import { Button } from '@deepseek-ai/dsh-client-ui-primitives' import type { RunningToolCall } from '@deepseek-ai/dsh-client-runtime/client' import { PendingApproval, type ApprovalComposerProps } from '../contract/slots.ts' +import { rootToolCall } from '../chat/tool-node-reader.ts' import css from './ApprovalPanel.module.css' /** Extract the shell command from an approval's paired running call (bash-family args carry `command`); undefined hides the line. */ @@ -40,8 +41,12 @@ export function commandOf(call: RunningToolCall | undefined): string | undefined */ export function ApprovalPanel(props: ApprovalComposerProps) { const approval = useMemo(() => new PendingApproval(props.matched), [props.matched]) - const command = props.useSession(s => commandOf( - approval.callId === undefined ? undefined : s.runningCalls.find(call => call.callId === approval.callId))) + const command = props.useSession((snapshot) => { + if (approval.callId === undefined) return undefined + const root = rootToolCall(snapshot, approval.callId) + if (root === undefined) return undefined + return root.callId === approval.callId && !('kind' in root) ? commandOf(root) : undefined + }) return } diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx index a04e9ba9e8..1caefd2ecf 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx @@ -12,6 +12,7 @@ import { CodeBlock } from '@deepseek-ai/dsh-client-ui-primitives' import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSnapshot, RunningToolCall, ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' import type { DetailsSlotProps } from '../contract/slots.ts' +import { findToolCall } from '../chat/tool-node-reader.ts' import css from './DetailsPanel.module.css' /** Full props composed by reference from the contract (automatic shares & injected share). */ @@ -40,30 +41,10 @@ function runningMaterial(call: RunningToolCall): CallMaterial { return { name: call.name, argsRaw: call.argsRaw, block: call } } -function findCall(block: ToolCallBlock, callId: string): ToolCallBlock | undefined { - if (block.callId === callId) return block - for (const child of block.subCalls) { - const found = findCall(child, callId) - if (found !== undefined) return found - } - return undefined -} - function materialFor(s: ConversationSnapshot, callId: string): CallMaterial | null { - for (const node of s.nodes) { - if (node.kind !== 'tool-result') continue - const found = findCall(node, callId) - if (found !== undefined) { - return 'kind' in found ? settledMaterial(found, callId) : runningMaterial(found) - } - } - for (const root of s.runningCalls) { - const found = findCall(root, callId) - if (found !== undefined) { - return 'kind' in found ? settledMaterial(found, callId) : runningMaterial(found) - } - } - return null + const found = findToolCall(s, callId) + if (found === undefined) return null + return 'kind' in found ? settledMaterial(found, callId) : runningMaterial(found) } function pretty(raw: string): string { diff --git a/packages/client/ui-conversation/src/invariant.ts b/packages/client/ui-conversation/src/invariant.ts index f9a7d46553..66c54081ab 100644 --- a/packages/client/ui-conversation/src/invariant.ts +++ b/packages/client/ui-conversation/src/invariant.ts @@ -17,7 +17,7 @@ export const inject = ['invariants'] /** * No runtime invariant: the conversation service emits no cordis events, and * both rings this package owns (the 'conversation.view' tab ring and the - * 'conversation.chat.tool' whole-call seat) ride the slot system, whose ledger + * 'conversation.chat.node' business renderer seat) ride the slot system, whose ledger * invariants live with the runtime slots package. */ const install: InvariantInstaller = () => {} diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index 81b2f61b79..fb8bdd0224 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -12,7 +12,9 @@ import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-conversation/c import type {} from '@deepseek-ai/dsh-client-locale/client' import { ProducedFiles } from './ProducedFiles.tsx' import { en, NS, zh, type DeliverablesKey } from './locales.ts' -import { producedFileMentions, selectProducedFiles } from './turn-deliverables.ts' +import { + deliverablesDefinition, producedFileMentions, selectProducedFiles, +} from './turn-deliverables.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { @@ -25,13 +27,14 @@ export { ProducedFiles, type ProducedFilesProps } from './ProducedFiles.tsx' export { producedForClosing } from './turn-deliverables.ts' /** Required services for the tail-slot registration and its dictionaries. */ -export const inject = ['slots', 'locale'] +export const inject = ['slots', 'locale', 'conversationEvents'] /** * Client plugin body: register the dictionaries and the turn-tail entry. * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { + ctx.conversationEvents.register(deliverablesDefinition) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-deliverables: dictionaries') ctx.slots.inject( 'conversation.chat.turnTail', diff --git a/packages/client/ui-deliverables/src/client/turn-deliverables.ts b/packages/client/ui-deliverables/src/client/turn-deliverables.ts index 4316ddf8e6..0227b34261 100644 --- a/packages/client/ui-deliverables/src/client/turn-deliverables.ts +++ b/packages/client/ui-deliverables/src/client/turn-deliverables.ts @@ -1,12 +1,37 @@ /** - * Pure derivation of one turn's produced files from finalized snapshot - * nodes. Client-only and model-free: the vocabulary is the mutation tools' - * own follow-along `locations`, never the closing prose. + * Turn-scoped produced-file Definition and readers. Client-only and + * model-free: the vocabulary is the mutation tools' own follow-along + * `locations`, never the closing prose. */ -import type { ConversationNode, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' +import type { + ConversationNodeDefinition, ToolResultNode, +} from '@deepseek-ai/dsh-client-runtime/client' +import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-client-runtime/client' import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +interface ProducedPath { + readonly seq: number + readonly path: string +} + +/** Immutable produced-file facts published against one Turn. */ +export interface DeliverablesTurnData { + readonly produced: readonly ProducedPath[] +} + +declare module '@deepseek-ai/dsh-client-runtime/client' { + interface ConversationTurnDataMap { + /** Successful mutation paths accumulated in this Turn. */ + deliverables: DeliverablesTurnData + } +} + +interface DeliverablesState extends DeliverablesTurnData { + readonly turn: number + readonly calls: ReadonlyMap +} + /** * Paths a call view reports having created or changed, by render intent rather * than tool name: a diff card, or a generic card whose kind is `edit` (the @@ -23,9 +48,7 @@ function producedPaths(view: ToolResultNode['callView']): readonly string[] { } /** - * Files produced by the turn the assistant at `seq` closes — the anchor the - * render site elects, so the row lands under the message that reports the - * work rather than after some mid-turn narration. + * Files produced by one Turn data value. * * The source is the mutation tools' own follow-along `locations`, not the * closing prose: a produced file must be listed whether or not the model @@ -37,46 +60,26 @@ function producedPaths(view: ToolResultNode['callView']): readonly string[] { * failed calls. Paths keep first-seen order and appear once, so a file written * and then edited in the same turn is one entry. * - * Accumulation resets on the turn boundary — a user message, or a node - * reporting a different turn number — so a turn that mutates files and then - * ends without content text cannot spill its paths into the next turn's row, - * nor leave the dedup set suppressing a file the next turn legitimately - * rewrites. Tool results carry no turn of their own; the boundary is read off - * the nodes that do, and a user message resets the tracked turn to undefined - * because the next node to report one is stating the current turn, not - * entering a new one. - * @param nodes - snapshot nodes (surface order). - * @param seq - the closing assistant's seq (the render site's anchor). + * The Conversation Location index owns turn membership before this function + * runs, so paths cannot spill across turns and this derivation does not infer + * boundaries from neighboring presentation Nodes. + * @param data - engine-published Deliverables data for one Turn. + * @param seq - closing Assistant seq; later Tool settlements are excluded. * @returns Produced paths in first-seen order; empty when the turn wrote nothing. */ -export function producedForClosing(nodes: readonly ConversationNode[], seq: number): readonly string[] { - let pending: string[] = [] - let seen = new Set() - let turn: number | undefined - for (const node of nodes) { - if (node.kind === 'tool-result') { - if (node.isError) continue - for (const path of producedPaths(node.callView)) { - if (seen.has(path)) continue - seen.add(path) - pending.push(path) - } - continue - } - if (node.kind === 'user') { - turn = undefined - pending = [] - seen = new Set() - } else if ('turn' in node) { - if (turn !== undefined && node.turn !== turn) { - pending = [] - seen = new Set() - } - turn = node.turn - } - if (node.kind === 'assistant' && node.seq === seq) return pending +export function producedForClosing( + data: Readonly | undefined, + seq = Number.POSITIVE_INFINITY, +): readonly string[] { + if (data === undefined) return [] + const paths: string[] = [] + const seen = new Set() + for (const produced of data.produced) { + if (produced.seq > seq || seen.has(produced.path)) continue + seen.add(produced.path) + paths.push(produced.path) } - return [] + return paths } /** @@ -85,11 +88,55 @@ export function producedForClosing(nodes: readonly ConversationNode[], seq: numb * @returns Produced paths as the component's match, or null to decline before mount. */ export function selectProducedFiles(owner: TurnTailOwnerProps): readonly string[] | null { - const { nodes, seq } = owner - const paths = producedForClosing(nodes, seq) + const paths = producedForClosing(owner.turn.data.get('deliverables'), owner.seq) return paths.length === 0 ? null : paths } +/** Turn-local successful mutation accumulator; it publishes no view Node. */ +export const deliverablesDefinition: ConversationNodeDefinition = { + kind: 'deliverables', + match: (event) => { + if (event.type === 'turn/start') return { id: String(event.data.turn), role: 'start' } + if (event.type === 'tool/call') return { id: String(event.data.turn), role: 'update' } + if (event.type === 'tool/result' && isAppendSurfaceEvent(event)) { + return { id: String(event.data.turn), role: 'update' } + } + return null + }, + start: (_context, match) => { + if (match.event.type !== 'turn/start') throw new Error('deliverables start requires turn/start') + return { turn: match.event.data.turn, calls: new Map(), produced: [] } + }, + update: (context, match) => { + if (match.event.type === 'tool/call') { + const calls = new Map(context.state.calls) + calls.set( + String(match.event.data.callId), + match.view?.for === 'call' ? match.view.view : null, + ) + return { ...context.state, calls } + } + if (match.event.type !== 'tool/result') return context.state + const result = match.event.data.message.content[0] + if (result.isError === true) return context.state + const callId = String(match.event.data.message.source.callId) + const additions = producedPaths(context.state.calls.get(callId) ?? null) + .map(path => ({ seq: match.event.seq, path })) + return additions.length === 0 + ? context.state + : { ...context.state, produced: [...context.state.produced, ...additions] } + }, + buildLocationData: (context, scope) => scope !== 'turn' || context.state === undefined + ? null + : { + kind: 'turn', + turn: context.state.turn, + key: 'deliverables', + value: { produced: context.state.produced }, + }, + buildViewNode: () => null, +} + /** * Trailing path segment, the part that identifies the file at a glance. * @param path - Slash- or backslash-separated path. diff --git a/packages/client/ui-tool/src/client/apply.ts b/packages/client/ui-tool/src/client/apply.ts index 48a9a4c812..ec2f0b8ec1 100644 --- a/packages/client/ui-tool/src/client/apply.ts +++ b/packages/client/ui-tool/src/client/apply.ts @@ -20,8 +20,9 @@ export const inject = ['slots'] * @param ctx - Client root context. */ export function apply(ctx: ClientContext): void { - ctx.slots.inject('conversation.chat.tool', () => ctx.slots.register({ - name: 'conversation.chat.tool', + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ + name: 'conversation.chat.node', + key: 'tool-call', locale: NS, children: { 'tool.call.toolview': { kind: 'keyed', scope: 'session' }, diff --git a/packages/client/ui-tool/src/client/contract/slots.ts b/packages/client/ui-tool/src/client/contract/slots.ts index 4b74055b2c..26039efc6f 100644 --- a/packages/client/ui-tool/src/client/contract/slots.ts +++ b/packages/client/ui-tool/src/client/contract/slots.ts @@ -30,8 +30,8 @@ export interface ToolCallOwnerProps { /** Full props of a registered atomic Tool view. */ export type ToolCallViewProps = PropsRuntime<'tool.call.toolview'> -/** Full props of the Tool call-tree renderer registered into the chat flow. */ -export type ToolTreeProps = PropsRuntime<'conversation.chat.tool'> +/** Full props of the Tool call-tree renderer registered as a `tool-call` Chat Node. */ +export type ToolTreeProps = PropsRuntime<'conversation.chat.node', 'tool-call'> & PropsRenderSlots<'tool.call.toolview'> & PropsLocale<'conversation'> diff --git a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx index 278be7dacf..db3ed0af06 100644 --- a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx +++ b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx @@ -88,8 +88,9 @@ const ToolCallBranch = memo(function ToolCallBranch({ * @returns the Tool call tree. */ export function ToolCallTree({ - renderSlot, block, selectedCallId, cwd, openFile, inspectCall, t, + renderSlot, node, selectedCallId, cwd, openFile, inspectCall, t, }: ToolTreeProps) { + const block = node.data.root return (