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-primitives": "^0.0.1",
|
||||||
"@deepseek-ai/dsh-client-ui-slash": "^0.0.1",
|
"@deepseek-ai/dsh-client-ui-slash": "^0.0.1",
|
||||||
"@deepseek-ai/dsh-client-ui-slots": "^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-invariants": "^0.0.1",
|
||||||
"@deepseek-ai/dsh-token-meter": "^0.0.1",
|
"@deepseek-ai/dsh-token-meter": "^0.0.1",
|
||||||
"cordis": "^4.0.0-rc.7",
|
"cordis": "^4.0.0-rc.7",
|
||||||
@@ -53,6 +54,7 @@
|
|||||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
||||||
|
"@deepseek-ai/dsh-compact": "workspace:^",
|
||||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||||
"@deepseek-ai/dsh-plan-mode": "workspace:^",
|
"@deepseek-ai/dsh-plan-mode": "workspace:^",
|
||||||
"@deepseek-ai/dsh-client-ui-layout": "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 {} from '@deepseek-ai/dsh-client-locale/client'
|
||||||
import type { ViewTab } from './contract/views.ts'
|
import type { ViewTab } from './contract/views.ts'
|
||||||
import type {
|
import type {
|
||||||
ApprovalWait, ChatScrollPosition, ChatViewInjected, ComposerBarInjected, ComposerChainProps, ConversationInjected,
|
ApprovalWait, ChatNodeTurnDataInjected, ChatScrollPosition, ChatViewInjected, ComposerBarInjected,
|
||||||
ConversationSessionHeaderInjected, ConversationSessionInjected, DetailsInjected,
|
ComposerChainProps, ConversationInjected, ConversationSessionHeaderInjected, ConversationSessionInjected,
|
||||||
|
DetailsInjected,
|
||||||
} from './contract/slots.ts'
|
} from './contract/slots.ts'
|
||||||
import type { InputNotice } from './input/contract.ts'
|
import type { InputNotice } from './input/contract.ts'
|
||||||
import { createChatStore } from './stores.ts'
|
import { createChatStore } from './stores.ts'
|
||||||
@@ -30,6 +31,8 @@ import { ConversationRoot } from './skeleton/ConversationRoot.tsx'
|
|||||||
import { ConversationSession, ConversationSessionHeader } from './skeleton/ConversationSession.tsx'
|
import { ConversationSession, ConversationSessionHeader } from './skeleton/ConversationSession.tsx'
|
||||||
import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
|
import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
|
||||||
import { en, NS, zh, type ConversationKey } from './locales.ts'
|
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' {
|
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||||
interface LocaleNamespaceMap {
|
interface LocaleNamespaceMap {
|
||||||
@@ -39,7 +42,10 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Services required by the conversation plugin. */
|
/** 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
|
// Static no-session sources for the composer-bar hooks compartment: module
|
||||||
// constants so the render side's per-source hook cache (observableHook) keeps
|
// constants so the render side's per-source hook cache (observableHook) keeps
|
||||||
@@ -63,6 +69,19 @@ const ABSENT_MENU_LAUNCHER = {
|
|||||||
subscribe: () => () => {},
|
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. */
|
/** Resolve the session-scoped conversation face (scope-addressed send/cancel), failing loud. */
|
||||||
function scopedConversation(sessions: ISessions, id: SessionId): IConversation {
|
function scopedConversation(sessions: ISessions, id: SessionId): IConversation {
|
||||||
const scoped = sessions.scope(id)
|
const scoped = sessions.scope(id)
|
||||||
@@ -86,6 +105,9 @@ export function apply(ctx: Context): void {
|
|||||||
const layout = ctx.layout
|
const layout = ctx.layout
|
||||||
const slots = ctx.slots
|
const slots = ctx.slots
|
||||||
|
|
||||||
|
registerConversationNodes(ctx)
|
||||||
|
registerChatNodeRenderers(ctx)
|
||||||
|
|
||||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-conversation: dictionaries')
|
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-conversation: dictionaries')
|
||||||
|
|
||||||
// Registration-time text (the view tab label) reads through the bound
|
// 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)
|
slots.register({ name: 'conversation.composer', select: selectApproval, priority: 1, locale: NS }, ApprovalPanel)
|
||||||
|
|
||||||
// The chat view: first entry of the ring this package just declared.
|
// The chat view: first entry of the ring this package just declared.
|
||||||
// ChatView owns ordered Tool placement but delegates each whole root call
|
// ChatView owns only the stable ordered Node list. Business renderers are
|
||||||
// to ui-tool, which owns root/subcall composition and atomic dispatch.
|
// independently keyed behind its one Node seat.
|
||||||
slots.register({
|
slots.register({
|
||||||
name: 'conversation.view',
|
name: 'conversation.view',
|
||||||
id: 'chat',
|
id: 'chat',
|
||||||
@@ -305,9 +327,7 @@ export function apply(ctx: Context): void {
|
|||||||
label: () => t('view.chat'),
|
label: () => t('view.chat'),
|
||||||
locale: NS,
|
locale: NS,
|
||||||
children: {
|
children: {
|
||||||
'conversation.chat.tool': { kind: 'single', scope: 'session' },
|
'conversation.chat.node': { kind: 'keyed', scope: 'session', inject: CHAT_NODE_INJECT },
|
||||||
'conversation.chat.commandview': { kind: 'keyed', scope: 'session' },
|
|
||||||
'conversation.chat.turnTail': { kind: 'chain', scope: 'session' },
|
|
||||||
},
|
},
|
||||||
store: chatStore,
|
store: chatStore,
|
||||||
inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ChatViewInjected => {
|
inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ChatViewInjected => {
|
||||||
|
|||||||
@@ -11,12 +11,9 @@
|
|||||||
|
|
||||||
import { memo, useMemo } from 'react'
|
import { memo, useMemo } from 'react'
|
||||||
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
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 { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||||
import type { MarkdownFileMentions } 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 type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||||
import { hasContentText } from './chat-flow.ts'
|
|
||||||
import { MessageIconActions } from './MessageIconActions.tsx'
|
|
||||||
import { ReasoningRow } from './ReasoningRow.tsx'
|
import { ReasoningRow } from './ReasoningRow.tsx'
|
||||||
import css from './AssistantMarkdown.module.css'
|
import css from './AssistantMarkdown.module.css'
|
||||||
|
|
||||||
@@ -25,61 +22,19 @@ export interface AssistantMarkdownProps {
|
|||||||
streaming: boolean
|
streaming: boolean
|
||||||
/** Frozen partial of an aborted turn: rendered with a stopped marker. */
|
/** Frozen partial of an aborted turn: rendered with a stopped marker. */
|
||||||
interrupted?: boolean | undefined
|
interrupted?: boolean | undefined
|
||||||
/** Unix epoch ms for the IconActions clock; omitted while streaming or when
|
/** Resolved prose file mentions for this Assistant's closing turn. */
|
||||||
* the parent withholds chrome (mid-turn content assistants and every node
|
mentions?: MarkdownFileMentions | undefined
|
||||||
* 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
|
|
||||||
/** The owning view's locale seat, passed down as a plain prop. */
|
/** The owning view's locale seat, passed down as a plain prop. */
|
||||||
t: ChatViewSlotProps['t']
|
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). */
|
/** Reasoning block as the Think variant summary row (figma 39:28304). */
|
||||||
export const AssistantMarkdown = memo(function AssistantMarkdown({
|
export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||||
blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, turnTail,
|
blocks, streaming, interrupted, mentions, t,
|
||||||
fileMentions, t,
|
|
||||||
}: AssistantMarkdownProps) {
|
}: AssistantMarkdownProps) {
|
||||||
// Stable per locale revision (t identity changes on switch): a fresh object
|
// Stable per locale revision (t identity changes on switch): a fresh object
|
||||||
// per render would rebuild MarkdownText's component table every chunk.
|
// per render would rebuild MarkdownText's component table every chunk.
|
||||||
const codeLabels = useMemo(() => ({ copyLabel: t('copy'), copiedLabel: t('copied') }), [t])
|
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
|
const last = blocks.length - 1
|
||||||
// Tool-call heads render as tool rows in the chat view's grouping pass, so
|
// 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
|
// 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
|
|| interrupted === true
|
||||||
|| blocks.some(block => block.kind !== 'tool-call')
|
|| blocks.some(block => block.kind !== 'tool-call')
|
||||||
if (!hasVisible) return null
|
if (!hasVisible) return null
|
||||||
// Footer only under settled content text; Think-only / streaming omit it.
|
|
||||||
const showActions = !streaming && time !== undefined && hasContentText(blocks)
|
|
||||||
return (
|
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}>
|
<div className={css.body}>
|
||||||
{blocks.map((block, i) => {
|
{blocks.map((block, i) => {
|
||||||
switch (block.kind) {
|
switch (block.kind) {
|
||||||
@@ -119,21 +72,6 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
|
|||||||
})}
|
})}
|
||||||
{interrupted && <span className={css.stopped}>{t('message.stopped')}</span>}
|
{interrupted && <span className={css.stopped}>{t('message.stopped')}</span>}
|
||||||
</div>
|
</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>
|
</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
|
/* Chat flow: one 16px rhythm everywhere through the column gap. Input
|
||||||
runs) via the column gap and between consecutive tool rows via the group
|
padding cap rides the skeleton. Under
|
||||||
gap. Input padding cap rides the skeleton. Under
|
|
||||||
`[data-conversation-scroll]` the column host owns overflow and this view
|
`[data-conversation-scroll]` the column host owns overflow and this view
|
||||||
is ordinary flow (see ConversationRoot active-phase rules). */
|
is ordinary flow (see ConversationRoot active-phase rules). */
|
||||||
|
|
||||||
@@ -51,10 +50,11 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolGroup {
|
/* A keyed renderer may intentionally decline its row after dispatch (the
|
||||||
display: flex;
|
completed-turn tail does this when it owns neither actions nor extensions).
|
||||||
flex-direction: column;
|
An empty flex item must not consume the column gap. */
|
||||||
gap: 16px;
|
.flowItem:empty {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.callRow {
|
.callRow {
|
||||||
|
|||||||
@@ -1,41 +1,24 @@
|
|||||||
// ChatView: the default conversation view — message flow with user bubbles,
|
// ChatView: the default conversation view — one stable keyed parent list over
|
||||||
// assistant narration, tool summary rows grouped into step runs, pending
|
// final business Nodes, plus paging, pending steering and bottom-follow.
|
||||||
// cards, paging, and bottom-follow. Session stats live on
|
// Each row dispatches through 'conversation.chat.node'; ui-tool owns the
|
||||||
// 'conversation.composer.dock' (sticky with the composer). Pure component
|
// tool-call renderer and its recursive root/subcall composition.
|
||||||
// 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.
|
|
||||||
//
|
//
|
||||||
// Scroll: when nested under `[data-conversation-scroll]` (active conversation
|
// Scroll: when nested under `[data-conversation-scroll]` (active conversation
|
||||||
// column), that host is the scrollport and this view is flow content; when
|
// column), that host is the scrollport and this view is flow content; when
|
||||||
// mounted alone (unit tests), `.scroll` owns overflow. Bottom-follow and
|
// mounted alone (unit tests), `.scroll` owns overflow. Bottom-follow and
|
||||||
// prepend anchoring always target the resolved scrollport.
|
// prepend anchoring always target the resolved scrollport.
|
||||||
//
|
//
|
||||||
// Render economics (architecture RFC performance model): the list parent
|
// Render economics: order changes only when rows enter, leave or move. Each
|
||||||
// subscribes to snapshot segments that do NOT change per streaming chunk
|
// ChatNodeSeat subscribes to one Node key, so Assistant deltas and Tool
|
||||||
// (nodes/runningCalls/pending keep their references across chunk batches), so
|
// lifecycle updates replace only their own row without remounting it.
|
||||||
// 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.
|
|
||||||
|
|
||||||
import {
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||||
memo, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode,
|
import type { ConversationTimelineSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
} 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 { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||||
import { assistantActionsSeqs, assistantBranchSeqs, deriveChatFlow, runningTurnStartTime, type ChatFlowItem } from './chat-flow.ts'
|
import { PendingSteeringBubble } from './MessageItem.tsx'
|
||||||
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
|
import { ChatNodeSeat } from './ChatNodeSeat.tsx'
|
||||||
import { CompactionCommandCard } from './CompactionCommandCard.tsx'
|
|
||||||
import { GenericCommandCard } from './GenericCommandCard.tsx'
|
|
||||||
import { MessageItem, PendingSteeringBubble } from './MessageItem.tsx'
|
|
||||||
import { formatRunDuration } from './message-chrome.ts'
|
import { formatRunDuration } from './message-chrome.ts'
|
||||||
import { deriveTurnMetrics } from './turn-metrics.ts'
|
|
||||||
import css from './ChatView.module.css'
|
import css from './ChatView.module.css'
|
||||||
|
|
||||||
const FOLLOW_THRESHOLD = 24
|
const FOLLOW_THRESHOLD = 24
|
||||||
@@ -98,35 +81,8 @@ function pagingAnchor(list: HTMLElement, scrollport: HTMLElement): HTMLElement |
|
|||||||
return visibleRows[0] ?? rows[0] ?? null
|
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']>>
|
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. */
|
/** Capture a reflow-resistant reader position from the current rendered window. */
|
||||||
function scrollPosition(list: HTMLElement, scrollport: HTMLElement): ChatScrollPosition | null {
|
function scrollPosition(list: HTMLElement, scrollport: HTMLElement): ChatScrollPosition | null {
|
||||||
const row = pagingAnchor(list, scrollport)
|
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. */
|
function runningTurnStartTime(timeline: ConversationTimelineSnapshot): number | null {
|
||||||
const ToolSeat = memo(function ToolSeat({
|
let latest: number | null = null
|
||||||
renderSlot, callId, toolName, block, openFile, selectedCallId, cwd, inspectCall,
|
for (const turn of timeline.turns.values()) {
|
||||||
}: {
|
if (turn.status === 'open' && turn.start !== undefined) latest = turn.start.time
|
||||||
renderSlot: RenderChatSlot
|
}
|
||||||
callId: string
|
return latest
|
||||||
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>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
/** Turn-level model activity label retained across first-token, tool, and streaming phases. */
|
/** Turn-level model activity label retained across first-token, tool, and streaming phases. */
|
||||||
function TurnStatus({ startTime, t }: {
|
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
|
* 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({
|
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,
|
fileMentions, t,
|
||||||
}: ChatViewSlotProps) {
|
}: ChatViewSlotProps) {
|
||||||
const nodes = useSession(s => s.nodes)
|
const order = useSession(s => s.chat.order)
|
||||||
const turnTimings = useSession(s => s.turnTimings)
|
const nodeStore = useSession(s => s.chat.nodes)
|
||||||
const turnEnds = useSession(s => s.turnEnds)
|
const timeline = useSession(s => s.chat.timeline)
|
||||||
const inbox = useSession(s => s.queue)
|
const inbox = useSession(s => s.queue)
|
||||||
// Workspace root off the session list row: path summaries display relative to it.
|
// Workspace root off the session list row: path summaries display relative to it.
|
||||||
const cwd = useSessions(s => s.byId[sessionId]?.cwd)
|
const cwd = useSessions(s => s.byId[sessionId]?.cwd)
|
||||||
const running = useSession(s => s.running)
|
const running = useSession(s => s.running)
|
||||||
const runningCalls = useSession(s => s.runningCalls)
|
|
||||||
const openState = useSession(s => s.openState)
|
const openState = useSession(s => s.openState)
|
||||||
const openError = useSession(s => s.openError)
|
const openError = useSession(s => s.openError)
|
||||||
const hasMore = useSession(s => s.hasMore)
|
const hasMore = useSession(s => s.hasMore)
|
||||||
const loadingOlder = useSession(s => s.loadingOlder)
|
const loadingOlder = useSession(s => s.loadingOlder)
|
||||||
const selectedCallId = useStore(s => s.selection?.callId)
|
const selectedCallId = useStore(s => s.selection?.callId)
|
||||||
|
|
||||||
const items = useMemo(() => deriveChatFlow(nodes), [nodes])
|
|
||||||
const pendingSteering = useMemo(
|
const pendingSteering = useMemo(
|
||||||
() => inbox.filter(item => item.placement === 'steering'),
|
() => inbox.filter(item => item.placement === 'steering'),
|
||||||
[inbox],
|
[inbox],
|
||||||
)
|
)
|
||||||
const activeRetry = useMemo(() => activeRetrySeq(nodes, running), [nodes, running])
|
const runningTurnStart = useMemo(() => runningTurnStartTime(timeline), [timeline])
|
||||||
// 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 listRef = useRef<HTMLDivElement | null>(null)
|
const listRef = useRef<HTMLDivElement | null>(null)
|
||||||
const columnRef = 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). */
|
* scrolls the rest of the way to the floor). */
|
||||||
const followSigRef = useRef<string | null>(null)
|
const followSigRef = useRef<string | null>(null)
|
||||||
|
|
||||||
const firstSeq = nodes[0]?.seq ?? null
|
const firstKey = order[0]
|
||||||
const lastItem = items[items.length - 1]
|
const firstSeq = firstKey === undefined ? null : nodeStore.get(firstKey)?.anchorSeq ?? null
|
||||||
const lastKey = lastItem?.key ?? null
|
const lastKey = order.at(-1) ?? null
|
||||||
|
const lastNode = lastKey === null ? undefined : nodeStore.get(lastKey)
|
||||||
const lastSteeringId = pendingSteering[pendingSteering.length - 1]?.id ?? null
|
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 => {
|
const toBottom = (el: HTMLElement): void => {
|
||||||
anchorRef.current = null
|
anchorRef.current = null
|
||||||
@@ -377,8 +250,7 @@ export function ChatView({
|
|||||||
firstSeqRef.current = firstSeq
|
firstSeqRef.current = firstSeq
|
||||||
// Own words must be visible: a new trailing user node force-scrolls
|
// 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).
|
// (send lives in the composer, so arrival is detected here, not armed there).
|
||||||
const appendedUser = lastKey !== lastKeyRef.current
|
const appendedUser = lastKey !== lastKeyRef.current && lastNode?.kind === 'user'
|
||||||
&& lastItem !== undefined && lastItem.kind === 'node' && lastItem.node.kind === 'user'
|
|
||||||
const appendedSteering = lastSteeringId !== null && lastSteeringId !== lastSteeringIdRef.current
|
const appendedSteering = lastSteeringId !== null && lastSteeringId !== lastSteeringIdRef.current
|
||||||
const tipMoved = followSigRef.current !== followSig
|
const tipMoved = followSigRef.current !== followSig
|
||||||
lastKeyRef.current = lastKey
|
lastKeyRef.current = lastKey
|
||||||
@@ -490,71 +362,6 @@ export function ChatView({
|
|||||||
loadOlder()
|
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 (
|
return (
|
||||||
<div className={css.root}>
|
<div className={css.root}>
|
||||||
<div ref={listRef} className={css.scroll}>
|
<div ref={listRef} className={css.scroll}>
|
||||||
@@ -572,43 +379,21 @@ export function ChatView({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{items.map(item => (
|
{order.map(nodeKey => (
|
||||||
<div
|
<ChatNodeSeat
|
||||||
key={item.key}
|
key={nodeKey}
|
||||||
className={css.flowItem}
|
nodeKey={nodeKey}
|
||||||
data-chat-anchor-key={item.kind === 'node'
|
useSession={useSession}
|
||||||
? `node:${String(item.node.seq)}`
|
selectedCallId={selectedCallId}
|
||||||
: item.kind === 'command-compaction'
|
cwd={cwd}
|
||||||
? `node:${String(item.compaction.seq)}`
|
openFile={openFile}
|
||||||
: undefined}
|
inspectCall={inspectCall}
|
||||||
data-chat-flow-key={item.key}
|
forkAt={forkAt}
|
||||||
data-chat-flow-kind={item.kind === 'node'
|
fileMentions={fileMentions}
|
||||||
? item.node.kind
|
renderSlot={renderSlot}
|
||||||
: item.kind === 'command-compaction'
|
t={t}
|
||||||
? item.kind
|
/>
|
||||||
: 'tool-group'}
|
|
||||||
>
|
|
||||||
{renderItem(item)}
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
<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
|
{/* No pending placeholders: questions (ui-question) and approvals
|
||||||
(ApprovalPanel) both take over the composer, so a flow card would
|
(ApprovalPanel) both take over the composer, so a flow card would
|
||||||
double-render the same wait. */}
|
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 { memo, useEffect, useMemo, useState } from 'react'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import type {
|
import type {
|
||||||
CompactionSummaryNode, ContextMessageNode, ModelRetryNode, SteeringMessageNode,
|
ModelRetryNode, TurnErrorNode,
|
||||||
TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
|
|
||||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
import { JsonBlock, MessageText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
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 { CompactionItem } from './CompactionItem.tsx'
|
||||||
import { ContextInjectionRow } from './ContextInjectionRow.tsx'
|
import { ContextInjectionRow } from './ContextInjectionRow.tsx'
|
||||||
import { MessageIconActions } from './MessageIconActions.tsx'
|
import { MessageIconActions } from './MessageIconActions.tsx'
|
||||||
import css from './MessageItem.module.css'
|
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[] } {
|
function contentText(content: readonly unknown[]): { text: string; rest: unknown[] } {
|
||||||
const texts: string[] = []
|
const texts: string[] = []
|
||||||
const rest: unknown[] = []
|
const rest: unknown[] = []
|
||||||
@@ -219,50 +204,69 @@ export function PendingSteeringBubble({ content, t }: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MessageItem = memo(function MessageItem({
|
/** User and admitted-steering keyed Chat renderer. */
|
||||||
node, retryActive = false, t,
|
export const UserMessageNodeView = memo(function UserMessageNodeView({
|
||||||
}: MessageItemProps) {
|
node, t,
|
||||||
const truncated = (total: number): string => t('json.truncated', { total })
|
}: ChatNodeViewProps<'user' | 'steering'>) {
|
||||||
switch (node.kind) {
|
const data = node.data
|
||||||
case 'user':
|
return (
|
||||||
case 'steering':
|
<UserStyleBubble
|
||||||
return (
|
content={data.content}
|
||||||
<UserStyleBubble
|
steering={data.kind === 'steering'}
|
||||||
content={node.content}
|
t={t}
|
||||||
steering={node.kind === 'steering'}
|
actions={text => (
|
||||||
t={t}
|
<MessageIconActions
|
||||||
actions={text => (
|
text={text}
|
||||||
<MessageIconActions
|
time={data.time}
|
||||||
text={text}
|
clock="start"
|
||||||
time={node.time}
|
className={css.actions}
|
||||||
clock="start"
|
|
||||||
className={css.actions}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
case 'context':
|
|
||||||
return (
|
|
||||||
<ContextInjectionRow
|
|
||||||
content={node.content}
|
|
||||||
source={node.source}
|
|
||||||
provenance={node.provenance}
|
|
||||||
form={node.form}
|
|
||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
)
|
)}
|
||||||
case 'compaction':
|
/>
|
||||||
return <CompactionItem node={node} t={t} />
|
)
|
||||||
case 'model-retry':
|
})
|
||||||
return <ModelRetryItem node={node} active={retryActive} t={t} />
|
|
||||||
case 'turn-error':
|
/** Injected-context keyed Chat renderer. */
|
||||||
return <TurnErrorItem node={node} t={t} />
|
export const ContextMessageNodeView = memo(function ContextMessageNodeView({ node, t }: ChatNodeViewProps<'context'>) {
|
||||||
default:
|
const data = node.data
|
||||||
return (
|
return (
|
||||||
<div className={css.contextRow}>
|
<ContextInjectionRow
|
||||||
<JsonBlock label={t('message.unknownSurface', { type: node.type })} payload={node.data} truncatedLabel={truncated} />
|
content={data.content}
|
||||||
</div>
|
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) {
|
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 usage = useProjection('tokenUsage')
|
||||||
const stats = useMemo(() => deriveStats(nodes), [nodes])
|
|
||||||
// Pipe-separated groups (figma stats strip); a group with no data drops out whole.
|
// Pipe-separated groups (figma stats strip); a group with no data drops out whole.
|
||||||
const groups: string[] = []
|
const groups: string[] = []
|
||||||
if (stats.steps > 0) {
|
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.
|
// 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. */
|
/** Latency and decode-throughput readings for one turn's footer. */
|
||||||
export interface TurnMetrics {
|
export interface TurnMetrics {
|
||||||
@@ -24,7 +24,7 @@ interface UsageLike {
|
|||||||
outputTokens?: number
|
outputTokens?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type AssistantNode = Extract<ConversationSnapshot['nodes'][number], { kind: 'assistant' }>
|
type AssistantNode = AssistantMessageNode
|
||||||
|
|
||||||
function usageOutputTokens(usage: unknown): number | null {
|
function usageOutputTokens(usage: unknown): number | null {
|
||||||
if (typeof usage !== 'object' || usage === null) return null
|
if (typeof usage !== 'object' || usage === null) return null
|
||||||
@@ -67,7 +67,7 @@ interface TurnFold {
|
|||||||
* @param nodes - Snapshot nodes of the loaded window.
|
* @param nodes - Snapshot nodes of the loaded window.
|
||||||
* @returns Turn number → available metrics; turns with none are absent.
|
* @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>()
|
const folds = new Map<number, TurnFold>()
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
if (node.kind !== 'assistant') continue
|
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. */
|
/** Conversation slot declarations and their composed component props. */
|
||||||
import type { ReactNode, RefObject } from 'react'
|
import type { ReactNode, RefObject } from 'react'
|
||||||
import type {
|
import type {
|
||||||
InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
|
InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore,
|
||||||
|
SlotHookFactory, SnapshotSelectorHook,
|
||||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
} 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 { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||||
import type { ComposerBlock } from '../input/blocks.ts'
|
import type { ComposerBlock } from '../input/blocks.ts'
|
||||||
import type { ComposerKeyboard, EditSelection, InputActions, InputNotice, InputState } from '../input/contract.ts'
|
import type { ComposerKeyboard, EditSelection, InputActions, InputNotice, InputState } from '../input/contract.ts'
|
||||||
import type { createChatStore } from '../stores.ts'
|
import type { createChatStore } from '../stores.ts'
|
||||||
import type { ComposerSubmitGesture, InputSubmitMode } from './composer-submission.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'
|
import type { CallId, SelectionTarget, ViewTab } from './views.ts'
|
||||||
|
|
||||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
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 snapshot through the standard kit.
|
||||||
*/
|
*/
|
||||||
'conversation.view': { kind: 'list'; scope: 'session'; owner: ConvViewOwnerProps }
|
'conversation.view': { kind: 'list'; scope: 'session'; owner: ConvViewOwnerProps }
|
||||||
/**
|
/** Final business node renderer, dispatched by `ChatConversationViewNode.kind`. */
|
||||||
* One root Tool call at its ordered ChatFlow position. The chat view owns
|
'conversation.chat.node': {
|
||||||
* placement; ui-tool owns root/subcall composition and keyed dispatch.
|
kind: 'keyed'
|
||||||
* The filler preserves the call-anchor DOM contract documented by
|
scope: 'session'
|
||||||
* {@link ToolTreeOwnerProps} for every root and child wrapper.
|
owner: ChatNodeOwnerProps
|
||||||
*/
|
keyProps: { [Kind in ChatNodeKind]: { node: ChatNode<Kind> } }
|
||||||
'conversation.chat.tool': { kind: 'single'; scope: 'session'; owner: ToolTreeOwnerProps }
|
hookContext: string
|
||||||
|
inject: ChatNodeTurnDataInjected
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* The chat view's per-command row hole: keyed dispatch on the command
|
* 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
|
* name (`command/run.name`; a run-less cross-window node has none and
|
||||||
@@ -171,7 +179,7 @@ export interface ConvViewOwnerProps {
|
|||||||
export interface ChatFileMentions {
|
export interface ChatFileMentions {
|
||||||
/**
|
/**
|
||||||
* Mention vocabulary for the closing message the owner currency names.
|
* 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
|
* @returns The resolver MarkdownText consumes, or undefined when the turn
|
||||||
* produced nothing worth linking.
|
* produced nothing worth linking.
|
||||||
*/
|
*/
|
||||||
@@ -186,14 +194,13 @@ declare module 'cordis' {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Owner currency of the chat view's turn-tail hole: the finalized snapshot
|
* Owner currency of the chat view's turn-tail hole: the engine-owned Turn and
|
||||||
* and the closing assistant's anchor. Registrants derive their own facts
|
* the closing assistant's anchor. Registrants read their own typed Turn data
|
||||||
* from the nodes (the owner never pre-chews a feature's vocabulary), and
|
* and open files through the same opener the tool rows use.
|
||||||
* open files through the same opener the tool rows use.
|
|
||||||
*/
|
*/
|
||||||
export interface TurnTailOwnerProps {
|
export interface TurnTailOwnerProps {
|
||||||
/** Finalized snapshot nodes in surface order. */
|
/** Engine-owned closing Turn boundary. */
|
||||||
nodes: readonly ConversationNode[]
|
turn: TurnLocation
|
||||||
/** The closing assistant's seq — the anchor the tail renders under. */
|
/** The closing assistant's seq — the anchor the tail renders under. */
|
||||||
seq: number
|
seq: number
|
||||||
/**
|
/**
|
||||||
@@ -203,34 +210,34 @@ export interface TurnTailOwnerProps {
|
|||||||
openFile: (path: string) => void
|
openFile: (path: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Hook constrained to business data published on the current Chat Node's Turn. */
|
||||||
* Owner currency of the chat view's whole-Tool rendering seat. The filler
|
export type UseChatNodeTurnData = <Key extends keyof ConversationTurnDataMap & string>(
|
||||||
* wraps every rendered root and child with `data-chat-anchor-key="call:<id>"`
|
key: Key,
|
||||||
* and `data-chat-call-id="<id>"`, plus `data-selected="true"` for the selected
|
) => Readonly<ConversationTurnDataMap[Key]> | undefined
|
||||||
* call. ChatView consumes those anchors to restore prepend/paging position.
|
|
||||||
*/
|
/** Slot-level Hook factory used by renderers reading their Node's Turn data. */
|
||||||
export interface ToolTreeOwnerProps {
|
export interface ChatNodeTurnDataInjected {
|
||||||
/** Root Tool call identity, stable across running → settled. */
|
hooks: {
|
||||||
callId: CallId
|
turnData: SlotHookFactory<'conversation.chat.node', UseChatNodeTurnData>
|
||||||
/** 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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. */
|
/** Owner currency of the details panel's Tool output renderer. */
|
||||||
export interface DetailsToolOwnerProps {
|
export interface DetailsToolOwnerProps {
|
||||||
/** Frozen selected call slice. */
|
/** 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. */
|
/** Full chat-view component props: runtime & its Tool/command/tail render shares & store & injected & locale seat. */
|
||||||
export type ChatViewSlotProps =
|
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'>
|
& 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)
|
||||||
|
}
|
||||||
@@ -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 { apply, inject } from './apply.ts'
|
||||||
export { ConversationService } from './service.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 { IConversation } from './service.ts'
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
@@ -12,12 +23,16 @@ export type {
|
|||||||
} from './contract/views.ts'
|
} from './contract/views.ts'
|
||||||
export type { ConversationKey } from './locales.ts'
|
export type { ConversationKey } from './locales.ts'
|
||||||
export type {
|
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,
|
ChatStore, ChatViewInjected, ChatViewSlotProps, CommandRowOwnerProps, CommandRowProps, ComposerBarInjected,
|
||||||
ComposerChainProps, ConversationInjected,
|
ComposerChainProps, ConversationInjected,
|
||||||
ConversationSessionHeaderInjected, ConversationSessionInjected, ConversationSlotProps, ConvViewOwnerProps,
|
ConversationSessionHeaderInjected, ConversationSessionInjected, ConversationSlotProps, ConvViewOwnerProps,
|
||||||
ConvViewProps, DetailsInjected, DetailsSlotProps, DetailsToolOwnerProps, EmptyWorkspaceOwnerProps,
|
ConvViewProps, DetailsInjected, DetailsSlotProps, DetailsToolOwnerProps, EmptyWorkspaceOwnerProps,
|
||||||
ToolTreeOwnerProps, TurnTailOwnerProps,
|
TurnTailOwnerProps, UseChatNodeTurnData,
|
||||||
} from './contract/slots.ts'
|
} from './contract/slots.ts'
|
||||||
// Export discipline: packages/client/AGENTS.md.
|
// 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 { Button } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||||
import type { RunningToolCall } from '@deepseek-ai/dsh-client-runtime/client'
|
import type { RunningToolCall } from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
import { PendingApproval, type ApprovalComposerProps } from '../contract/slots.ts'
|
import { PendingApproval, type ApprovalComposerProps } from '../contract/slots.ts'
|
||||||
|
import { rootToolCall } from '../chat/tool-node-reader.ts'
|
||||||
import css from './ApprovalPanel.module.css'
|
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. */
|
/** 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) {
|
export function ApprovalPanel(props: ApprovalComposerProps) {
|
||||||
const approval = useMemo(() => new PendingApproval(props.matched), [props.matched])
|
const approval = useMemo(() => new PendingApproval(props.matched), [props.matched])
|
||||||
const command = props.useSession(s => commandOf(
|
const command = props.useSession((snapshot) => {
|
||||||
approval.callId === undefined ? undefined : s.runningCalls.find(call => call.callId === approval.callId)))
|
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 }} />
|
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 { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
import type { ConversationSnapshot, RunningToolCall, ToolCallBlock, ToolResultNode } 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 type { DetailsSlotProps } from '../contract/slots.ts'
|
||||||
|
import { findToolCall } from '../chat/tool-node-reader.ts'
|
||||||
import css from './DetailsPanel.module.css'
|
import css from './DetailsPanel.module.css'
|
||||||
|
|
||||||
/** Full props composed by reference from the contract (automatic shares & injected share). */
|
/** 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 }
|
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 {
|
function materialFor(s: ConversationSnapshot, callId: string): CallMaterial | null {
|
||||||
for (const node of s.nodes) {
|
const found = findToolCall(s, callId)
|
||||||
if (node.kind !== 'tool-result') continue
|
if (found === undefined) return null
|
||||||
const found = findCall(node, callId)
|
return 'kind' in found ? settledMaterial(found, callId) : runningMaterial(found)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function pretty(raw: string): string {
|
function pretty(raw: string): string {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export const inject = ['invariants']
|
|||||||
/**
|
/**
|
||||||
* No runtime invariant: the conversation service emits no cordis events, and
|
* No runtime invariant: the conversation service emits no cordis events, and
|
||||||
* both rings this package owns (the 'conversation.view' tab ring and the
|
* 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.
|
* invariants live with the runtime slots package.
|
||||||
*/
|
*/
|
||||||
const install: InvariantInstaller = () => {}
|
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 type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||||
import { ProducedFiles } from './ProducedFiles.tsx'
|
import { ProducedFiles } from './ProducedFiles.tsx'
|
||||||
import { en, NS, zh, type DeliverablesKey } from './locales.ts'
|
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' {
|
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||||
interface LocaleNamespaceMap {
|
interface LocaleNamespaceMap {
|
||||||
@@ -25,13 +27,14 @@ export { ProducedFiles, type ProducedFilesProps } from './ProducedFiles.tsx'
|
|||||||
export { producedForClosing } from './turn-deliverables.ts'
|
export { producedForClosing } from './turn-deliverables.ts'
|
||||||
|
|
||||||
/** Required services for the tail-slot registration and its dictionaries. */
|
/** 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.
|
* Client plugin body: register the dictionaries and the turn-tail entry.
|
||||||
* @param ctx - client root context.
|
* @param ctx - client root context.
|
||||||
*/
|
*/
|
||||||
export function apply(ctx: ClientContext): void {
|
export function apply(ctx: ClientContext): void {
|
||||||
|
ctx.conversationEvents.register(deliverablesDefinition)
|
||||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-deliverables: dictionaries')
|
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-deliverables: dictionaries')
|
||||||
ctx.slots.inject(
|
ctx.slots.inject(
|
||||||
'conversation.chat.turnTail',
|
'conversation.chat.turnTail',
|
||||||
|
|||||||
@@ -1,12 +1,37 @@
|
|||||||
/**
|
/**
|
||||||
* Pure derivation of one turn's produced files from finalized snapshot
|
* Turn-scoped produced-file Definition and readers. Client-only and
|
||||||
* nodes. Client-only and model-free: the vocabulary is the mutation tools'
|
* model-free: the vocabulary is the mutation tools' own follow-along
|
||||||
* own follow-along `locations`, never the closing prose.
|
* `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 { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||||
import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
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
|
* 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
|
* 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
|
* Files produced by one Turn data value.
|
||||||
* render site elects, so the row lands under the message that reports the
|
|
||||||
* work rather than after some mid-turn narration.
|
|
||||||
*
|
*
|
||||||
* The source is the mutation tools' own follow-along `locations`, not the
|
* 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
|
* 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
|
* failed calls. Paths keep first-seen order and appear once, so a file written
|
||||||
* and then edited in the same turn is one entry.
|
* and then edited in the same turn is one entry.
|
||||||
*
|
*
|
||||||
* Accumulation resets on the turn boundary — a user message, or a node
|
* The Conversation Location index owns turn membership before this function
|
||||||
* reporting a different turn number — so a turn that mutates files and then
|
* runs, so paths cannot spill across turns and this derivation does not infer
|
||||||
* ends without content text cannot spill its paths into the next turn's row,
|
* boundaries from neighboring presentation Nodes.
|
||||||
* nor leave the dedup set suppressing a file the next turn legitimately
|
* @param data - engine-published Deliverables data for one Turn.
|
||||||
* rewrites. Tool results carry no turn of their own; the boundary is read off
|
* @param seq - closing Assistant seq; later Tool settlements are excluded.
|
||||||
* 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).
|
|
||||||
* @returns Produced paths in first-seen order; empty when the turn wrote nothing.
|
* @returns Produced paths in first-seen order; empty when the turn wrote nothing.
|
||||||
*/
|
*/
|
||||||
export function producedForClosing(nodes: readonly ConversationNode[], seq: number): readonly string[] {
|
export function producedForClosing(
|
||||||
let pending: string[] = []
|
data: Readonly<DeliverablesTurnData> | undefined,
|
||||||
let seen = new Set<string>()
|
seq = Number.POSITIVE_INFINITY,
|
||||||
let turn: number | undefined
|
): readonly string[] {
|
||||||
for (const node of nodes) {
|
if (data === undefined) return []
|
||||||
if (node.kind === 'tool-result') {
|
const paths: string[] = []
|
||||||
if (node.isError) continue
|
const seen = new Set<string>()
|
||||||
for (const path of producedPaths(node.callView)) {
|
for (const produced of data.produced) {
|
||||||
if (seen.has(path)) continue
|
if (produced.seq > seq || seen.has(produced.path)) continue
|
||||||
seen.add(path)
|
seen.add(produced.path)
|
||||||
pending.push(path)
|
paths.push(produced.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
|
|
||||||
}
|
}
|
||||||
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.
|
* @returns Produced paths as the component's match, or null to decline before mount.
|
||||||
*/
|
*/
|
||||||
export function selectProducedFiles(owner: TurnTailOwnerProps): readonly string[] | null {
|
export function selectProducedFiles(owner: TurnTailOwnerProps): readonly string[] | null {
|
||||||
const { nodes, seq } = owner
|
const paths = producedForClosing(owner.turn.data.get('deliverables'), owner.seq)
|
||||||
const paths = producedForClosing(nodes, seq)
|
|
||||||
return paths.length === 0 ? null : paths
|
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.
|
* Trailing path segment, the part that identifies the file at a glance.
|
||||||
* @param path - Slash- or backslash-separated path.
|
* @param path - Slash- or backslash-separated path.
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ export const inject = ['slots']
|
|||||||
* @param ctx - Client root context.
|
* @param ctx - Client root context.
|
||||||
*/
|
*/
|
||||||
export function apply(ctx: ClientContext): void {
|
export function apply(ctx: ClientContext): void {
|
||||||
ctx.slots.inject('conversation.chat.tool', () => ctx.slots.register({
|
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({
|
||||||
name: 'conversation.chat.tool',
|
name: 'conversation.chat.node',
|
||||||
|
key: 'tool-call',
|
||||||
locale: NS,
|
locale: NS,
|
||||||
children: {
|
children: {
|
||||||
'tool.call.toolview': { kind: 'keyed', scope: 'session' },
|
'tool.call.toolview': { kind: 'keyed', scope: 'session' },
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ export interface ToolCallOwnerProps {
|
|||||||
/** Full props of a registered atomic Tool view. */
|
/** Full props of a registered atomic Tool view. */
|
||||||
export type ToolCallViewProps = PropsRuntime<'tool.call.toolview'>
|
export type ToolCallViewProps = PropsRuntime<'tool.call.toolview'>
|
||||||
|
|
||||||
/** Full props of the Tool call-tree renderer registered into the chat flow. */
|
/** Full props of the Tool call-tree renderer registered as a `tool-call` Chat Node. */
|
||||||
export type ToolTreeProps = PropsRuntime<'conversation.chat.tool'>
|
export type ToolTreeProps = PropsRuntime<'conversation.chat.node', 'tool-call'>
|
||||||
& PropsRenderSlots<'tool.call.toolview'>
|
& PropsRenderSlots<'tool.call.toolview'>
|
||||||
& PropsLocale<'conversation'>
|
& PropsLocale<'conversation'>
|
||||||
|
|
||||||
|
|||||||
@@ -88,8 +88,9 @@ const ToolCallBranch = memo(function ToolCallBranch({
|
|||||||
* @returns the Tool call tree.
|
* @returns the Tool call tree.
|
||||||
*/
|
*/
|
||||||
export function ToolCallTree({
|
export function ToolCallTree({
|
||||||
renderSlot, block, selectedCallId, cwd, openFile, inspectCall, t,
|
renderSlot, node, selectedCallId, cwd, openFile, inspectCall, t,
|
||||||
}: ToolTreeProps) {
|
}: ToolTreeProps) {
|
||||||
|
const block = node.data.root
|
||||||
return (
|
return (
|
||||||
<ToolCallBranch
|
<ToolCallBranch
|
||||||
renderSlot={renderSlot}
|
renderSlot={renderSlot}
|
||||||
|
|||||||
Reference in New Issue
Block a user