refactor(ui-conversation): register business node definitions

This commit is contained in:
imccyu
2026-08-09 15:48:40 +08:00
parent 81d6886006
commit 6d09b3168d
41 changed files with 2744 additions and 745 deletions

View File

@@ -12,7 +12,9 @@ import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-conversation/c
import type {} from '@deepseek-ai/dsh-client-locale/client'
import { ProducedFiles } from './ProducedFiles.tsx'
import { en, NS, zh, type DeliverablesKey } from './locales.ts'
import { producedFileMentions, selectProducedFiles } from './turn-deliverables.ts'
import {
deliverablesDefinition, producedFileMentions, selectProducedFiles,
} from './turn-deliverables.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
@@ -25,13 +27,14 @@ export { ProducedFiles, type ProducedFilesProps } from './ProducedFiles.tsx'
export { producedForClosing } from './turn-deliverables.ts'
/** Required services for the tail-slot registration and its dictionaries. */
export const inject = ['slots', 'locale']
export const inject = ['slots', 'locale', 'conversationEvents']
/**
* Client plugin body: register the dictionaries and the turn-tail entry.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
ctx.conversationEvents.register(deliverablesDefinition)
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-deliverables: dictionaries')
ctx.slots.inject(
'conversation.chat.turnTail',

View File

@@ -1,12 +1,37 @@
/**
* Pure derivation of one turn's produced files from finalized snapshot
* nodes. Client-only and model-free: the vocabulary is the mutation tools'
* own follow-along `locations`, never the closing prose.
* Turn-scoped produced-file Definition and readers. Client-only and
* model-free: the vocabulary is the mutation tools' own follow-along
* `locations`, never the closing prose.
*/
import type { ConversationNode, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type {
ConversationNodeDefinition, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-client-runtime/client'
import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
interface ProducedPath {
readonly seq: number
readonly path: string
}
/** Immutable produced-file facts published against one Turn. */
export interface DeliverablesTurnData {
readonly produced: readonly ProducedPath[]
}
declare module '@deepseek-ai/dsh-client-runtime/client' {
interface ConversationTurnDataMap {
/** Successful mutation paths accumulated in this Turn. */
deliverables: DeliverablesTurnData
}
}
interface DeliverablesState extends DeliverablesTurnData {
readonly turn: number
readonly calls: ReadonlyMap<string, ToolResultNode['callView']>
}
/**
* Paths a call view reports having created or changed, by render intent rather
* than tool name: a diff card, or a generic card whose kind is `edit` (the
@@ -23,9 +48,7 @@ function producedPaths(view: ToolResultNode['callView']): readonly string[] {
}
/**
* Files produced by the turn the assistant at `seq` closes — the anchor the
* render site elects, so the row lands under the message that reports the
* work rather than after some mid-turn narration.
* Files produced by one Turn data value.
*
* The source is the mutation tools' own follow-along `locations`, not the
* closing prose: a produced file must be listed whether or not the model
@@ -37,46 +60,26 @@ function producedPaths(view: ToolResultNode['callView']): readonly string[] {
* failed calls. Paths keep first-seen order and appear once, so a file written
* and then edited in the same turn is one entry.
*
* Accumulation resets on the turn boundary — a user message, or a node
* reporting a different turn number — so a turn that mutates files and then
* ends without content text cannot spill its paths into the next turn's row,
* nor leave the dedup set suppressing a file the next turn legitimately
* rewrites. Tool results carry no turn of their own; the boundary is read off
* the nodes that do, and a user message resets the tracked turn to undefined
* because the next node to report one is stating the current turn, not
* entering a new one.
* @param nodes - snapshot nodes (surface order).
* @param seq - the closing assistant's seq (the render site's anchor).
* The Conversation Location index owns turn membership before this function
* runs, so paths cannot spill across turns and this derivation does not infer
* boundaries from neighboring presentation Nodes.
* @param data - engine-published Deliverables data for one Turn.
* @param seq - closing Assistant seq; later Tool settlements are excluded.
* @returns Produced paths in first-seen order; empty when the turn wrote nothing.
*/
export function producedForClosing(nodes: readonly ConversationNode[], seq: number): readonly string[] {
let pending: string[] = []
let seen = new Set<string>()
let turn: number | undefined
for (const node of nodes) {
if (node.kind === 'tool-result') {
if (node.isError) continue
for (const path of producedPaths(node.callView)) {
if (seen.has(path)) continue
seen.add(path)
pending.push(path)
}
continue
}
if (node.kind === 'user') {
turn = undefined
pending = []
seen = new Set()
} else if ('turn' in node) {
if (turn !== undefined && node.turn !== turn) {
pending = []
seen = new Set()
}
turn = node.turn
}
if (node.kind === 'assistant' && node.seq === seq) return pending
export function producedForClosing(
data: Readonly<DeliverablesTurnData> | undefined,
seq = Number.POSITIVE_INFINITY,
): readonly string[] {
if (data === undefined) return []
const paths: string[] = []
const seen = new Set<string>()
for (const produced of data.produced) {
if (produced.seq > seq || seen.has(produced.path)) continue
seen.add(produced.path)
paths.push(produced.path)
}
return []
return paths
}
/**
@@ -85,11 +88,55 @@ export function producedForClosing(nodes: readonly ConversationNode[], seq: numb
* @returns Produced paths as the component's match, or null to decline before mount.
*/
export function selectProducedFiles(owner: TurnTailOwnerProps): readonly string[] | null {
const { nodes, seq } = owner
const paths = producedForClosing(nodes, seq)
const paths = producedForClosing(owner.turn.data.get('deliverables'), owner.seq)
return paths.length === 0 ? null : paths
}
/** Turn-local successful mutation accumulator; it publishes no view Node. */
export const deliverablesDefinition: ConversationNodeDefinition<DeliverablesState> = {
kind: 'deliverables',
match: (event) => {
if (event.type === 'turn/start') return { id: String(event.data.turn), role: 'start' }
if (event.type === 'tool/call') return { id: String(event.data.turn), role: 'update' }
if (event.type === 'tool/result' && isAppendSurfaceEvent(event)) {
return { id: String(event.data.turn), role: 'update' }
}
return null
},
start: (_context, match) => {
if (match.event.type !== 'turn/start') throw new Error('deliverables start requires turn/start')
return { turn: match.event.data.turn, calls: new Map(), produced: [] }
},
update: (context, match) => {
if (match.event.type === 'tool/call') {
const calls = new Map(context.state.calls)
calls.set(
String(match.event.data.callId),
match.view?.for === 'call' ? match.view.view : null,
)
return { ...context.state, calls }
}
if (match.event.type !== 'tool/result') return context.state
const result = match.event.data.message.content[0]
if (result.isError === true) return context.state
const callId = String(match.event.data.message.source.callId)
const additions = producedPaths(context.state.calls.get(callId) ?? null)
.map(path => ({ seq: match.event.seq, path }))
return additions.length === 0
? context.state
: { ...context.state, produced: [...context.state.produced, ...additions] }
},
buildLocationData: (context, scope) => scope !== 'turn' || context.state === undefined
? null
: {
kind: 'turn',
turn: context.state.turn,
key: 'deliverables',
value: { produced: context.state.produced },
},
buildViewNode: () => null,
}
/**
* Trailing path segment, the part that identifies the file at a glance.
* @param path - Slash- or backslash-separated path.