feat(ui): add trajectory context generations
This commit is contained in:
@@ -28,8 +28,8 @@ export type {
|
||||
EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore,
|
||||
} from './contract/store.ts'
|
||||
export type {
|
||||
AssistantBlock, AssistantMessageNode, AssistantTiming, CodeSubCall, ComposerPhase, ContextMessageNode, ConversationNode,
|
||||
ConversationSnapshot, QueuedMessage, RunningToolCall,
|
||||
AssistantBlock, AssistantMessageNode, AssistantTiming, CodeSubCall, ComposerPhase, ContextMessageNode,
|
||||
ConversationContext, ConversationContextOriginKind, ConversationNode, ConversationSnapshot, QueuedMessage, RunningToolCall,
|
||||
SteeringMessageNode, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
|
||||
} from './sessions/conversation.ts'
|
||||
export { PendingWait } from './sessions/pending.ts'
|
||||
|
||||
@@ -206,6 +206,25 @@ export type OpenState = 'cold' | 'loading' | 'open' | 'error'
|
||||
*/
|
||||
export type ComposerPhase = 'blank' | 'engaging' | 'active'
|
||||
|
||||
/** Operation that started a new append-only model context. */
|
||||
export type ConversationContextOriginKind = 'compaction' | 'rewind' | 'rewrite'
|
||||
|
||||
/** One immutable model-context generation reconstructed from surface replacements. */
|
||||
export interface ConversationContext {
|
||||
/** Zero-based generation within the session; stable across later appends. */
|
||||
id: number
|
||||
/** Previous generation in this session; absent for the initial context. */
|
||||
parentId?: number
|
||||
/** Why this generation exists; absent for the initial context. */
|
||||
origin?: ConversationContextOriginKind
|
||||
/** Event seq of the replacement that created this generation. */
|
||||
originSeq?: number
|
||||
/** Unix epoch ms of the replacement that created this generation. */
|
||||
createdAt?: number
|
||||
/** Final frozen nodes for historical generations, or current folded nodes for the tail. */
|
||||
nodes: readonly ConversationNode[]
|
||||
}
|
||||
|
||||
/** Send/stop failure surfaced in the input error strip; op picks the user-facing copy (发送失败 vs 停止失败). */
|
||||
export interface PromptError {
|
||||
op: 'send' | 'stop'
|
||||
@@ -217,6 +236,8 @@ export interface ConversationSnapshot {
|
||||
sessionId: SessionId
|
||||
/** Surface fold product (finalized conversation nodes in surface order). */
|
||||
nodes: readonly ConversationNode[]
|
||||
/** Append-only context generations split at every model-surface replacement. */
|
||||
contexts?: readonly ConversationContext[]
|
||||
/** Fold degradation flag (cross-window replace defense): when true, nodes come from the lenient linear scan. */
|
||||
foldDegraded: boolean
|
||||
partial: PartialAssistant | null
|
||||
|
||||
@@ -7,9 +7,13 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
// Subpath export (package.json exports "./surface", alias added for this): all value imports
|
||||
// go through it — the package root points at lib/index.js (needs a build) which the vite
|
||||
// browser bundle cannot resolve; surface.ts has no Node dependencies.
|
||||
import { SurfaceManager, isSurfaceEligibleType } from '@deepseek-ai/dsh-session/surface'
|
||||
import {
|
||||
SurfaceManager, isSurfaceEligibleType, isSurfaceEvent,
|
||||
} from '@deepseek-ai/dsh-session/surface'
|
||||
import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { AssistantTiming, ConversationNode } from './conversation.ts'
|
||||
import type {
|
||||
AssistantTiming, ConversationContext, ConversationContextOriginKind, ConversationNode,
|
||||
} from './conversation.ts'
|
||||
import { toAssistantBlocks } from './conversation.ts'
|
||||
|
||||
/** In-window tool/call index entry (result-card backfill + runningCalls material). */
|
||||
@@ -107,6 +111,9 @@ export class FoldAdapter {
|
||||
* reference-stability contract (§A.9.4) starts here. */
|
||||
private rev = 0
|
||||
private nodesResult: { rev: number; value: { nodes: ConversationNode[]; degraded: boolean } } | null = null
|
||||
/** Revision of the model-visible surface only; log-only chunks do not rebuild context generations. */
|
||||
private surfaceRev = 0
|
||||
private contextsResult: { rev: number; value: readonly ConversationContext[] } | null = null
|
||||
|
||||
/** In-window tool/call index (Session uses it for runningCalls and result-card backfill). */
|
||||
get callIndex(): ReadonlyMap<string, CallIndexEntry> {
|
||||
@@ -122,6 +129,7 @@ export class FoldAdapter {
|
||||
*/
|
||||
reset(events: readonly SessionEvent[], baseSeq: number, views?: readonly (ToolEventView | undefined)[]): void {
|
||||
this.rev++
|
||||
this.surfaceRev++
|
||||
this.baseSeq = baseSeq
|
||||
this.padded = []
|
||||
for (let i = 0; i < baseSeq; i++) this.padded.push(paddingEvent(i))
|
||||
@@ -146,6 +154,7 @@ export class FoldAdapter {
|
||||
*/
|
||||
append(event: SessionEvent, view?: ToolEventView): void {
|
||||
this.rev++
|
||||
if (isSurfaceEvent(event)) this.surfaceRev++
|
||||
this.padded.push(event)
|
||||
this.indexCall(event, view)
|
||||
}
|
||||
@@ -193,6 +202,41 @@ export class FoldAdapter {
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Append-only context generations reconstructed from canonical surface replacements.
|
||||
* @returns Frozen historical contexts followed by the current context.
|
||||
*/
|
||||
contexts(): readonly ConversationContext[] {
|
||||
if (this.contextsResult !== null && this.contextsResult.rev === this.surfaceRev) {
|
||||
return this.contextsResult.value
|
||||
}
|
||||
const current = this.nodes()
|
||||
if (current.degraded) {
|
||||
const value: readonly ConversationContext[] = [{ id: 0, nodes: current.nodes }]
|
||||
this.contextsResult = { rev: this.surfaceRev, value }
|
||||
return value
|
||||
}
|
||||
const value = this.surface.contexts.map((context): ConversationContext => {
|
||||
const nodes: ConversationNode[] = []
|
||||
for (const seq of context.nodes) {
|
||||
const node = this.materialize(seq)
|
||||
if (node !== undefined) nodes.push(node)
|
||||
}
|
||||
if (context.origin === undefined) return { id: context.generation, nodes }
|
||||
const originEvent = this.padded[context.origin.seq]
|
||||
return {
|
||||
id: context.generation,
|
||||
parentId: context.generation - 1,
|
||||
origin: contextOriginKind(originEvent),
|
||||
originSeq: context.origin.seq,
|
||||
...(originEvent === undefined ? {} : { createdAt: originEvent.time }),
|
||||
nodes,
|
||||
}
|
||||
})
|
||||
this.contextsResult = { rev: this.surfaceRev, value }
|
||||
return value
|
||||
}
|
||||
|
||||
/** Degradation branch: lenient linear scan ignoring surfaceOp/replace (all surface-eligible events in append order). */
|
||||
private degradedSeqs(): number[] {
|
||||
const seqs: number[] = []
|
||||
@@ -203,6 +247,21 @@ export class FoldAdapter {
|
||||
return seqs
|
||||
}
|
||||
|
||||
private materialize(seq: number): ConversationNode | undefined {
|
||||
const cached = this.nodeCache.get(seq)
|
||||
if (cached !== undefined) return cached
|
||||
const event = this.padded[seq]
|
||||
if (event === undefined) return
|
||||
const node = materializeNode(
|
||||
event,
|
||||
this.callIdx,
|
||||
this.resultViews.get(seq) ?? null,
|
||||
event.type === 'assistant/message' ? this.assistantTiming(event) : undefined,
|
||||
)
|
||||
this.nodeCache.set(seq, node)
|
||||
return node
|
||||
}
|
||||
|
||||
private assistantTiming(event: SessionEvent<'assistant/message'>): AssistantTiming {
|
||||
let stepStartTime: number | null = null
|
||||
let firstTokenTime: number | null = null
|
||||
@@ -246,6 +305,22 @@ export class FoldAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
function contextOriginKind(event: SessionEvent | undefined): ConversationContextOriginKind {
|
||||
if (event?.type !== 'user/message') return 'rewrite'
|
||||
const source = event.data.source
|
||||
if (
|
||||
typeof source === 'object'
|
||||
&& source !== null
|
||||
&& 'kind' in source
|
||||
&& 'plugin' in source
|
||||
&& source.kind === 'plugin'
|
||||
) {
|
||||
if (source.plugin === 'compact') return 'compaction'
|
||||
if (source.plugin === 'rewind') return 'rewind'
|
||||
}
|
||||
return 'rewrite'
|
||||
}
|
||||
|
||||
function isTokenDelta(chunk: SessionEvent<'assistant/chunk'>['data']['chunk']): boolean {
|
||||
switch (chunk.type) {
|
||||
case 'text-delta':
|
||||
|
||||
@@ -772,6 +772,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
|
||||
private buildSnapshot(): ConversationSnapshot {
|
||||
const { nodes: folded, degraded } = this.foldAdapter.nodes()
|
||||
const contexts = this.foldAdapter.contexts()
|
||||
// Frozen interrupted nodes ride fractional seqs: a stable merge keeps them in flow order.
|
||||
// The merged array is cached on (folded reference, frozenRev) so an unchanged flow keeps its
|
||||
// reference across snapshot swaps (§A.9.4).
|
||||
@@ -803,6 +804,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
return {
|
||||
sessionId: this.sessionId,
|
||||
nodes,
|
||||
contexts,
|
||||
foldDegraded: degraded,
|
||||
partial,
|
||||
runningCalls: this.callsCache.value,
|
||||
|
||||
Reference in New Issue
Block a user