refactor(client): localize conversation projections

This commit is contained in:
imccyu
2026-08-23 23:05:31 +08:00
parent 85427aea9e
commit 9f2f498e7c
27 changed files with 476 additions and 358 deletions
@@ -4,13 +4,13 @@ import {
createAssistantMessage,
createToolResultMessage,
createUserMessage,
isTokenDelta,
} from '@deepseek-ai/dsh-llm/message'
import { CallId, type MessageId } from '@deepseek-ai/dsh-llm/brand'
import type {
AssistantMessage,
ContentBlock,
MessageSource,
StreamChunk,
TokenUsage,
ToolResultMessage,
UserMessage,
@@ -40,6 +40,20 @@ import type {
const FIXTURE_SESSION_SEARCH_RESULT_LIMIT = 20
/* jscpd:ignore-start -- The standalone fixture mirrors host timing without importing a target implementation. */
function isFixtureTokenDelta(chunk: StreamChunk): boolean {
switch (chunk.type) {
case 'text-delta':
case 'reasoning-delta':
return chunk.text !== ''
case 'tool-call-delta':
return chunk.argumentsDelta !== '' || chunk.name !== undefined
default:
return false
}
}
/* jscpd:ignore-end */
interface FixtureSessionSummary {
readonly sessionId: SessionId
updatedAt: number
@@ -1129,7 +1143,7 @@ function sessionStatsOf(log: readonly SessionEvent[]): {
break
case 'assistant/chunk':
if (openStep !== null && openStep.turn === event.data.turn && openStep.step === event.data.step
&& openStep.firstTokenTime === null && isTokenDelta(event.data.chunk)) {
&& openStep.firstTokenTime === null && isFixtureTokenDelta(event.data.chunk)) {
openStep.firstTokenTime = event.time
}
break
@@ -3,7 +3,6 @@
// active conversation scrollport (see ConversationRoot data-conversation-scroll).
import { Fragment, memo, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { contextOccupancy } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import type { UseProjection } from '@deepseek-ai/dsh-api-session-controller/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
@@ -16,8 +15,6 @@ import { formatTokensPerSecond } from './message-chrome.ts'
import { assistantStepReading } from '../contract/turn-metrics.ts'
import css from './StatsLine.module.css'
export { contextOccupancy }
interface WindowStats {
turns: number
steps: number
@@ -1,10 +1,8 @@
import type {
ConversationLocation, ConversationViewNode,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {
AssistantBlock, AssistantMessageNode, CommandNode, CompactionSummaryNode,
ModelRetryNode, RunningToolCall, ToolCallBlock,
} from './snapshot.ts'
ConversationLocation, ConversationViewNode, ModelRetryNode, RunningToolCall,
ToolCallBlock,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
/** Final Chat render unit produced by a Chat business Definition. */
export interface ChatConversationViewNode extends ConversationViewNode {
@@ -10,10 +10,6 @@ export type {
ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode, UnknownSurfaceNode,
UserMessageNode,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
export {
emptyAssistantBlock, toAssistantBlock, toAssistantBlocks,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
/** Stable live per-key reader for Chat nodes. */
export interface ChatNodeStore {
/** @param key - stable Conversation Context key. @returns current Node, when visible or hidden. */
@@ -2,14 +2,14 @@ import type { Context } from '@deepseek-ai/cordis'
import type {
ConversationLocation, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { isTokenDelta } from '@deepseek-ai/dsh-llm/message'
import type {} from '@deepseek-ai/dsh-llm-retry/types'
import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
import type { AssistantChatData } from '../contract/chat-nodes.ts'
import type { AssistantBlock, AssistantMessageNode } from '../contract/snapshot.ts'
import { toAssistantBlock, toAssistantBlocks } from '../contract/snapshot.ts'
import { emptyAssistantBlock } from '../contract/snapshot.ts'
import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts'
import {
emptyAssistantBlock, isTokenDelta, toAssistantBlock, toAssistantBlocks,
} from './event-projection.ts'
declare module '../contract/chat-nodes.ts' {
interface ChatNodeDataMap {
@@ -9,7 +9,7 @@ import type {
ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot, ConversationNode,
LegacyConversationSlice, PartialAssistant, RunningToolCall,
} from '../contract/snapshot.ts'
import { sessionRecallLabels } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { sessionRecallLabels } from './event-projection.ts'
const EMPTY_KEYS: readonly string[] = []
const EMPTY_TURNS: readonly number[] = []
@@ -0,0 +1,165 @@
/** Chat-owned conversion from durable Session events to Chat view data. */
import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm/types'
import type {
AssistantBlock, ContextProvenanceView, KnownContextForm,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
/* jscpd:ignore-start -- Chat and Trajectory own independent event-to-view projections. */
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: null
}
function readString(record: Record<string, unknown>, key: string): string | null {
const value = record[key]
return typeof value === 'string' && value.length > 0 ? value : null
}
function collect(source: Record<string, unknown>, member: string, field: string): string[] {
const list = source[member]
if (!Array.isArray(list)) return []
const seen: string[] = []
for (const entry of list) {
const record = asRecord(entry)
const value = record === null ? null : readString(record, field)
if (value !== null && !seen.includes(value)) seen.push(value)
}
return seen
}
function joined(names: string[]): string | null {
return names.length > 0 ? names.join(', ') : null
}
const KNOWN_FORMS: readonly KnownContextForm[] = [
'instructions', 'catalog', 'snapshot', 'notice', 'relay', 'recall',
]
/**
* Read the target-supported presentation form from a durable message source.
* @param source - Logged `user/message` source.
* @returns Supported form, or null for the opaque presentation.
*/
export function contextForm(source: unknown): KnownContextForm | null {
const record = asRecord(source)
const form = record === null ? null : readString(record, 'form')
return form !== null && (KNOWN_FORMS as readonly string[]).includes(form)
? form as KnownContextForm
: null
}
/**
* Project a durable message source to the Chat row's role and producer label.
* @param source - Logged `user/message` source.
* @returns Role and label rendered by Chat.
*/
export function contextProvenance(source: unknown): ContextProvenanceView {
const record = asRecord(source)
const kind = record === null ? null : readString(record, 'kind')
if (record === null || kind === null) return { role: 'inject', label: null }
switch (kind) {
case 'session-reference':
return { role: 'recall', label: joined(collect(record, 'references', 'label')) ?? kind }
case 'agent-instructions':
return { role: 'inject', label: joined(collect(record, 'changes', 'path')) ?? kind }
case 'plugin':
return { role: 'inject', label: readString(record, 'plugin') ?? kind }
case 'skill-invocation':
return { role: 'inject', label: readString(record, 'name') ?? kind }
default:
return { role: 'inject', label: kind }
}
}
/**
* Read distinct labels cited by a durable cross-session recall source.
* @param source - Logged `user/message` source.
* @returns Labels in first-seen order.
*/
export function sessionRecallLabels(source: unknown): string[] {
const record = asRecord(source)
if (record === null || readString(record, 'kind') !== 'session-reference') return []
return collect(record, 'references', 'label')
}
/**
* Classify finalized Assistant content for Chat rendering.
* @param content - Core content blocks.
* @returns Chat blocks in source order.
*/
export function toAssistantBlocks(content: readonly ContentBlock[]): AssistantBlock[] {
return content.map(toAssistantBlock)
}
/**
* Classify one finalized Assistant block for Chat rendering.
* @param block - Core content block.
* @returns Chat block.
*/
export function toAssistantBlock(block: ContentBlock): AssistantBlock {
switch (block.type) {
case 'text': return { kind: 'text', text: block.text }
case 'reasoning': return { kind: 'reasoning', text: block.text }
case 'image': return { kind: 'image', attachment: block.attachment }
case 'tool-call': return { kind: 'tool-call', callId: String(block.id), name: block.name, argsRaw: block.arguments }
default: return { kind: 'other', block }
}
}
/**
* Create the initial Chat block for one streamed Assistant block kind.
* @param blockType - Wire block kind.
* @returns Empty block ready to receive deltas.
*/
export function emptyAssistantBlock(blockType: string): AssistantBlock {
switch (blockType) {
case 'text': return { kind: 'text', text: '' }
case 'reasoning': return { kind: 'reasoning', text: '' }
case 'tool-call': return { kind: 'tool-call', callId: '', name: '', argsRaw: '' }
default: return { kind: 'other', block: null }
}
}
/** Display-safe failure fields retained by Chat projections. */
export interface DisplayFailure {
readonly code?: string
readonly message: string
}
/**
* Convert a durable failure to locale-independent fields safe for Chat.
* @param failure - Failure preserved by a Session event.
* @returns Sanitized message and optional stable provider code.
*/
export function displayFailure(failure: unknown): DisplayFailure {
if (failure === null || typeof failure !== 'object') return { message: String(failure) }
const record = failure as { code?: unknown; message?: unknown }
const code = typeof record.code === 'string' ? record.code : undefined
if (code === 'AUTH') return { code, message: '' }
return {
...(code === undefined ? {} : { code }),
message: typeof record.message === 'string' ? record.message : JSON.stringify(failure),
}
}
/**
* Whether a stream chunk carries visible model output for Chat timing.
* @param chunk - Stream chunk to inspect.
* @returns true for a non-empty text, reasoning, or Tool-call delta.
*/
export function isTokenDelta(chunk: StreamChunk): boolean {
switch (chunk.type) {
case 'text-delta':
case 'reasoning-delta':
return chunk.text !== ''
case 'tool-call-delta':
return chunk.argumentsDelta !== '' || chunk.name !== undefined
default:
return false
}
}
/* jscpd:ignore-end */
@@ -2,9 +2,9 @@ import type { Context } from '@deepseek-ai/cordis'
import type { ConversationNodeDefinition } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { isAppendSurfaceEvent, isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
import type { ContextMessageNode, SteeringMessageNode, UserMessageNode } from '../contract/snapshot.ts'
import { contextForm, contextProvenance } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { InboxState } from './inbox.ts'
import { chatNode } from './common.ts'
import { contextForm, contextProvenance } from './event-projection.ts'
interface ReferencedUserMessageNode extends UserMessageNode {
/** Labels cited by the immediately following session-reference context. */
@@ -1,6 +1,6 @@
import type { StreamChunk } from '@deepseek-ai/dsh-llm/types'
import type { AssistantBlock, PartialAssistant } from '../contract/snapshot.ts'
import { emptyAssistantBlock, toAssistantBlock } from '../contract/snapshot.ts'
import { emptyAssistantBlock, toAssistantBlock } from './event-projection.ts'
/**
* Whether a stream chunk changes the partial assistant projection shown by the UI.
@@ -3,8 +3,8 @@ import type {
ConversationMatch, ConversationNodeContext, ConversationNodeDefinition,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { TurnErrorNode } from '../contract/snapshot.ts'
import { displayFailure } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { chatNode } from './common.ts'
import { displayFailure } from './event-projection.ts'
declare module '../contract/chat-nodes.ts' {
interface ChatNodeDataMap {
@@ -7,9 +7,9 @@ import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
import type {
AssistantChatData, FinalAssistantChatData, TurnTailChatData,
} from '../contract/chat-nodes.ts'
import { toAssistantBlocks } from '../contract/snapshot.ts'
import { deriveTurnMetrics } from '../contract/turn-metrics.ts'
import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts'
import { toAssistantBlocks } from './event-projection.ts'
declare module '../contract/chat-nodes.ts' {
interface ChatNodeDataMap {
@@ -1,4 +1,3 @@
import { conversationContextKey } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ChatNode } from '../contract/chat-nodes.ts'
import type { ChatNodeStore, ChatSnapshot, ToolCallBlock } from '../contract/snapshot.ts'
@@ -6,19 +5,6 @@ function toolNode(node: ReturnType<ChatNodeStore['get']>): ChatNode<'tool-call'>
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: ChatSnapshot,
rootCallId: string,
): ToolCallBlock | undefined {
return toolNode(snapshot.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.
+1 -4
View File
@@ -41,10 +41,7 @@ export type {
} from '@deepseek-ai/dsh-client-ui-conversation/client'
export { isRunningTool, isSettledTool } from './contract/chat-nodes.ts'
export { EMPTY_CHAT_SNAPSHOT, toAssistantBlock, toAssistantBlocks } from './contract/snapshot.ts'
export {
contextForm, contextProvenance, displayFailure, emptyAssistantBlock, isTokenDelta,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
export { EMPTY_CHAT_SNAPSHOT } from './contract/snapshot.ts'
/** Public merge surface for Chat renderer payloads contributed by other plugins. */
export interface ChatNodeDataMap {}
@@ -9,7 +9,7 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { StatsLine, contextOccupancy, deriveStats, formatDuration, formatTokens, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
import { StatsLine, deriveStats, formatDuration, formatTokens, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
import { en, zh } from '../src/client/locale.ts'
import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts'
@@ -262,24 +262,6 @@ describe('StatsLine', () => {
.toBe('Cache hit 90%| Input 100 tok · Output 5 tok')
})
it('computes context occupancy only when both a numerator and capacity are known', () => {
// The projected figure wins: it is the provider sample carried forward over
// the surface's movement, so a compaction shows without waiting a request.
expect(contextOccupancy({ pressureTokens: 32_000, projectedTokens: 6_000, contextWindow: 128_000 }))
.toEqual({ percent: 5, usedTokens: 6_000, contextWindow: 128_000 })
// A log whose projection predates the field still reads its bare sample.
expect(contextOccupancy({ pressureTokens: 32_000, contextWindow: 128_000 }))
.toEqual({ percent: 25, usedTokens: 32_000, contextWindow: 128_000 })
// A numerator without capacity has no denominator; capacity without a
// provider sample has no numerator yet, rather than a synthetic 0%.
expect(contextOccupancy({ pressureTokens: 32_000 })).toBeNull()
expect(contextOccupancy({ contextWindow: 128_000 })).toBeNull()
expect(contextOccupancy(undefined)).toBeNull()
// Capacity and the sample are independent last-wins fields, so a model
// switch can pair a smaller new window with the previous route's prompt.
expect(contextOccupancy({ pressureTokens: 300_000, contextWindow: 128_000 })?.percent).toBe(100)
})
it('drops every token group when no projection is composed', () => {
const { source } = makeSource({ nodes: [assistant(1, 1)] })
const view = render(<StatsLine {...props(source, {})} />)
@@ -1,9 +1,12 @@
/** Assistant block classifier (moved here with sessions/conversation.ts). */
/** Chat-owned event-to-view projection. */
import { describe, expect, it } from 'vitest'
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
import type { ContentBlock } from '@deepseek-ai/dsh-api-remotes/client'
import { toAssistantBlock, toAssistantBlocks } from '../src/client/contract/snapshot.ts'
import {
displayFailure, emptyAssistantBlock, toAssistantBlock, toAssistantBlocks,
isTokenDelta,
} from '../src/client/conversation-nodes/event-projection.ts'
describe('toAssistantBlock', () => {
it('classifies the four block shapes', () => {
@@ -27,5 +30,30 @@ describe('toAssistantBlock', () => {
{ kind: 'image', attachment },
])
expect(toAssistantBlock(blocks[0] as ContentBlock)).toEqual({ kind: 'text', text: '正文' })
expect(toAssistantBlock({ type: 'future' } as unknown as ContentBlock))
.toEqual({ kind: 'other', block: { type: 'future' } })
})
it('creates empty streamed block projections', () => {
expect(emptyAssistantBlock('text')).toEqual({ kind: 'text', text: '' })
expect(emptyAssistantBlock('reasoning')).toEqual({ kind: 'reasoning', text: '' })
expect(emptyAssistantBlock('tool-call')).toEqual({ kind: 'tool-call', callId: '', name: '', argsRaw: '' })
expect(emptyAssistantBlock('future')).toEqual({ kind: 'other', block: null })
})
it('redacts auth failures and presents the remaining durable values', () => {
expect(displayFailure({ code: 'AUTH', message: 'secret' })).toEqual({ code: 'AUTH', message: '' })
expect(displayFailure({ code: 'TRANSPORT', message: 'offline' }))
.toEqual({ code: 'TRANSPORT', message: 'offline' })
expect(displayFailure({ code: 'UNKNOWN' })).toEqual({ code: 'UNKNOWN', message: '{"code":"UNKNOWN"}' })
expect(displayFailure(null)).toEqual({ message: 'null' })
})
it('recognizes only non-empty token deltas', () => {
expect(isTokenDelta({ type: 'text-delta', index: 0, text: 'x' } as never)).toBe(true)
expect(isTokenDelta({ type: 'reasoning-delta', index: 0, text: '' } as never)).toBe(false)
expect(isTokenDelta({ type: 'tool-call-delta', index: 0, id: 'c', argumentsDelta: '', name: 'tool' } as never)).toBe(true)
expect(isTokenDelta({ type: 'tool-call-delta', index: 0, id: 'c', argumentsDelta: '' } as never)).toBe(false)
expect(isTokenDelta({ type: 'finish', reason: 'stop' } as never)).toBe(false)
})
})
@@ -1,8 +1,4 @@
// Conversation context source projection: the role and the human-facing producer name
// of one logged non-user `user/message`, read from its durable `source` alone.
// The client keeps no table of known plugin ids — a renamed or newly mounted
// producer must never need a client release to stay identifiable, and a resumed
// or foreign log must project the same way as a live one.
/** Shared types for target-owned context-source projections. */
/**
* Which model-facing role a logged non-user message plays.
@@ -27,106 +23,5 @@ export interface ContextProvenanceView {
label: string | null
}
/** One durable source narrowed to the readable-record shape; null for anything else. */
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: null
}
/** A record field read as a non-empty string, or null. */
function readString(record: Record<string, unknown>, key: string): string | null {
const value = record[key]
return typeof value === 'string' && value.length > 0 ? value : null
}
/** Distinct non-empty `field` values of an array-valued source member, in first-seen order. */
function collect(source: Record<string, unknown>, member: string, field: string): string[] {
const list = source[member]
if (!Array.isArray(list)) return []
const seen: string[] = []
for (const entry of list) {
const record = asRecord(entry)
const value = record === null ? null : readString(record, field)
if (value !== null && !seen.includes(value)) seen.push(value)
}
return seen
}
/** A collected name list rendered as one label; null when the list is empty. */
function joined(names: string[]): string | null {
return names.length > 0 ? names.join(', ') : null
}
/**
* The referenced-session labels of one durable `session-reference` recall
* source, in first-seen order; empty for every other source shape, including
* a foreign or older log whose reference entries carry no readable label.
* @param source - the logged `user/message` source, exactly as recorded.
* @returns distinct non-empty reference labels.
*/
export function sessionRecallLabels(source: unknown): string[] {
const record = asRecord(source)
if (record === null || readString(record, 'kind') !== 'session-reference') return []
return collect(record, 'references', 'label')
}
/**
* Project one durable message source onto its transcript role and producer name.
*
* The source arrives over the wire as opaque JSON (`MessageSource` is
* merge-extensible, so no client-side union can be exhaustive), and a durable
* log may predate or postdate this UI; every unreadable shape therefore
* degrades to `inject` with whatever name the record still carries.
* @param source - the logged `user/message` source, exactly as recorded.
* @returns the role and producer name to present for this context.
*/
export function contextProvenance(source: unknown): ContextProvenanceView {
const record = asRecord(source)
const kind = record === null ? null : readString(record, 'kind')
if (record === null || kind === null) return { role: 'inject', label: null }
switch (kind) {
// Cross-session snapshots are the one durable source that carries another
// session's material; its references name the sessions they were read from.
case 'session-reference':
return { role: 'recall', label: joined(collect(record, 'references', 'label')) ?? kind }
// Workspace instructions name the files they were reconciled from, which
// identifies the producer far better than the plugin id would.
case 'agent-instructions':
return { role: 'inject', label: joined(collect(record, 'changes', 'path')) ?? kind }
case 'plugin':
return { role: 'inject', label: readString(record, 'plugin') ?? kind }
// A user-explicit skill invocation names the skill it injected.
case 'skill-invocation':
return { role: 'inject', label: readString(record, 'name') ?? kind }
// Documented default arm of the merge-extensible source map: an unknown
// producer still identifies itself by its own durable kind.
default:
return { role: 'inject', label: kind }
}
}
/**
* Context forms this UI version renders with a dedicated presentation. The
* durable vocabulary (`ContextForm` in `dsh-llm`) may already be wider — an
* unrecognized or absent value degrades to the opaque presentation rather than
* dropping the row, so a log written by a newer or foreign producer still
* renders.
*/
const KNOWN_FORMS = ['instructions', 'catalog', 'snapshot', 'notice', 'relay', 'recall'] as const
/** One durable context form this UI version knows how to present. */
export type KnownContextForm = typeof KNOWN_FORMS[number]
/**
* Read the producer-declared form off one durable message source.
* @param source - the logged `user/message` source, exactly as recorded.
* @returns the form when this UI version presents it, otherwise null (opaque).
*/
export function contextForm(source: unknown): KnownContextForm | null {
const record = asRecord(source)
const form = record === null ? null : readString(record, 'form')
return form !== null && (KNOWN_FORMS as readonly string[]).includes(form)
? form as KnownContextForm
: null
}
export type KnownContextForm = 'instructions' | 'catalog' | 'snapshot' | 'notice' | 'relay' | 'recall'
@@ -32,8 +32,7 @@ export interface AssistantProvenanceView {
model: string
}
/** Assistant content blocks sorted by what the UI cares about
* (text body / collapsible reasoning / tool-call card head / other fallback). */
/** Assistant content blocks sorted by what a UI target presents. */
export type AssistantBlock =
| { kind: 'text'; text: string }
| { kind: 'reasoning'; text: string }
@@ -41,44 +40,6 @@ export type AssistantBlock =
| { kind: 'tool-call'; callId: string; name: string; argsRaw: string }
| { kind: 'other'; block: unknown }
/**
* core ContentBlock[] -> AssistantBlock[] (classifier shared by finalized messages and partial block-end).
* @param content - core content blocks verbatim.
* @returns UI-classified blocks in source order.
*/
export function toAssistantBlocks(content: readonly ContentBlock[]): AssistantBlock[] {
return content.map(toAssistantBlock)
}
/**
* Classify one block (ToolCallBlock fields are id/arguments, mapped to callId/argsRaw).
* @param block - one core content block.
* @returns the UI classification.
*/
export function toAssistantBlock(block: ContentBlock): AssistantBlock {
switch (block.type) {
case 'text': return { kind: 'text', text: block.text }
case 'reasoning': return { kind: 'reasoning', text: block.text }
case 'image': return { kind: 'image', attachment: block.attachment }
case 'tool-call': return { kind: 'tool-call', callId: String(block.id), name: block.name, argsRaw: block.arguments }
default: return { kind: 'other', block }
}
}
/**
* Create the empty projection for one streamed Assistant block kind.
* @param blockType - wire block kind.
* @returns empty projected block ready to receive deltas.
*/
export function emptyAssistantBlock(blockType: string): AssistantBlock {
switch (blockType) {
case 'text': return { kind: 'text', text: '' }
case 'reasoning': return { kind: 'reasoning', text: '' }
case 'tool-call': return { kind: 'tool-call', callId: '', name: '', argsRaw: '' }
default: return { kind: 'other', block: null }
}
}
/** A finalized user message. */
export interface UserMessageNode {
kind: 'user'
@@ -145,9 +106,9 @@ export interface ContextMessageNode {
time: number
content: readonly ContentBlock[]
source: unknown
/** Role and producer name projected from `source` ({@link contextProvenance}). */
/** Role and producer name projected from `source` by the target. */
provenance: ContextProvenanceView
/** Producer-declared information form ({@link contextForm}); null presents as opaque. */
/** Producer-declared information form supported by the target; null presents as opaque. */
form: KnownContextForm | null
}
@@ -1,70 +0,0 @@
// Shared assistant step-timing fold: target Definitions and Trajectory
// history fold derive AssistantTiming from the same step/start -> first token
// delta -> assistant/message sequence.
import { isTokenDelta } from '@deepseek-ai/dsh-llm/message'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { AssistantTiming } from '../contract/records.ts'
// The first-token predicate lives beside the StreamChunk type in dsh-llm;
// re-exported here for consumers sharing the Conversation timing fold.
export { isTokenDelta } from '@deepseek-ai/dsh-llm/message'
/** Pre-finalize timing boundaries for one assistant step (start + first token). */
export interface AssistantStepMetadata {
stepStartTime: number | null
firstTokenTime: number | null
}
/**
* Composite map key for one assistant step.
* @param turn - turn number from the event payload.
* @param step - step number from the event payload.
* @returns collision-free `turn`/`step` key (NUL separator).
*/
export function assistantStepKey(turn: number, step: number): string {
return `${turn}\u0000${step}`
}
/**
* Fold one event into the per-step timing index: step/start opens the entry,
* the first non-empty token delta stamps first-token time once. Other event
* types are no-ops.
* @param steps - the mutable per-step index, keyed by {@link assistantStepKey}.
* @param event - the raw window event.
*/
export function indexAssistantStepTiming(steps: Map<string, AssistantStepMetadata>, event: SessionEvent): void {
if (event.type === 'step/start') {
steps.set(
assistantStepKey(event.data.turn, event.data.step),
{ stepStartTime: event.time, firstTokenTime: null },
)
} else if (event.type === 'assistant/chunk' && isTokenDelta(event.data.chunk)) {
const key = assistantStepKey(event.data.turn, event.data.step)
const current = steps.get(key) ?? { stepStartTime: null, firstTokenTime: null }
if (current.firstTokenTime === null) {
steps.set(key, { ...current, firstTokenTime: event.time })
}
}
}
/**
* Settle one finalized assistant message's timing from its step entry; a step
* whose start or first token fell outside the window yields null boundaries.
* @param steps - the per-step index built by {@link indexAssistantStepTiming}.
* @param turn - the assistant/message turn number.
* @param step - the assistant/message step number.
* @param completedTime - the assistant/message event timestamp (epoch ms).
* @returns the node-ready timing record.
*/
export function settledAssistantTiming(
steps: ReadonlyMap<string, AssistantStepMetadata>,
turn: number,
step: number,
completedTime: number,
): AssistantTiming {
return {
...(steps.get(assistantStepKey(turn, step)) ?? { stepStartTime: null, firstTokenTime: null }),
completedTime,
}
}
@@ -1,25 +0,0 @@
/** Display-safe failure fields retained by locale-independent projections. */
export interface DisplayFailure {
/** Stable provider failure code used for localized known-error copy. */
code?: string
/** Sanitized provider message; empty when the code owns the display copy. */
message: string
}
/**
* Convert a durable failure into locale-independent fields safe for GUI projections.
* @param failure - Failure value preserved by the session event.
* @returns Sanitized message and optional stable provider code.
*/
export function displayFailure(failure: unknown): DisplayFailure {
if (failure === null || typeof failure !== 'object') return { message: String(failure) }
const record = failure as { code?: unknown; message?: unknown }
const code = typeof record.code === 'string' ? record.code : undefined
// Provider AUTH messages may echo a masked or partially preserved credential.
// Keep the raw diagnostic in the session log, but never project it into UI state.
if (code === 'AUTH') return { code, message: '' }
return {
...(code === undefined ? {} : { code }),
message: typeof record.message === 'string' ? record.message : JSON.stringify(failure),
}
}
@@ -4,14 +4,6 @@ export { UiConversation } from './conversation/assembly.ts'
export type { ConversationBinding } from './conversation/assembly.ts'
export { ConversationController, UnsupportedImageMediaTypeError } from './service.ts'
export type { IConversation } from './service.ts'
export { bytesToBase64 } from './browser-bytes.ts'
export { settlePendingComposer } from './pending-composer.ts'
export { contextOccupancy } from './context-occupancy.ts'
export type { ContextOccupancy } from './context-occupancy.ts'
export { ReferenceIcon } from './skeleton/ReferenceIcon.tsx'
export type { ReferenceIconKind, ReferenceIconProps } from './skeleton/ReferenceIcon.tsx'
export { conversationContextKey } from './contract/conversation.ts'
export type {
ConversationContextReader, ConversationEventInput, ConversationLocation,
ConversationLocationData, ConversationLocationDataScope, ConversationLocationDataStore,
@@ -32,24 +24,12 @@ export type {
ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode, UnknownSurfaceNode,
UserMessageNode,
} from './contract/records.ts'
export {
emptyAssistantBlock, toAssistantBlock, toAssistantBlocks,
} from './contract/records.ts'
export type {
ContextProvenanceView, ContextRole, KnownContextForm,
} from './contract/context-provenance.ts'
export {
contextForm, contextProvenance, sessionRecallLabels,
} from './contract/context-provenance.ts'
export type {
ConversationPromptSnapshot, RequestInspectionSnapshot, RequestPromptChange, RequestView,
} from './contract/request-inspection.ts'
export type { AssistantStepMetadata } from './conversation/assistant-timing.ts'
export {
assistantStepKey, indexAssistantStepTiming, isTokenDelta, settledAssistantTiming,
} from './conversation/assistant-timing.ts'
export { displayFailure } from './conversation/failure-display.ts'
export type { DisplayFailure } from './conversation/failure-display.ts'
export type { ConversationStoreState, ConversationViewRequest, ViewTab } from './contract/views.ts'
export { ConversationNodeAssembler } from './conversation/assembler.ts'
@@ -5,6 +5,7 @@ import { cleanup, fireEvent, render } from '@testing-library/react'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { en as commonEn, zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/index.ts'
import { ContextMeter, type ContextMeterProps } from '../src/client/skeleton/ContextMeter.tsx'
import { contextOccupancy } from '../src/client/context-occupancy.ts'
import css from '../src/client/skeleton/ContextMeter.module.css'
import { en, zh } from '../src/client/locales.ts'
@@ -27,6 +28,17 @@ function meter(values: Record<string, unknown>, translate: ContextMeterProps['t'
}
describe('ContextMeter', () => {
it('computes occupancy only when both a numerator and capacity are known', () => {
expect(contextOccupancy({ pressureTokens: 32_000, projectedTokens: 6_000, contextWindow: 128_000 }))
.toEqual({ percent: 5, usedTokens: 6_000, contextWindow: 128_000 })
expect(contextOccupancy({ pressureTokens: 32_000, contextWindow: 128_000 }))
.toEqual({ percent: 25, usedTokens: 32_000, contextWindow: 128_000 })
expect(contextOccupancy({ pressureTokens: 32_000 })).toBeNull()
expect(contextOccupancy({ contextWindow: 128_000 })).toBeNull()
expect(contextOccupancy(undefined)).toBeNull()
expect(contextOccupancy({ pressureTokens: 300_000, contextWindow: 128_000 })?.percent).toBe(100)
})
it('renders nothing until both pressure and capacity are known', () => {
expect(meter({}).container.textContent).toBe('')
expect(meter({ contextPressure: { pressureTokens: 32_000 } }).container.textContent).toBe('')
@@ -1,23 +0,0 @@
import { describe, expect, it } from 'vitest'
import { displayFailure } from '../src/client/conversation/failure-display.ts'
describe('displayFailure', () => {
it('keeps ordinary diagnostics and stable provider codes', () => {
expect(displayFailure(null)).toEqual({ message: 'null' })
expect(displayFailure('disconnected')).toEqual({ message: 'disconnected' })
expect(displayFailure({ code: 'RATE_LIMIT', message: 'try later' })).toEqual({
code: 'RATE_LIMIT',
message: 'try later',
})
expect(displayFailure({ detail: 'unknown' })).toEqual({
message: '{"detail":"unknown"}',
})
})
it('removes the provider message when AUTH owns localized display copy', () => {
expect(displayFailure({ code: 'AUTH', message: 'credential sk-secret failed' })).toEqual({
code: 'AUTH',
message: '',
})
})
})
@@ -1,12 +1,13 @@
import type { Context } from '@deepseek-ai/cordis'
import {
displayFailure, emptyAssistantBlock, isTokenDelta, toAssistantBlock,
toAssistantBlocks,
type AssistantBlock, type AssistantMessageNode, type ConversationLocation,
type ConversationMatch, type ConversationNodeContext, type ConversationNodeDefinition,
type PartialAssistant, type RequestView,
import type {
AssistantBlock, AssistantMessageNode, ConversationLocation,
ConversationMatch, ConversationNodeContext, ConversationNodeDefinition,
PartialAssistant, RequestView,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { trajectoryNode } from './trajectory-definition-common.ts'
import {
displayFailure, emptyAssistantBlock, isTokenDelta, toAssistantBlock, toAssistantBlocks,
} from './trajectory-event-projection.ts'
/* jscpd:ignore-start -- Target-owned Definitions intentionally keep their event
* state machines independent; see ../../../../../.agents/notes/implemented/
@@ -0,0 +1,154 @@
/** Trajectory-owned conversion from durable Session events to ledger view data. */
import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm/types'
import type {
AssistantBlock, ContextProvenanceView, KnownContextForm,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
/* jscpd:ignore-start -- Chat and Trajectory own independent event-to-view projections. */
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: null
}
function readString(record: Record<string, unknown>, key: string): string | null {
const value = record[key]
return typeof value === 'string' && value.length > 0 ? value : null
}
function collect(source: Record<string, unknown>, member: string, field: string): string[] {
const list = source[member]
if (!Array.isArray(list)) return []
const seen: string[] = []
for (const entry of list) {
const record = asRecord(entry)
const value = record === null ? null : readString(record, field)
if (value !== null && !seen.includes(value)) seen.push(value)
}
return seen
}
function joined(names: string[]): string | null {
return names.length > 0 ? names.join(', ') : null
}
const KNOWN_FORMS: readonly KnownContextForm[] = [
'instructions', 'catalog', 'snapshot', 'notice', 'relay', 'recall',
]
/**
* Read the target-supported presentation form from a durable message source.
* @param source - Logged `user/message` source.
* @returns Supported form, or null for the opaque presentation.
*/
export function contextForm(source: unknown): KnownContextForm | null {
const record = asRecord(source)
const form = record === null ? null : readString(record, 'form')
return form !== null && (KNOWN_FORMS as readonly string[]).includes(form)
? form as KnownContextForm
: null
}
/**
* Project a durable message source to the Trajectory row's role and producer label.
* @param source - Logged `user/message` source.
* @returns Role and label rendered by Trajectory.
*/
export function contextProvenance(source: unknown): ContextProvenanceView {
const record = asRecord(source)
const kind = record === null ? null : readString(record, 'kind')
if (record === null || kind === null) return { role: 'inject', label: null }
switch (kind) {
case 'session-reference':
return { role: 'recall', label: joined(collect(record, 'references', 'label')) ?? kind }
case 'agent-instructions':
return { role: 'inject', label: joined(collect(record, 'changes', 'path')) ?? kind }
case 'plugin':
return { role: 'inject', label: readString(record, 'plugin') ?? kind }
case 'skill-invocation':
return { role: 'inject', label: readString(record, 'name') ?? kind }
default:
return { role: 'inject', label: kind }
}
}
/**
* Classify finalized Assistant content for Trajectory rendering.
* @param content - Core content blocks.
* @returns Trajectory blocks in source order.
*/
export function toAssistantBlocks(content: readonly ContentBlock[]): AssistantBlock[] {
return content.map(toAssistantBlock)
}
/**
* Classify one finalized Assistant block for Trajectory rendering.
* @param block - Core content block.
* @returns Trajectory block.
*/
export function toAssistantBlock(block: ContentBlock): AssistantBlock {
switch (block.type) {
case 'text': return { kind: 'text', text: block.text }
case 'reasoning': return { kind: 'reasoning', text: block.text }
case 'image': return { kind: 'image', attachment: block.attachment }
case 'tool-call': return { kind: 'tool-call', callId: String(block.id), name: block.name, argsRaw: block.arguments }
default: return { kind: 'other', block }
}
}
/**
* Create the initial Trajectory block for one streamed Assistant block kind.
* @param blockType - Wire block kind.
* @returns Empty block ready to receive deltas.
*/
export function emptyAssistantBlock(blockType: string): AssistantBlock {
switch (blockType) {
case 'text': return { kind: 'text', text: '' }
case 'reasoning': return { kind: 'reasoning', text: '' }
case 'tool-call': return { kind: 'tool-call', callId: '', name: '', argsRaw: '' }
default: return { kind: 'other', block: null }
}
}
/** Display-safe failure fields retained by Trajectory projections. */
export interface DisplayFailure {
readonly code?: string
readonly message: string
}
/**
* Convert a durable failure to locale-independent fields safe for Trajectory.
* @param failure - Failure preserved by a Session event.
* @returns Sanitized message and optional stable provider code.
*/
export function displayFailure(failure: unknown): DisplayFailure {
if (failure === null || typeof failure !== 'object') return { message: String(failure) }
const record = failure as { code?: unknown; message?: unknown }
const code = typeof record.code === 'string' ? record.code : undefined
if (code === 'AUTH') return { code, message: '' }
return {
...(code === undefined ? {} : { code }),
message: typeof record.message === 'string' ? record.message : JSON.stringify(failure),
}
}
/**
* Whether a stream chunk carries visible model output for Trajectory timing.
* @param chunk - Stream chunk to inspect.
* @returns true for a non-empty text, reasoning, or Tool-call delta.
*/
export function isTokenDelta(chunk: StreamChunk): boolean {
switch (chunk.type) {
case 'text-delta':
case 'reasoning-delta':
return chunk.text !== ''
case 'tool-call-delta':
return chunk.argumentsDelta !== '' || chunk.name !== undefined
default:
return false
}
}
/* jscpd:ignore-end */
@@ -1,11 +1,11 @@
import type { Context } from '@deepseek-ai/cordis'
import {
contextForm, contextProvenance,
type ContextMessageNode, type ConversationNodeDefinition, type ConversationPreviousContext,
type SteeringMessageNode, type UserMessageNode,
import type {
ContextMessageNode, ConversationNodeDefinition, ConversationPreviousContext,
SteeringMessageNode, UserMessageNode,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {} from '@deepseek-ai/dsh-agent/types'
import { trajectoryNode } from './trajectory-definition-common.ts'
import { contextForm, contextProvenance } from './trajectory-event-projection.ts'
/* jscpd:ignore-start -- Target-owned Definitions intentionally keep their event
* state machines independent; see ../../../../../.agents/notes/implemented/
@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest'
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import {
contextForm, contextProvenance, displayFailure, emptyAssistantBlock, isTokenDelta,
toAssistantBlock, toAssistantBlocks,
} from '../src/client/trajectory-event-projection.ts'
describe('Trajectory event projection', () => {
it('projects known, unknown, and unreadable context sources', () => {
expect(contextProvenance({ kind: 'session-reference', references: [{ label: 'A' }, { label: 'A' }] }))
.toEqual({ role: 'recall', label: 'A' })
expect(contextProvenance({ kind: 'session-reference', references: [] }))
.toEqual({ role: 'recall', label: 'session-reference' })
expect(contextProvenance({ kind: 'agent-instructions', changes: [{ path: 'AGENTS.md' }, null] }))
.toEqual({ role: 'inject', label: 'AGENTS.md' })
expect(contextProvenance({ kind: 'agent-instructions', changes: 'bad' }).label)
.toBe('agent-instructions')
expect(contextProvenance({ kind: 'plugin', plugin: 'p' }).label).toBe('p')
expect(contextProvenance({ kind: 'plugin', plugin: 1 }).label).toBe('plugin')
expect(contextProvenance({ kind: 'skill-invocation', name: 's' }).label).toBe('s')
expect(contextProvenance({ kind: 'future' }).label).toBe('future')
expect(contextProvenance(null)).toEqual({ role: 'inject', label: null })
expect(contextProvenance([])).toEqual({ role: 'inject', label: null })
expect(contextProvenance({ kind: '' })).toEqual({ role: 'inject', label: null })
})
it('accepts only forms supported by the target', () => {
for (const form of ['instructions', 'catalog', 'snapshot', 'notice', 'relay', 'recall']) {
expect(contextForm({ form })).toBe(form)
}
expect(contextForm({ form: 'future' })).toBeNull()
expect(contextForm({ form: 1 })).toBeNull()
expect(contextForm(null)).toBeNull()
})
it('projects finalized and empty Assistant blocks', () => {
const content = [
{ type: 'text', text: 'a' },
{ type: 'reasoning', text: 'b' },
{ type: 'image', attachment: { attachmentId: 'x' } },
{ type: 'tool-call', id: 'c', name: 'n', arguments: '{}' },
{ type: 'future' },
] as unknown as ContentBlock[]
expect(toAssistantBlocks(content).map(block => block.kind))
.toEqual(['text', 'reasoning', 'image', 'tool-call', 'other'])
expect(toAssistantBlock(content[0]!)).toEqual({ kind: 'text', text: 'a' })
expect(['text', 'reasoning', 'tool-call', 'future'].map(emptyAssistantBlock))
.toEqual([
{ kind: 'text', text: '' },
{ kind: 'reasoning', text: '' },
{ kind: 'tool-call', callId: '', name: '', argsRaw: '' },
{ kind: 'other', block: null },
])
})
it('redacts auth failures and presents other durable values', () => {
expect(displayFailure({ code: 'AUTH', message: 'secret' })).toEqual({ code: 'AUTH', message: '' })
expect(displayFailure({ message: 'offline' })).toEqual({ message: 'offline' })
expect(displayFailure({ code: 'UNKNOWN' })).toEqual({ code: 'UNKNOWN', message: '{"code":"UNKNOWN"}' })
expect(displayFailure(undefined)).toEqual({ message: 'undefined' })
})
it('recognizes only non-empty token deltas', () => {
expect(isTokenDelta({ type: 'text-delta', index: 0, text: 'x' } as never)).toBe(true)
expect(isTokenDelta({ type: 'reasoning-delta', index: 0, text: '' } as never)).toBe(false)
expect(isTokenDelta({ type: 'tool-call-delta', index: 0, id: 'c', argumentsDelta: '}' } as never)).toBe(true)
expect(isTokenDelta({ type: 'tool-call-delta', index: 0, id: 'c', argumentsDelta: '' } as never)).toBe(false)
expect(isTokenDelta({ type: 'finish', reason: 'stop' } as never)).toBe(false)
})
})