feat(ui): add trajectory inspection ledger
This commit is contained in:
@@ -28,7 +28,7 @@ export type {
|
||||
EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore,
|
||||
} from './contract/store.ts'
|
||||
export type {
|
||||
AssistantBlock, AssistantMessageNode, CodeSubCall, ComposerPhase, ContextMessageNode, ConversationNode,
|
||||
AssistantBlock, AssistantMessageNode, AssistantTiming, CodeSubCall, ComposerPhase, ContextMessageNode, ConversationNode,
|
||||
ConversationSnapshot, QueuedMessage, RunningToolCall,
|
||||
SteeringMessageNode, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
|
||||
} from './sessions/conversation.ts'
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// substructures keep their references (the React.memo premise). callId/approvalId stay plain
|
||||
// string here (narrow to real brands when convenient).
|
||||
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm/types'
|
||||
import type {
|
||||
RpcError, SessionId, ToolCallView, ToolResultView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
@@ -50,6 +50,16 @@ export interface UserMessageNode {
|
||||
source: unknown
|
||||
}
|
||||
|
||||
/** Recorded boundaries used to derive assistant latency and throughput. */
|
||||
export interface AssistantTiming {
|
||||
/** Matching step/start timestamp, or null when it is outside the current event window. */
|
||||
stepStartTime: number | null
|
||||
/** First non-empty text/reasoning/tool delta timestamp, or null when no token delta was recorded. */
|
||||
firstTokenTime: number | null
|
||||
/** Final assistant/message timestamp. */
|
||||
completedTime: number
|
||||
}
|
||||
|
||||
/** A finalized (or interruption-frozen) assistant message. */
|
||||
export interface AssistantMessageNode {
|
||||
kind: 'assistant'
|
||||
@@ -60,6 +70,8 @@ export interface AssistantMessageNode {
|
||||
step: number
|
||||
blocks: readonly AssistantBlock[]
|
||||
usage?: unknown
|
||||
/** Timing derived from the recorded step/chunk/message event sequence. */
|
||||
timing?: AssistantTiming
|
||||
/** Frozen partial of an aborted turn (no finalize ever arrives): rendered with a 已停止 marker.
|
||||
* Synthetic seq (fractional, derived from the turn/end seq) keeps it ordered inside the flow. */
|
||||
interrupted?: true
|
||||
@@ -216,6 +228,8 @@ export interface ConversationSnapshot {
|
||||
* unrelated snapshot swaps (memo premise, same regime as `nodes`).
|
||||
*/
|
||||
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
|
||||
/** Model-visible tool schema captured for each recorded call id. */
|
||||
callSchemas?: ReadonlyMap<string, ToolSchema>
|
||||
pending: readonly PendingInteraction[]
|
||||
/** Read-only inbox mirror (session/queued frames + mux-open baseline; cleared by the leave-running flip). */
|
||||
queue: readonly QueuedMessage[]
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
// browser bundle cannot resolve; surface.ts has no Node dependencies.
|
||||
import { SurfaceManager, isSurfaceEligibleType } from '@deepseek-ai/dsh-session/surface'
|
||||
import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ConversationNode } from './conversation.ts'
|
||||
import type { AssistantTiming, ConversationNode } from './conversation.ts'
|
||||
import { toAssistantBlocks } from './conversation.ts'
|
||||
|
||||
/** In-window tool/call index entry (result-card backfill + runningCalls material). */
|
||||
@@ -37,6 +37,7 @@ function materializeNode(
|
||||
event: SessionEvent,
|
||||
callIndex: ReadonlyMap<string, CallIndexEntry>,
|
||||
resultView: ToolResultView | null,
|
||||
assistantTiming?: AssistantTiming,
|
||||
): ConversationNode {
|
||||
switch (event.type) {
|
||||
case 'user/message':
|
||||
@@ -58,6 +59,7 @@ function materializeNode(
|
||||
kind: 'assistant', seq: event.seq, time: event.time,
|
||||
turn: event.data.turn, step: event.data.step,
|
||||
blocks: toAssistantBlocks(event.data.content), usage: event.data.usage,
|
||||
...(assistantTiming !== undefined ? { timing: assistantTiming } : {}),
|
||||
}
|
||||
case 'steering/message':
|
||||
return {
|
||||
@@ -177,7 +179,12 @@ export class FoldAdapter {
|
||||
const event = this.padded[seq]
|
||||
/* v8 ignore next -- sparse guard: both seq sources (surface fold and degradedSeqs) only emit indexes present in padded. */
|
||||
if (event === undefined) continue
|
||||
const node = materializeNode(event, this.callIdx, this.resultViews.get(seq) ?? null)
|
||||
const node = materializeNode(
|
||||
event,
|
||||
this.callIdx,
|
||||
this.resultViews.get(seq) ?? null,
|
||||
event.type === 'assistant/message' ? this.assistantTiming(event) : undefined,
|
||||
)
|
||||
this.nodeCache.set(seq, node)
|
||||
out.push(node)
|
||||
}
|
||||
@@ -196,6 +203,33 @@ export class FoldAdapter {
|
||||
return seqs
|
||||
}
|
||||
|
||||
private assistantTiming(event: SessionEvent<'assistant/message'>): AssistantTiming {
|
||||
let stepStartTime: number | null = null
|
||||
let firstTokenTime: number | null = null
|
||||
for (let i = this.baseSeq; i < this.padded.length; i++) {
|
||||
const candidate = this.padded[i]
|
||||
if (candidate === undefined || candidate.seq > event.seq) break
|
||||
if (
|
||||
candidate.type === 'step/start'
|
||||
&& candidate.data.turn === event.data.turn
|
||||
&& candidate.data.step === event.data.step
|
||||
) {
|
||||
stepStartTime = candidate.time
|
||||
continue
|
||||
}
|
||||
if (
|
||||
firstTokenTime === null
|
||||
&& candidate.type === 'assistant/chunk'
|
||||
&& candidate.data.turn === event.data.turn
|
||||
&& candidate.data.step === event.data.step
|
||||
&& isTokenDelta(candidate.data.chunk)
|
||||
) {
|
||||
firstTokenTime = candidate.time
|
||||
}
|
||||
}
|
||||
return { stepStartTime, firstTokenTime, completedTime: event.time }
|
||||
}
|
||||
|
||||
private indexCall(event: SessionEvent, view?: ToolEventView): void {
|
||||
if (event.type === 'tool/result') {
|
||||
if (view?.for === 'result') this.resultViews.set(event.seq, view.view)
|
||||
@@ -211,3 +245,15 @@ export class FoldAdapter {
|
||||
// (window order puts the call before its result; cannot happen on the normal path).
|
||||
}
|
||||
}
|
||||
|
||||
function isTokenDelta(chunk: SessionEvent<'assistant/chunk'>['data']['chunk']): 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Sessions remain resident after creation so they continue consuming mux frames off-screen.
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult,
|
||||
@@ -104,6 +104,12 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
private codeDispatches = new Map<string, readonly CodeSubCall[]>()
|
||||
private dispatchesRev = 0
|
||||
private dispatchesCache: { rev: number; value: ReadonlyMap<string, readonly CodeSubCall[]> } | null = null
|
||||
/** Schemas in force for the next tool/call, updated by request/header. */
|
||||
private activeToolSchemas = new Map<string, ToolSchema>()
|
||||
/** Call-time schema snapshots keyed by native or code-dispatch call id. */
|
||||
private callSchemas = new Map<string, ToolSchema>()
|
||||
private callSchemasRev = 0
|
||||
private callSchemasCache: { rev: number; value: ReadonlyMap<string, ToolSchema> } | null = null
|
||||
private running = false
|
||||
/**
|
||||
* Sticky send marker, private input of the composerPhase derivation: set
|
||||
@@ -611,6 +617,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
argsRaw: JSON.stringify(data.arguments),
|
||||
turn: 0, step: 0, time: event.time, callView: null,
|
||||
}
|
||||
this.captureCallSchema(data.subCallId, data.name)
|
||||
const siblings = this.codeDispatches.get(data.parentCallId) ?? []
|
||||
this.codeDispatches.set(data.parentCallId, [...siblings, running])
|
||||
this.dispatchesRev++
|
||||
@@ -630,6 +637,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
content: ContentBlock[]
|
||||
}
|
||||
const siblings = this.codeDispatches.get(data.parentCallId) ?? []
|
||||
this.captureCallSchema(data.subCallId, data.name)
|
||||
const at = siblings.findIndex(sub => sub.callId === data.subCallId)
|
||||
const started = at === -1 ? undefined : siblings[at]
|
||||
const settled: CodeSubCall = {
|
||||
@@ -651,6 +659,12 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
return
|
||||
}
|
||||
switch (event.type) {
|
||||
case 'request/header': {
|
||||
this.activeToolSchemas = new Map(
|
||||
(event.data.header.tools ?? []).map(schema => [schema.name, schema]),
|
||||
)
|
||||
return
|
||||
}
|
||||
case 'assistant/chunk': {
|
||||
const { turn, step, chunk } = event.data
|
||||
if (this.partial === null || this.partial.turn !== turn || this.partial.step !== step) {
|
||||
@@ -666,6 +680,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
return
|
||||
}
|
||||
case 'tool/call': {
|
||||
this.captureCallSchema(String(event.data.callId), event.data.name)
|
||||
this.openCalls.set(String(event.data.callId), {
|
||||
callId: String(event.data.callId), name: event.data.name, argsRaw: event.data.arguments,
|
||||
turn: event.data.turn, step: event.data.step, time: event.time,
|
||||
@@ -720,6 +735,15 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Preserve the schema active when one call starts. */
|
||||
private captureCallSchema(callId: string, name: string): void {
|
||||
if (this.callSchemas.has(callId)) return
|
||||
const schema = this.activeToolSchemas.get(name)
|
||||
if (schema === undefined) return
|
||||
this.callSchemas.set(callId, schema)
|
||||
this.callSchemasRev++
|
||||
}
|
||||
|
||||
/** Re-derive state (partial/openCalls/frozenNodes) from raw window events after a rebuild — keeps
|
||||
* paging/stitching consistent, and makes the live freeze and the history replay converge on the
|
||||
* same interrupted nodes (chunks are logged, so the replayed sweep re-freezes identical text). */
|
||||
@@ -731,6 +755,9 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
this.frozenRev++
|
||||
this.codeDispatches = new Map()
|
||||
this.dispatchesRev++
|
||||
this.activeToolSchemas = new Map()
|
||||
this.callSchemas = new Map()
|
||||
this.callSchemasRev++
|
||||
for (let i = 0; i < this.events.length; i++) {
|
||||
const event = this.events[i]
|
||||
/* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */
|
||||
@@ -766,6 +793,9 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
if (this.dispatchesCache === null || this.dispatchesCache.rev !== this.dispatchesRev) {
|
||||
this.dispatchesCache = { rev: this.dispatchesRev, value: new Map(this.codeDispatches) }
|
||||
}
|
||||
if (this.callSchemasCache === null || this.callSchemasCache.rev !== this.callSchemasRev) {
|
||||
this.callSchemasCache = { rev: this.callSchemasRev, value: new Map(this.callSchemas) }
|
||||
}
|
||||
if (this.queueCache === null || this.queueCache.rev !== this.queueRev) {
|
||||
this.queueCache = { rev: this.queueRev, value: this.queued.map(entry => entry.row) }
|
||||
}
|
||||
@@ -778,6 +808,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
runningCalls: this.callsCache.value,
|
||||
pending: this.pendingCache.value,
|
||||
codeDispatches: this.dispatchesCache.value,
|
||||
callSchemas: this.callSchemasCache.value,
|
||||
queue: this.queueCache.value,
|
||||
running: this.running,
|
||||
composerPhase: derivePhase(
|
||||
|
||||
Reference in New Issue
Block a user