refactor(ui-conversation): register business node definitions
This commit is contained in:
@@ -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:^",
|
||||
|
||||
@@ -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<typeof chatStore>): ChatViewInjected => {
|
||||
|
||||
@@ -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<PropsRenderSlots<'conversation.chat.turnTail'>, '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 (
|
||||
<div className={css.root} data-streaming={streaming || undefined} data-time-hover-root>
|
||||
<div className={css.root} data-streaming={streaming || undefined}>
|
||||
<div className={css.body}>
|
||||
{blocks.map((block, i) => {
|
||||
switch (block.kind) {
|
||||
@@ -119,21 +72,6 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
})}
|
||||
{interrupted && <span className={css.stopped}>{t('message.stopped')}</span>}
|
||||
</div>
|
||||
{showActions && turnTail?.renderSlotChain('conversation.chat.turnTail', turnTail.owner)}
|
||||
{showActions && (
|
||||
<MessageIconActions
|
||||
text={copyText(blocks)}
|
||||
time={time}
|
||||
runMs={runMs}
|
||||
ttftMs={ttftMs}
|
||||
tokensPerSecond={tokensPerSecond}
|
||||
clock="end"
|
||||
onBranch={onFork === undefined || seq === undefined ? undefined : () => { onFork(seq) }}
|
||||
branchUnavailable={forkUnavailable}
|
||||
className={css.actions}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -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<TurnTailOwnerProps | undefined>(() => {
|
||||
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 (
|
||||
<AssistantMarkdown
|
||||
blocks={data.blocks}
|
||||
streaming={data.status === 'running'}
|
||||
interrupted={data.status === 'interrupted'}
|
||||
mentions={mentions}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
})
|
||||
@@ -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<Kind> }
|
||||
}[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<ChatNodeOwnerProps | null>(() => 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 (
|
||||
<div
|
||||
className={css.flowItem}
|
||||
data-chat-anchor-key={routedNode.key}
|
||||
data-chat-flow-key={routedNode.key}
|
||||
data-chat-flow-kind={routedNode.kind}
|
||||
>
|
||||
{renderSlot('conversation.chat.node', routedOwner, {
|
||||
entryKey: routedNode.kind,
|
||||
hookContext: nodeKey,
|
||||
fallback: (
|
||||
<JsonBlock
|
||||
label={t('message.unknownSurface', { type: routedNode.kind })}
|
||||
payload={routedNode.data}
|
||||
truncatedLabel={total => t('json.truncated', { total })}
|
||||
/>
|
||||
),
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<ReturnType<ChatViewSlotProps['chatScroll']['read']>>
|
||||
|
||||
/** ui-slots' UseSession is deliberately wide (dependency direction); the
|
||||
* chat view narrows once to the runtime snapshot the binding actually feeds. */
|
||||
type UseConversation = SnapshotSelectorHook<ConversationSnapshot>
|
||||
|
||||
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 (
|
||||
<div className={css.toolGroup}>
|
||||
{results.map(node => (
|
||||
<ToolSeat
|
||||
key={node.callId}
|
||||
renderSlot={renderSlot}
|
||||
callId={node.callId}
|
||||
toolName={node.call?.name ?? ''}
|
||||
block={node}
|
||||
openFile={openFile}
|
||||
selectedCallId={treeContainsCall(node, selectedCallId) ? selectedCallId : undefined}
|
||||
cwd={cwd}
|
||||
inspectCall={inspectCall}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
/** 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<ConversationNode, { kind: 'compaction' }>
|
||||
t: ChatViewSlotProps['t']
|
||||
}) {
|
||||
const owner = useMemo(() => ({ node, ...compaction === undefined ? {} : { compaction } }), [compaction, node])
|
||||
const fallback = node.name === 'compact'
|
||||
? <CompactionCommandCard {...owner} t={t} />
|
||||
: <GenericCommandCard {...owner} t={t} />
|
||||
return (
|
||||
<div className={css.callRow}>
|
||||
{renderSlot('conversation.chat.commandview', owner, {
|
||||
entryKey: node.name ?? '',
|
||||
fallback,
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
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 <AssistantMarkdown blocks={partial.blocks} streaming t={t} />
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<HTMLDivElement | null>(null)
|
||||
const columnRef = useRef<HTMLDivElement | null>(null)
|
||||
@@ -312,11 +184,12 @@ export function ChatView({
|
||||
* scrolls the rest of the way to the floor). */
|
||||
const followSigRef = useRef<string | null>(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 (
|
||||
<ToolGroup
|
||||
renderSlot={renderSlot}
|
||||
results={item.results}
|
||||
openFile={openFile}
|
||||
selectedCallId={selectedCallId}
|
||||
cwd={cwd}
|
||||
inspectCall={inspectCall}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (item.kind === 'command-compaction') {
|
||||
return (
|
||||
<CommandRow
|
||||
renderSlot={renderSlot}
|
||||
node={item.command}
|
||||
compaction={item.compaction}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
}
|
||||
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 (
|
||||
<AssistantMarkdown
|
||||
blocks={node.blocks}
|
||||
streaming={false}
|
||||
interrupted={node.interrupted}
|
||||
time={actionSeqs.has(node.seq) ? node.time : undefined}
|
||||
runMs={timing?.endTime === undefined
|
||||
? undefined
|
||||
: Math.max(0, timing.endTime - timing.startTime)}
|
||||
ttftMs={metrics?.ttftMs}
|
||||
tokensPerSecond={metrics?.tokensPerSecond}
|
||||
seq={node.seq}
|
||||
onFork={forkAt}
|
||||
forkUnavailable={!branchSeqs.has(node.seq)}
|
||||
turnTail={actionSeqs.has(node.seq)
|
||||
? { renderSlotChain, owner: { nodes, seq: node.seq, openFile } }
|
||||
: undefined}
|
||||
fileMentions={actionSeqs.has(node.seq) ? fileMentions : undefined}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (node.kind === 'command') {
|
||||
return <CommandRow renderSlot={renderSlot} node={node} t={t} />
|
||||
}
|
||||
/* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */
|
||||
if (node.kind === 'tool-result') return null
|
||||
return (
|
||||
<MessageItem
|
||||
node={node}
|
||||
retryActive={node.kind === 'model-retry' && node.seq === activeRetry}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.root}>
|
||||
<div ref={listRef} className={css.scroll}>
|
||||
@@ -572,43 +379,21 @@ export function ChatView({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{items.map(item => (
|
||||
<div
|
||||
key={item.key}
|
||||
className={css.flowItem}
|
||||
data-chat-anchor-key={item.kind === 'node'
|
||||
? `node:${String(item.node.seq)}`
|
||||
: item.kind === 'command-compaction'
|
||||
? `node:${String(item.compaction.seq)}`
|
||||
: undefined}
|
||||
data-chat-flow-key={item.key}
|
||||
data-chat-flow-kind={item.kind === 'node'
|
||||
? item.node.kind
|
||||
: item.kind === 'command-compaction'
|
||||
? item.kind
|
||||
: 'tool-group'}
|
||||
>
|
||||
{renderItem(item)}
|
||||
</div>
|
||||
{order.map(nodeKey => (
|
||||
<ChatNodeSeat
|
||||
key={nodeKey}
|
||||
nodeKey={nodeKey}
|
||||
useSession={useSession}
|
||||
selectedCallId={selectedCallId}
|
||||
cwd={cwd}
|
||||
openFile={openFile}
|
||||
inspectCall={inspectCall}
|
||||
forkAt={forkAt}
|
||||
fileMentions={fileMentions}
|
||||
renderSlot={renderSlot}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
<StreamingTail useSession={useSession} t={t} />
|
||||
{runningCalls.length > 0 && (
|
||||
<div className={css.toolGroup}>
|
||||
{runningCalls.map(call => (
|
||||
<ToolSeat
|
||||
key={call.callId}
|
||||
renderSlot={renderSlot}
|
||||
callId={call.callId}
|
||||
toolName={call.name}
|
||||
block={call}
|
||||
openFile={openFile}
|
||||
selectedCallId={treeContainsCall(call, selectedCallId) ? selectedCallId : undefined}
|
||||
cwd={cwd}
|
||||
inspectCall={inspectCall}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* No pending placeholders: questions (ui-question) and approvals
|
||||
(ApprovalPanel) both take over the composer, so a flow card would
|
||||
double-render the same wait. */}
|
||||
|
||||
@@ -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<CommandRowOwnerProps>(() => ({ node: command }), [command])
|
||||
return (
|
||||
<div className={css.callRow}>
|
||||
{renderSlot('conversation.chat.commandview', owner, {
|
||||
entryKey: command.name ?? '',
|
||||
fallback: <GenericCommandCard {...owner} t={t} />,
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
/** One integrated `/compact` command and compaction transaction renderer. */
|
||||
export const ManualCompactionNodeView = memo(function ManualCompactionNodeView({
|
||||
node, t,
|
||||
}: ChatNodeViewProps<'manual-compaction'>) {
|
||||
const data = node.data
|
||||
return (
|
||||
<div className={css.callRow}>
|
||||
<CompactionCommandCard
|
||||
node={data.command}
|
||||
{...data.compaction === null ? {} : { compaction: data.compaction }}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -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 (
|
||||
<UserStyleBubble
|
||||
content={node.content}
|
||||
steering={node.kind === 'steering'}
|
||||
t={t}
|
||||
actions={text => (
|
||||
<MessageIconActions
|
||||
text={text}
|
||||
time={node.time}
|
||||
clock="start"
|
||||
className={css.actions}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
case 'context':
|
||||
return (
|
||||
<ContextInjectionRow
|
||||
content={node.content}
|
||||
source={node.source}
|
||||
provenance={node.provenance}
|
||||
form={node.form}
|
||||
/** User and admitted-steering keyed Chat renderer. */
|
||||
export const UserMessageNodeView = memo(function UserMessageNodeView({
|
||||
node, t,
|
||||
}: ChatNodeViewProps<'user' | 'steering'>) {
|
||||
const data = node.data
|
||||
return (
|
||||
<UserStyleBubble
|
||||
content={data.content}
|
||||
steering={data.kind === 'steering'}
|
||||
t={t}
|
||||
actions={text => (
|
||||
<MessageIconActions
|
||||
text={text}
|
||||
time={data.time}
|
||||
clock="start"
|
||||
className={css.actions}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
case 'compaction':
|
||||
return <CompactionItem node={node} t={t} />
|
||||
case 'model-retry':
|
||||
return <ModelRetryItem node={node} active={retryActive} t={t} />
|
||||
case 'turn-error':
|
||||
return <TurnErrorItem node={node} t={t} />
|
||||
default:
|
||||
return (
|
||||
<div className={css.contextRow}>
|
||||
<JsonBlock label={t('message.unknownSurface', { type: node.type })} payload={node.data} truncatedLabel={truncated} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
/** Injected-context keyed Chat renderer. */
|
||||
export const ContextMessageNodeView = memo(function ContextMessageNodeView({ node, t }: ChatNodeViewProps<'context'>) {
|
||||
const data = node.data
|
||||
return (
|
||||
<ContextInjectionRow
|
||||
content={data.content}
|
||||
source={data.source}
|
||||
provenance={data.provenance}
|
||||
form={data.form}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
/** Automatic compaction keyed Chat renderer. */
|
||||
export const CompactionNodeView = memo(function CompactionNodeView({ node, t }: ChatNodeViewProps<'compaction'>) {
|
||||
return <CompactionItem node={node.data} t={t} />
|
||||
})
|
||||
|
||||
/** Correlated retry-chain keyed Chat renderer. */
|
||||
export const RetryNodeView = memo(function RetryNodeView({ node, t }: ChatNodeViewProps<'model-retry'>) {
|
||||
const data = node.data
|
||||
return <ModelRetryItem node={data.current} active={data.current.retryState === 'scheduled'} t={t} />
|
||||
})
|
||||
|
||||
/** Terminal turn-error keyed Chat renderer. */
|
||||
export const TurnErrorNodeView = memo(function TurnErrorNodeView({ node, t }: ChatNodeViewProps<'turn-error'>) {
|
||||
return <TurnErrorItem node={node.data} t={t} />
|
||||
})
|
||||
|
||||
/** Explicit unknown-surface keyed Chat renderer. */
|
||||
export const UnknownNodeView = memo(function UnknownNodeView({ node, t }: ChatNodeViewProps<'unknown'>) {
|
||||
const data = node.data
|
||||
return (
|
||||
<div className={css.contextRow}>
|
||||
<JsonBlock
|
||||
label={t('message.unknownSurface', { type: data.type })}
|
||||
payload={data.data}
|
||||
truncatedLabel={total => t('json.truncated', { total })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-left: -6px;
|
||||
}
|
||||
@@ -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 : <div className={css.root}>{tail}</div>
|
||||
const runMs = turn.start === undefined || turn.end === undefined
|
||||
? undefined
|
||||
: Math.max(0, turn.end.time - turn.start.time)
|
||||
return (
|
||||
<div className={css.root} data-turn-tail={data.turn} data-time-hover-root>
|
||||
{tail}
|
||||
<MessageIconActions
|
||||
text={assistantText(closing.blocks)}
|
||||
time={closing.time}
|
||||
runMs={runMs}
|
||||
ttftMs={data.ttftMs}
|
||||
tokensPerSecond={data.tokensPerSecond}
|
||||
clock="end"
|
||||
onBranch={() => { forkAt(closing.finalNode.seq) }}
|
||||
branchUnavailable={data.branchUnavailable}
|
||||
className={css.actions}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -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<string, CompactionSummaryNode>
|
||||
readonly byCompactionSeq: ReadonlyMap<number, CommandNode>
|
||||
} {
|
||||
const commandsBySource = new Map<number, CommandNode | null>()
|
||||
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<number, CompactionSummaryNode | null>()
|
||||
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<string, CompactionSummaryNode>()
|
||||
const byCompactionSeq = new Map<number, CommandNode>()
|
||||
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<number, number>,
|
||||
): ReadonlySet<number> {
|
||||
const lastByTurn = new Map<number, number>()
|
||||
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<number, number>,
|
||||
): ReadonlySet<number> {
|
||||
const result = new Set<number>()
|
||||
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('|')
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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<ConversationSnapshot['chat']['nodes']['get']>): 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
|
||||
}
|
||||
@@ -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('')
|
||||
}
|
||||
@@ -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<ConversationSnapshot['nodes'][number], { kind: 'assistant' }>
|
||||
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<number, TurnMetrics> {
|
||||
export function deriveTurnMetrics(nodes: readonly ConversationNode[]): Map<number, TurnMetrics> {
|
||||
const folds = new Map<number, TurnFold>()
|
||||
for (const node of nodes) {
|
||||
if (node.kind !== 'assistant') continue
|
||||
|
||||
@@ -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<Kind extends ChatNodeKind = ChatNodeKind> = {
|
||||
[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<ToolCallBlock, { kind: 'tool-result' }> {
|
||||
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)
|
||||
}
|
||||
@@ -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<Kind> } }
|
||||
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:<id>"`
|
||||
* and `data-chat-call-id="<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 extends keyof ConversationTurnDataMap & string>(
|
||||
key: Key,
|
||||
) => Readonly<ConversationTurnDataMap[Key]> | 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<Kind extends ChatNodeKind = ChatNodeKind> =
|
||||
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<ChatStore> & ChatViewInjected & PropsLocale<'conversation'>
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<AssistantState>,
|
||||
): 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>): 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<AssistantState>): 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<AssistantState> = {
|
||||
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)
|
||||
}
|
||||
+456
@@ -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<T>(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<string, ChatConversationViewNode>()
|
||||
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<number, readonly string[]>()
|
||||
private steps = new Map<string, readonly string[]>()
|
||||
|
||||
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<number, string[]>()
|
||||
const steps = new Map<string, string[]>()
|
||||
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<number>()
|
||||
const steps = new Set<string>()
|
||||
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<Key>(
|
||||
previous: ReadonlyMap<Key, readonly string[]>,
|
||||
nextMutable: ReadonlyMap<Key, string[]>,
|
||||
): Map<Key, readonly string[]> {
|
||||
const next = new Map<Key, readonly string[]>()
|
||||
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<string, LegacyContribution>()
|
||||
private readonly finalizedContributions = new Map<string, LegacyContribution>()
|
||||
private readonly runningContributions = new Map<string, LegacyContribution>()
|
||||
private readonly partialContributions = new Map<string, LegacyContribution>()
|
||||
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<number, { startTime: number; endTime?: number }>()
|
||||
const turnEnds = new Map<number, number>()
|
||||
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<string, LegacyContribution>,
|
||||
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<ChatConversationViewNode, ChatSnapshot> {
|
||||
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<ChatConversationViewNode, ChatSnapshot> = {
|
||||
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)
|
||||
}
|
||||
@@ -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<ConversationNodeDefinition['match']>[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>): 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 extends CompactionEvidence>(
|
||||
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<CommandState> = {
|
||||
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 }
|
||||
@@ -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<Kind extends ChatNodeKind>(
|
||||
context: ConversationNodeContext,
|
||||
kind: Kind,
|
||||
anchorSeq: number,
|
||||
data: ChatNodeDataMap[Kind],
|
||||
options: {
|
||||
readonly location?: ConversationLocation
|
||||
readonly visibility?: 'visible' | 'hidden'
|
||||
} = {},
|
||||
): ChatNode<Kind> {
|
||||
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
|
||||
}
|
||||
@@ -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>): 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<CompactionState> = {
|
||||
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)
|
||||
}
|
||||
@@ -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<UnknownSurfaceNode> = {
|
||||
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)
|
||||
}
|
||||
@@ -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<string>
|
||||
}
|
||||
|
||||
function applySplice(
|
||||
previous: ConversationPreviousContext<InboxState> | 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<InboxState> {
|
||||
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<InboxState>(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)
|
||||
}
|
||||
@@ -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<ConversationNodeDefinition['match']>[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<MessageNode> = {
|
||||
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<InboxState>('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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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> = Node extends unknown
|
||||
? Omit<Node, 'kind' | 'seq' | 'time' | 'retryState'>
|
||||
: never
|
||||
type RetryEventData = WithoutRetryProjection<ModelRetryNode>
|
||||
|
||||
/** 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<string, unknown>
|
||||
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<RetryState> = {
|
||||
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)
|
||||
}
|
||||
@@ -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<string, readonly ToolCallBlock[]>
|
||||
readonly parents: ReadonlyMap<string, string>
|
||||
}
|
||||
|
||||
interface ProjectedBlockCache {
|
||||
readonly children: readonly ToolCallBlock[]
|
||||
readonly interruptionSeq: number | undefined
|
||||
readonly interruptionTime: number | undefined
|
||||
readonly value: ToolCallBlock
|
||||
}
|
||||
|
||||
const projectedBlocks = new WeakMap<ToolCallBlock, ProjectedBlockCache>()
|
||||
|
||||
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<string>()
|
||||
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<string>()
|
||||
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<string>(),
|
||||
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<T>(left: readonly T[], right: readonly T[]): boolean {
|
||||
return left.length === right.length && left.every((value, index) => value === right[index])
|
||||
}
|
||||
|
||||
function interruption(context: ConversationNodeContext<ToolState>): { 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>): 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<ToolState> = {
|
||||
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)
|
||||
}
|
||||
@@ -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<TurnErrorState>): 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<ConversationNodeDefinition['match']>[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>): 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<TurnErrorState> = {
|
||||
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)
|
||||
}
|
||||
@@ -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<ConversationNodeDefinition['match']>[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<ConversationNodeDefinition['match']>[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<ConversationNodeDefinition['match']>[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<TurnTailState>): 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<number, StepEvidence>()
|
||||
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<TurnTailState>): 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<TurnTailState>): 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<AssistantChatData> => candidate !== undefined)
|
||||
const finalized = assistants
|
||||
.filter((candidate): candidate is Readonly<FinalAssistantChatData> => 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<TurnTailState> = {
|
||||
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)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 <ApprovalFlow key={approval.key} pending={approval} t={props.t} {...command === undefined ? {} : { command }} />
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = () => {}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<string, ToolResultNode['callView']>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string>()
|
||||
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<DeliverablesTurnData> | undefined,
|
||||
seq = Number.POSITIVE_INFINITY,
|
||||
): readonly string[] {
|
||||
if (data === undefined) return []
|
||||
const paths: string[] = []
|
||||
const seen = new Set<string>()
|
||||
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<DeliverablesState> = {
|
||||
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.
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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'>
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<ToolCallBranch
|
||||
renderSlot={renderSlot}
|
||||
|
||||
Reference in New Issue
Block a user