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