Merge remote-tracking branch 'origin/master' into feat/web-session-log-export
# Conflicts: # docs/subsystems/persistence.i18n.yaml # docs/subsystems/persistence.md # docs/subsystems/persistence.zh.md # packages/client/ui-trajectory/README.i18n.yaml # packages/client/ui-trajectory/README.md # packages/client/ui-trajectory/README.zh.md # packages/client/ui-trajectory/package.json # packages/client/ui-trajectory/src/client/TrajectoryView.tsx # packages/client/ui-trajectory/src/client/index.ts # packages/client/ui-trajectory/tests/client-bundle.spec.ts # packages/client/ui-trajectory/tests/views.spec.tsx # packages/host/apiproxy/package.json # pnpm-lock.yaml
This commit is contained in:
@@ -24,7 +24,8 @@ import {
|
||||
groupTrajectoryVirtualRows, trajectoryVirtualRecordKey,
|
||||
} from './trajectory-virtual-rows.ts'
|
||||
import type { TrajectoryVirtualRow } from './trajectory-virtual-rows.ts'
|
||||
import { trajectoryPreviewText, type TrajectoryTurnModel } from './layout.ts'
|
||||
import type { TrajectoryTurnModel } from './layout.ts'
|
||||
import { trajectoryPreviewText } from './trajectory-preview.ts'
|
||||
import css from './TrajectoryTable.module.css'
|
||||
|
||||
const BOTTOM_FOLLOW_THRESHOLD_PX = 2
|
||||
@@ -491,6 +492,21 @@ function requestKey(turn: number | null, group: string): string {
|
||||
return `${turn}\u0000${group}`
|
||||
}
|
||||
|
||||
function indexRequestBoundaries(records: readonly TableRecord[]): ReadonlyMap<string, number> {
|
||||
const boundaries = new Map<string, number>()
|
||||
for (const record of records) {
|
||||
const key = requestKey(record.turn, record.group)
|
||||
if (boundaries.has(key)) continue
|
||||
if (requestStep(record.group) === undefined) {
|
||||
if (record.groupStart) boundaries.set(key, record.cell.index)
|
||||
continue
|
||||
}
|
||||
if (record.cell.kind === 'user' || record.cell.kind === 'context') continue
|
||||
boundaries.set(key, record.cell.index)
|
||||
}
|
||||
return boundaries
|
||||
}
|
||||
|
||||
function sectionLabel(turn: number | null): string {
|
||||
return turn === null ? 'Between turns' : `Turn ${turn}`
|
||||
}
|
||||
@@ -498,16 +514,18 @@ function sectionLabel(turn: number | null): string {
|
||||
function indexRequestNumbers(
|
||||
records: readonly TableRecord[],
|
||||
sessionNumbers: readonly TrajectoryRequestNumber[] | undefined,
|
||||
boundaries: ReadonlyMap<string, number>,
|
||||
): ReadonlyMap<string, number> {
|
||||
const numbers = new Map<string, number>()
|
||||
for (const request of sessionNumbers ?? []) {
|
||||
numbers.set(requestKey(request.turn, request.group), request.number)
|
||||
}
|
||||
let next = Math.max(0, ...numbers.values()) + 1
|
||||
const boundaries = records
|
||||
.filter(record => record.groupStart && requestStep(record.group) !== undefined)
|
||||
const boundaryRecords = records
|
||||
.filter(record => boundaries.get(requestKey(record.turn, record.group)) === record.cell.index
|
||||
&& requestStep(record.group) !== undefined)
|
||||
.sort((left, right) => left.cell.index - right.cell.index)
|
||||
for (const record of boundaries) {
|
||||
for (const record of boundaryRecords) {
|
||||
const key = requestKey(record.turn, record.group)
|
||||
if (!numbers.has(key)) numbers.set(key, next++)
|
||||
}
|
||||
@@ -903,6 +921,11 @@ function detailTabs(record: TableRecord): readonly DetailTabItem[] {
|
||||
|
||||
function recordDisplayText(cell: TrajectoryCellProps): string {
|
||||
if (isToolCallOnly(cell)) return ''
|
||||
if (cell.previewMarkdown !== undefined) {
|
||||
const preview = trajectoryPreviewText(cell.previewMarkdown)
|
||||
if (cell.text === '') return preview
|
||||
return preview === '' ? cell.text : `${cell.text} · ${preview}`
|
||||
}
|
||||
if (cell.text !== '') return cell.text
|
||||
const markdown = cell.kind === 'user' || cell.kind === 'context'
|
||||
? cell.inputDetail
|
||||
@@ -912,6 +935,12 @@ function recordDisplayText(cell: TrajectoryCellProps): string {
|
||||
return markdown === undefined ? '' : trajectoryPreviewText(markdown)
|
||||
}
|
||||
|
||||
function recordResultText(cell: TrajectoryCellProps): string | undefined {
|
||||
return cell.resultPreviewMarkdown === undefined
|
||||
? cell.result
|
||||
: trajectoryPreviewText(cell.resultPreviewMarkdown)
|
||||
}
|
||||
|
||||
function toolCallTextParts(
|
||||
kind: TrajectoryCellKind,
|
||||
text: string,
|
||||
@@ -932,6 +961,71 @@ function isToolCallOnly(cell: TrajectoryCellProps): boolean {
|
||||
&& cell.text === 'Tool call only'
|
||||
}
|
||||
|
||||
interface RecordPresentationValue {
|
||||
displayText: string
|
||||
listDisplayText: string
|
||||
resultText: string | undefined
|
||||
toolCallOnly: boolean
|
||||
toolCallText: ToolCallTextParts | undefined
|
||||
}
|
||||
|
||||
function RecordPresentation({
|
||||
cell,
|
||||
children,
|
||||
}: {
|
||||
cell: TrajectoryCellProps
|
||||
children: (value: RecordPresentationValue) => ReactNode
|
||||
}) {
|
||||
const displayText = useMemo(
|
||||
() => recordDisplayText(cell),
|
||||
[
|
||||
cell.kind, cell.text, cell.previewMarkdown,
|
||||
cell.inputDetail, cell.outputDetail, cell.thinkingDetail,
|
||||
],
|
||||
)
|
||||
const resultText = useMemo(
|
||||
() => recordResultText(cell),
|
||||
[cell.result, cell.resultPreviewMarkdown],
|
||||
)
|
||||
const toolCallOnly = isToolCallOnly(cell)
|
||||
const toolCallText = toolCallTextParts(cell.kind, displayText)
|
||||
const listDisplayText = toolCallOnly
|
||||
? '(tool call only)'
|
||||
: toolCallText === undefined
|
||||
? displayText
|
||||
: [toolCallText.name, toolCallText.args].filter(Boolean).join(' ')
|
||||
return children({
|
||||
displayText,
|
||||
listDisplayText,
|
||||
resultText,
|
||||
toolCallOnly,
|
||||
toolCallText,
|
||||
})
|
||||
}
|
||||
|
||||
function RecordListText({
|
||||
displayText,
|
||||
toolCallOnly,
|
||||
toolCallText,
|
||||
}: Pick<RecordPresentationValue, 'displayText' | 'toolCallOnly' | 'toolCallText'>) {
|
||||
if (toolCallOnly) {
|
||||
return <span className={css.toolCallOnly}>(tool call only)</span>
|
||||
}
|
||||
if (toolCallText === undefined) return displayText || '—'
|
||||
return (
|
||||
<>
|
||||
<span className={css.toolCallNameTypeface}>
|
||||
{toolCallText.name || '—'}
|
||||
</span>
|
||||
{toolCallText.args !== undefined && (
|
||||
<span className={css.toolCallPayload}>
|
||||
{toolCallText.args}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function MarkdownFragment({
|
||||
text,
|
||||
rendered,
|
||||
@@ -1654,9 +1748,10 @@ export function TrajectoryTable({
|
||||
useEffect(() => {
|
||||
onSelectedIndexChange?.(selectedIndex)
|
||||
}, [onSelectedIndexChange, selectedIndex])
|
||||
const requestBoundaries = useMemo(() => indexRequestBoundaries(allRecords), [allRecords])
|
||||
const requestNumbers = useMemo(
|
||||
() => indexRequestNumbers(allRecords, sessionRequestNumbers),
|
||||
[allRecords, sessionRequestNumbers],
|
||||
() => indexRequestNumbers(allRecords, sessionRequestNumbers, requestBoundaries),
|
||||
[allRecords, requestBoundaries, sessionRequestNumbers],
|
||||
)
|
||||
const records = useMemo(() => {
|
||||
if (searchMatchIndexes !== null) return filterRecords(allRecords, searchMatchIndexes)
|
||||
@@ -2131,263 +2226,251 @@ export function TrajectoryTable({
|
||||
/>
|
||||
</tr>
|
||||
)}
|
||||
{renderedRecords.map(({ record, position, terminalRequestBoundary }) => {
|
||||
const displayText = recordDisplayText(record.cell)
|
||||
const toolCallOnly = isToolCallOnly(record.cell)
|
||||
const toolCallText = toolCallTextParts(record.cell.kind, displayText)
|
||||
const listDisplayText = toolCallOnly
|
||||
? '(tool call only)'
|
||||
: toolCallText === undefined
|
||||
? displayText
|
||||
: [toolCallText.name, toolCallText.args].filter(Boolean).join(' ')
|
||||
const isCollapsedSummary = record.collapsedSummary !== undefined
|
||||
const isRequestOnly = record.cell.requestOnly === true
|
||||
const isInitialSystem = record.cell.kind === 'system'
|
||||
{renderedRecords.map(({ record, position, terminalRequestBoundary }) => (
|
||||
<RecordPresentation
|
||||
key={trajectoryVirtualRecordKey(record)}
|
||||
cell={record.cell}
|
||||
>
|
||||
{({ displayText, listDisplayText, resultText, toolCallOnly, toolCallText }) => {
|
||||
const isCollapsedSummary = record.collapsedSummary !== undefined
|
||||
const isRequestOnly = record.cell.requestOnly === true
|
||||
const isInitialSystem = record.cell.kind === 'system'
|
||||
&& record.cell.index === allRecords[0]?.cell.index
|
||||
const request = record.groupStart
|
||||
const key = requestKey(record.turn, record.group)
|
||||
const request = requestBoundaries.get(key) === record.cell.index
|
||||
&& !isCollapsedSummary
|
||||
&& (record.turn === null || !collapsedTurns.has(record.turn))
|
||||
? requestNumbers.get(requestKey(record.turn, record.group))
|
||||
: undefined
|
||||
const requestInfo = request === undefined
|
||||
? undefined
|
||||
: sessionRequestNumbers?.find(candidate => candidate.number === request)
|
||||
const requestStatus = requestInfo?.status
|
||||
? requestNumbers.get(key)
|
||||
: undefined
|
||||
const requestInfo = request === undefined
|
||||
? undefined
|
||||
: sessionRequestNumbers?.find(candidate => candidate.number === request)
|
||||
const requestStatus = requestInfo?.status
|
||||
?? (record.cell.isError === true ? 'error' : undefined)
|
||||
const requestRunIndex = requestBoundaryRuns.get(record.cell.index) ?? 0
|
||||
const requestBoundaryStyle: RequestBoundaryStyle = {
|
||||
'--request-boundary-offset': `${requestRunIndex * 8}px`,
|
||||
}
|
||||
const requestLabel = request === undefined
|
||||
? undefined
|
||||
: `Request #${request}${requestInfo?.purpose === 'compaction' ? ' · Compaction' : ''}`
|
||||
const requestSelected = request !== undefined
|
||||
const requestRunIndex = requestBoundaryRuns.get(record.cell.index) ?? 0
|
||||
const requestBoundaryStyle: RequestBoundaryStyle = {
|
||||
'--request-boundary-offset': `${requestRunIndex * 8}px`,
|
||||
}
|
||||
const requestLabel = request === undefined
|
||||
? undefined
|
||||
: `Request #${request}${requestInfo?.purpose === 'compaction' ? ' · Compaction' : ''}`
|
||||
const requestSelected = request !== undefined
|
||||
&& selectedRequest?.turn === record.turn
|
||||
&& selectedRequest.group === record.group
|
||||
const sectionActive = record.turn === null
|
||||
? activeSection === record.section
|
||||
: activeTurn === record.turn
|
||||
return (
|
||||
<tr
|
||||
key={trajectoryVirtualRecordKey(record)}
|
||||
tabIndex={isRequestOnly ? -1 : 0}
|
||||
aria-rowindex={position + 1}
|
||||
aria-label={isCollapsedSummary
|
||||
? `Collapsed ${record.collapsedSummaryKind} summary, ${record.collapsedSummary}`
|
||||
: isRequestOnly
|
||||
? `Request ${request ?? ''}, compaction`
|
||||
: `${request === undefined ? '' : `Request ${request}, `}${KIND_LABEL[record.cell.kind]}, ${listDisplayText || 'no content'}`}
|
||||
aria-selected={!isCollapsedSummary && !isRequestOnly && selectedIndex === record.cell.index}
|
||||
data-kind={record.cell.kind}
|
||||
data-trajectory-row-key={trajectoryVirtualRecordKey(record)}
|
||||
data-virtual-position={virtualizationEnabled ? position : undefined}
|
||||
data-record-index={!isCollapsedSummary && !isRequestOnly
|
||||
? record.cell.index
|
||||
: undefined}
|
||||
data-request-only={isRequestOnly || undefined}
|
||||
data-terminal-request-boundary={terminalRequestBoundary || undefined}
|
||||
data-group-start={record.groupStart || undefined}
|
||||
data-turn-start={record.turnStart || undefined}
|
||||
data-error={record.cell.isError || undefined}
|
||||
data-running={stateOf(record) === 'running' || undefined}
|
||||
data-turn-end={record.turnEnd || undefined}
|
||||
data-collapsed-summary={record.collapsedSummaryKind}
|
||||
data-selected={!isCollapsedSummary && selectedIndex === record.cell.index || undefined}
|
||||
data-timeline-focus={isCollapsedSummary || timelineFocusIndexes === null
|
||||
? undefined
|
||||
: timelineFocusIndexes.has(record.cell.index) ? 'inside' : 'outside'}
|
||||
onClick={isRequestOnly
|
||||
? undefined
|
||||
: isCollapsedSummary
|
||||
? () => {
|
||||
if (record.collapsedSummaryKind === 'turn' && record.turn !== null) {
|
||||
const sectionActive = record.turn === null
|
||||
? activeSection === record.section
|
||||
: activeTurn === record.turn
|
||||
return (
|
||||
<tr
|
||||
tabIndex={isRequestOnly ? -1 : 0}
|
||||
aria-rowindex={position + 1}
|
||||
aria-label={isCollapsedSummary
|
||||
? `Collapsed ${record.collapsedSummaryKind} summary, ${record.collapsedSummary}`
|
||||
: isRequestOnly
|
||||
? `Request ${request ?? ''}, compaction`
|
||||
: `${request === undefined ? '' : `Request ${request}, `}${KIND_LABEL[record.cell.kind]}, ${listDisplayText || 'no content'}`}
|
||||
aria-selected={!isCollapsedSummary && !isRequestOnly && selectedIndex === record.cell.index}
|
||||
data-kind={record.cell.kind}
|
||||
data-trajectory-row-key={trajectoryVirtualRecordKey(record)}
|
||||
data-virtual-position={virtualizationEnabled ? position : undefined}
|
||||
data-record-index={!isCollapsedSummary && !isRequestOnly
|
||||
? record.cell.index
|
||||
: undefined}
|
||||
data-request-only={isRequestOnly || undefined}
|
||||
data-terminal-request-boundary={terminalRequestBoundary || undefined}
|
||||
data-group-start={record.groupStart || undefined}
|
||||
data-turn-start={record.turnStart || undefined}
|
||||
data-error={record.cell.isError || undefined}
|
||||
data-running={stateOf(record) === 'running' || undefined}
|
||||
data-turn-end={record.turnEnd || undefined}
|
||||
data-collapsed-summary={record.collapsedSummaryKind}
|
||||
data-selected={!isCollapsedSummary && selectedIndex === record.cell.index || undefined}
|
||||
data-timeline-focus={isCollapsedSummary || timelineFocusIndexes === null
|
||||
? undefined
|
||||
: timelineFocusIndexes.has(record.cell.index) ? 'inside' : 'outside'}
|
||||
onClick={isRequestOnly
|
||||
? undefined
|
||||
: isCollapsedSummary
|
||||
? () => {
|
||||
if (record.collapsedSummaryKind === 'turn' && record.turn !== null) {
|
||||
onToggleTurn(record.turn)
|
||||
} else onToggleAssistant(trajectoryRecordId(record.cell))
|
||||
}
|
||||
: () => { selectRecord(record.cell.index) }}
|
||||
onDoubleClick={(event) => {
|
||||
if (isCollapsedSummary || isRequestOnly) return
|
||||
if (record.turn !== null && collapsedTurns.has(record.turn)) {
|
||||
event.preventDefault()
|
||||
onToggleTurn(record.turn)
|
||||
} else onToggleAssistant(trajectoryRecordId(record.cell))
|
||||
}
|
||||
: () => { selectRecord(record.cell.index) }}
|
||||
onDoubleClick={(event) => {
|
||||
if (isCollapsedSummary || isRequestOnly) return
|
||||
if (record.turn !== null && collapsedTurns.has(record.turn)) {
|
||||
event.preventDefault()
|
||||
onToggleTurn(record.turn)
|
||||
return
|
||||
}
|
||||
if (
|
||||
record.cell.kind === 'message'
|
||||
return
|
||||
}
|
||||
if (
|
||||
record.cell.kind === 'message'
|
||||
&& assistantToolCalls(allRecords, record.cell.index).length > 0
|
||||
) {
|
||||
event.preventDefault()
|
||||
onToggleAssistant(trajectoryRecordId(record.cell))
|
||||
return
|
||||
}
|
||||
if (!record.turnStart) return
|
||||
if (record.turn === null) return
|
||||
if (allRecords.filter(candidate =>
|
||||
candidate.turn === record.turn
|
||||
) {
|
||||
event.preventDefault()
|
||||
onToggleAssistant(trajectoryRecordId(record.cell))
|
||||
return
|
||||
}
|
||||
if (!record.turnStart) return
|
||||
if (record.turn === null) return
|
||||
if (allRecords.filter(candidate =>
|
||||
candidate.turn === record.turn
|
||||
&& candidate.cell.requestOnly !== true
|
||||
&& candidate.cell.kind !== 'system').length <= 1) return
|
||||
event.preventDefault()
|
||||
onToggleTurn(record.turn)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (isRequestOnly) return
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return
|
||||
event.preventDefault()
|
||||
if (isCollapsedSummary) {
|
||||
if (record.collapsedSummaryKind === 'turn' && record.turn !== null) {
|
||||
event.preventDefault()
|
||||
onToggleTurn(record.turn)
|
||||
} else onToggleAssistant(trajectoryRecordId(record.cell))
|
||||
return
|
||||
}
|
||||
selectRecord(record.cell.index)
|
||||
}}
|
||||
>
|
||||
<td className={css.event}>
|
||||
{request !== undefined && (
|
||||
<button
|
||||
type="button"
|
||||
className={requestSelected
|
||||
? `${css.requestBoundaryControl} ${css.requestBoundaryControlActive}`
|
||||
: css.requestBoundaryControl}
|
||||
aria-label={requestLabel}
|
||||
aria-pressed={requestSelected}
|
||||
data-label={requestLabel}
|
||||
data-request-run-index={requestRunIndex}
|
||||
data-request-status={requestStatus}
|
||||
style={requestBoundaryStyle}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
selectRequest({
|
||||
turn: record.turn,
|
||||
group: record.group,
|
||||
...(requestInfo?.seq === undefined ? {} : { seq: requestInfo.seq }),
|
||||
})
|
||||
}}
|
||||
onDoubleClick={(event) => { event.stopPropagation() }}
|
||||
/>
|
||||
)}
|
||||
{record.turn !== null
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (isRequestOnly) return
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return
|
||||
event.preventDefault()
|
||||
if (isCollapsedSummary) {
|
||||
if (record.collapsedSummaryKind === 'turn' && record.turn !== null) {
|
||||
onToggleTurn(record.turn)
|
||||
} else onToggleAssistant(trajectoryRecordId(record.cell))
|
||||
return
|
||||
}
|
||||
selectRecord(record.cell.index)
|
||||
}}
|
||||
>
|
||||
<td className={css.event}>
|
||||
{request !== undefined && (
|
||||
<button
|
||||
type="button"
|
||||
className={requestSelected
|
||||
? `${css.requestBoundaryControl} ${css.requestBoundaryControlActive}`
|
||||
: css.requestBoundaryControl}
|
||||
aria-label={requestLabel}
|
||||
aria-pressed={requestSelected}
|
||||
data-label={requestLabel}
|
||||
data-request-run-index={requestRunIndex}
|
||||
data-request-status={requestStatus}
|
||||
style={requestBoundaryStyle}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
selectRequest({
|
||||
turn: record.turn,
|
||||
group: record.group,
|
||||
...(requestInfo?.seq === undefined ? {} : { seq: requestInfo.seq }),
|
||||
})
|
||||
}}
|
||||
onDoubleClick={(event) => { event.stopPropagation() }}
|
||||
/>
|
||||
)}
|
||||
{record.turn !== null
|
||||
&& activeTurn === record.turn
|
||||
&& !isInitialSystem && (
|
||||
<span className={css.turnRail} aria-hidden="true" />
|
||||
)}
|
||||
{!isCollapsedSummary && selectedIndex === record.cell.index && (
|
||||
<span className={css.selectionRail} aria-hidden="true" />
|
||||
)}
|
||||
{!isCollapsedSummary
|
||||
<span className={css.turnRail} aria-hidden="true" />
|
||||
)}
|
||||
{!isCollapsedSummary && selectedIndex === record.cell.index && (
|
||||
<span className={css.selectionRail} aria-hidden="true" />
|
||||
)}
|
||||
{!isCollapsedSummary
|
||||
&& !isRequestOnly
|
||||
&& record.turnStart && (
|
||||
<span
|
||||
className={sectionActive
|
||||
? `${css.turnLabel} ${css.turnLabelActive}`
|
||||
: css.turnLabel}
|
||||
aria-label={sectionLabel(record.turn)}
|
||||
>
|
||||
{record.turn === null
|
||||
? sectionLabel(record.turn)
|
||||
: (
|
||||
<>
|
||||
<span className={css.turnLabelFull} aria-hidden="true">
|
||||
{sectionLabel(record.turn)}
|
||||
</span>
|
||||
<span className={css.turnLabelCompact} aria-hidden="true">
|
||||
#{record.turn}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<div className={css.eventInner}>
|
||||
{!isCollapsedSummary && !isRequestOnly && (
|
||||
<span
|
||||
className={css.kindSlot}
|
||||
>
|
||||
<span
|
||||
className={`${css.kindTag} ${
|
||||
record.cell.kind === 'system'
|
||||
? css.systemNeutral
|
||||
: record.cell.kind === 'context'
|
||||
? css.contextGreen
|
||||
: record.cell.kind === 'compacted'
|
||||
? css.compacted
|
||||
: record.cell.kind === 'tool'
|
||||
? css.toolAmber
|
||||
: record.cell.kind === 'message'
|
||||
? css.assistantVioletBright
|
||||
: record.cell.kind === 'subtool'
|
||||
? css.subtoolAmber
|
||||
: css[record.cell.kind]
|
||||
}`}
|
||||
data-role-kind={record.cell.kind}
|
||||
className={sectionActive
|
||||
? `${css.turnLabel} ${css.turnLabelActive}`
|
||||
: css.turnLabel}
|
||||
aria-label={sectionLabel(record.turn)}
|
||||
>
|
||||
<Tooltip
|
||||
label={KIND_LABEL[record.cell.kind]}
|
||||
side="right"
|
||||
>
|
||||
<span className={css.kindTagIcon} aria-hidden="true">
|
||||
{KIND_ICON[record.cell.kind]}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span className={css.kindTagLabel}>
|
||||
{KIND_LABEL[record.cell.kind]}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className={css.content}>
|
||||
{isRequestOnly
|
||||
? null
|
||||
: record.collapsedSummary !== undefined
|
||||
? (
|
||||
<span className={css.collapsedTurnContent} title={record.collapsedSummary}>
|
||||
<span className={css.collapsedTurnEllipsis}>…</span>
|
||||
<span className={css.collapsedTurnText}>{record.collapsedSummary}</span>
|
||||
</span>
|
||||
)
|
||||
: (
|
||||
<span
|
||||
className={record.cell.result === undefined ? css.contentText : css.resultPreview}
|
||||
title={record.cell.result === undefined
|
||||
? listDisplayText
|
||||
: `${listDisplayText} → ${record.cell.result}`}
|
||||
>
|
||||
<span className={record.cell.result === undefined ? undefined : css.resultRequest}>
|
||||
{toolCallOnly
|
||||
? <span className={css.toolCallOnly}>(tool call only)</span>
|
||||
: toolCallText === undefined
|
||||
? listDisplayText || '—'
|
||||
: (
|
||||
<>
|
||||
<span className={css.toolCallNameTypeface}>
|
||||
{toolCallText.name || '—'}
|
||||
</span>
|
||||
{toolCallText.args !== undefined && (
|
||||
<span className={css.toolCallPayload}>
|
||||
{toolCallText.args}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
{record.cell.result !== undefined && (
|
||||
<span className={record.cell.isError ? `${css.inlineResult} ${css.error}` : css.inlineResult}>
|
||||
<span className={css.arrow}>→</span>
|
||||
<span className={record.cell.result === 'No output'
|
||||
? `${css.inlineResultText} ${css.noOutputText}`
|
||||
: css.inlineResultText}
|
||||
>
|
||||
{record.cell.result}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{record.turn === null
|
||||
? sectionLabel(record.turn)
|
||||
: (
|
||||
<>
|
||||
<span className={css.turnLabelFull} aria-hidden="true">
|
||||
{sectionLabel(record.turn)}
|
||||
</span>
|
||||
<span className={css.turnLabelCompact} aria-hidden="true">
|
||||
#{record.turn}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
<div className={css.eventInner}>
|
||||
{!isCollapsedSummary && !isRequestOnly && (
|
||||
<span
|
||||
className={css.kindSlot}
|
||||
>
|
||||
<span
|
||||
className={`${css.kindTag} ${
|
||||
record.cell.kind === 'system'
|
||||
? css.systemNeutral
|
||||
: record.cell.kind === 'context'
|
||||
? css.contextGreen
|
||||
: record.cell.kind === 'compacted'
|
||||
? css.compacted
|
||||
: record.cell.kind === 'tool'
|
||||
? css.toolAmber
|
||||
: record.cell.kind === 'message'
|
||||
? css.assistantVioletBright
|
||||
: record.cell.kind === 'subtool'
|
||||
? css.subtoolAmber
|
||||
: css[record.cell.kind]
|
||||
}`}
|
||||
data-role-kind={record.cell.kind}
|
||||
>
|
||||
<Tooltip
|
||||
label={KIND_LABEL[record.cell.kind]}
|
||||
side="right"
|
||||
>
|
||||
<span className={css.kindTagIcon} aria-hidden="true">
|
||||
{KIND_ICON[record.cell.kind]}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span className={css.kindTagLabel}>
|
||||
{KIND_LABEL[record.cell.kind]}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className={css.content}>
|
||||
{isRequestOnly
|
||||
? null
|
||||
: record.collapsedSummary !== undefined
|
||||
? (
|
||||
<span className={css.collapsedTurnContent} title={record.collapsedSummary}>
|
||||
<span className={css.collapsedTurnEllipsis}>…</span>
|
||||
<span className={css.collapsedTurnText}>{record.collapsedSummary}</span>
|
||||
</span>
|
||||
)
|
||||
: (
|
||||
<span
|
||||
className={resultText === undefined ? css.contentText : css.resultPreview}
|
||||
title={resultText === undefined
|
||||
? listDisplayText
|
||||
: `${listDisplayText} → ${resultText}`}
|
||||
>
|
||||
<span className={resultText === undefined ? undefined : css.resultRequest}>
|
||||
<RecordListText
|
||||
displayText={displayText}
|
||||
toolCallOnly={toolCallOnly}
|
||||
toolCallText={toolCallText}
|
||||
/>
|
||||
</span>
|
||||
{resultText !== undefined && (
|
||||
<span className={record.cell.isError ? `${css.inlineResult} ${css.error}` : css.inlineResult}>
|
||||
<span className={css.arrow}>→</span>
|
||||
<span className={resultText === 'No output'
|
||||
? `${css.inlineResultText} ${css.noOutputText}`
|
||||
: css.inlineResultText}
|
||||
>
|
||||
{resultText}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}}
|
||||
</RecordPresentation>
|
||||
))}
|
||||
{virtualBottom > 0 && (
|
||||
<tr className={css.virtualSpacer} data-virtual-spacer="bottom" aria-hidden="true">
|
||||
<td
|
||||
|
||||
@@ -687,7 +687,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
||||
return (
|
||||
<Tooltip
|
||||
key={span.index}
|
||||
label={timelineTooltipLabel(span.kind, detail)}
|
||||
label={() => timelineTooltipLabel(span.kind, detail)}
|
||||
side="bottom"
|
||||
delayMs={TIMELINE_TOOLTIP_DELAY_MS}
|
||||
>
|
||||
|
||||
@@ -4,12 +4,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {
|
||||
AssistantBlock, AssistantMessageNode, ConversationContext, ConversationSnapshot,
|
||||
SessionHistoryFace, SnapshotStore,
|
||||
AssistantBlock, AssistantMessageNode, ConversationSnapshot,
|
||||
SnapshotStore,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
deriveTrajectoryContextBranches, trajectoryBranchContainsRequest,
|
||||
} from './context-branches.ts'
|
||||
import {
|
||||
TrajectoryTable,
|
||||
type TrajectoryRequestNumber,
|
||||
@@ -27,10 +24,13 @@ import {
|
||||
type TrajectoryTimeRange,
|
||||
} from './timeline.ts'
|
||||
import { trajectoryRecordId } from './trajectory-record.ts'
|
||||
import { TrajectorySearchIndex } from './trajectory-search-index.ts'
|
||||
import { EMPTY_TRAJECTORY_SNAPSHOT } from './trajectory-snapshot-builder.ts'
|
||||
import css from './views.module.css'
|
||||
|
||||
const EMPTY_TURN_IDS: ReadonlySet<number> = new Set()
|
||||
const EMPTY_RECORD_IDS: ReadonlySet<string> = new Set()
|
||||
const SEARCH_INDEX_THROTTLE_MS = 3_000
|
||||
|
||||
function lastCellIndex(turns: readonly TrajectoryTurnModel[]): number {
|
||||
let last = 0
|
||||
@@ -64,14 +64,12 @@ function partialStructureSignature(partial: ConversationSnapshot['partial']): st
|
||||
: block.kind).join('\u0000')
|
||||
}
|
||||
|
||||
/** Session-history paging needed by the event-complete trajectory view. */
|
||||
/** Session-bound controls not already supplied by the conversation view slot. */
|
||||
export interface TrajectoryViewInjected {
|
||||
hooks: {
|
||||
history: SessionHistoryFace
|
||||
duration: SnapshotStore<boolean>
|
||||
}
|
||||
loadHistoryTail: (signal: AbortSignal) => Promise<void>
|
||||
loadOlderHistory: (signal: AbortSignal) => Promise<boolean>
|
||||
loadOlder: () => Promise<boolean>
|
||||
setActualDuration: (actualDuration: boolean) => void
|
||||
/** Download the session log (including subagent logs) as a ZIP archive; rejects on failure. */
|
||||
exportLog: () => Promise<void>
|
||||
@@ -121,86 +119,23 @@ function addUsage(
|
||||
}
|
||||
}
|
||||
|
||||
function searchableJson(value: unknown): string {
|
||||
if (value === undefined) return ''
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function searchMatches(
|
||||
turns: ReturnType<typeof deriveTrajectoryLayout>,
|
||||
query: string,
|
||||
): ReadonlySet<number> | null {
|
||||
const terms = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean)
|
||||
if (terms.length === 0) return null
|
||||
const matches = new Set<number>()
|
||||
for (const turn of turns) {
|
||||
for (const group of turn.groups) {
|
||||
for (const cell of group.cells) {
|
||||
if (cell.requestOnly === true) continue
|
||||
const blocks = [
|
||||
...(cell.sourceBlocks ?? []),
|
||||
...(cell.outputBlocks ?? []),
|
||||
]
|
||||
const text = [
|
||||
turn.turn === null ? 'between turns' : `turn ${turn.turn}`,
|
||||
group.title,
|
||||
cell.kind,
|
||||
cell.kind === 'message' ? 'assistant' : undefined,
|
||||
cell.text,
|
||||
cell.inputDetail,
|
||||
cell.outputDetail,
|
||||
cell.thinkingDetail,
|
||||
cell.schemaDetail,
|
||||
cell.result,
|
||||
cell.callId,
|
||||
...blocks.flatMap(block => [
|
||||
block.type,
|
||||
block.content,
|
||||
block.callId,
|
||||
block.toolName,
|
||||
block.imageAlt,
|
||||
]),
|
||||
searchableJson(cell.messageSource),
|
||||
searchableJson(cell.promptDetail),
|
||||
searchableJson(cell.previousPromptDetail),
|
||||
].filter((value): value is string => typeof value === 'string')
|
||||
.join('\n')
|
||||
.toLocaleLowerCase()
|
||||
if (terms.every(term => text.includes(term))) matches.add(cell.index)
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
function mergeSearchMatches(
|
||||
finalized: ReadonlySet<number> | null,
|
||||
partial: ReadonlySet<number> | null,
|
||||
): ReadonlySet<number> | null {
|
||||
if (finalized === null || partial === null) return null
|
||||
return new Set([...finalized, ...partial])
|
||||
}
|
||||
|
||||
export function TrajectoryView({
|
||||
useHistory, useDuration, loadHistoryTail, loadOlderHistory, setActualDuration, exportLog,
|
||||
useSession, useDuration, loadOlder, setActualDuration, exportLog,
|
||||
inspect, onInspectDone, t,
|
||||
}: ConvViewProps & InjectFace<TrajectoryViewInjected> & PropsLocale<'trajectory'>) {
|
||||
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_TURN_IDS)
|
||||
const [collapsedAssistants, setCollapsedAssistants] =
|
||||
useState<ReadonlySet<string>>(EMPTY_RECORD_IDS)
|
||||
const [timelineSelection, setTimelineSelection] = useState<{
|
||||
branchKey: string
|
||||
range: TrajectoryTimeRange
|
||||
} | null>(null)
|
||||
const [timelineSelection, setTimelineSelection] = useState<TrajectoryTimeRange | null>(null)
|
||||
const actualDuration = useDuration(value => value)
|
||||
const [actualTime, setActualTime] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const [exportError, setExportError] = useState<string | null>(null)
|
||||
const [searchIndex] = useState(() => new TrajectorySearchIndex())
|
||||
const [searchIndexRevision, setSearchIndexRevision] = useState(0)
|
||||
const searchIndexTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const searchIndexInitialized = useRef(false)
|
||||
const [selectedTimelineIndex, setSelectedTimelineIndex] = useState<number | null>(null)
|
||||
const [timelineRecordSelection, setTimelineRecordSelection] = useState<{
|
||||
readonly index: number
|
||||
@@ -208,60 +143,20 @@ export function TrajectoryView({
|
||||
const [timelineRecordFocus, setTimelineRecordFocus] = useState<{
|
||||
readonly index: number
|
||||
} | null>(null)
|
||||
const inspection = useHistory(snapshot => snapshot.inspection)
|
||||
const historyLoading = useHistory(snapshot =>
|
||||
snapshot.state === 'cold' || snapshot.state === 'loading')
|
||||
const hasOlderHistory = useHistory(snapshot => snapshot.hasMore)
|
||||
const historyBaseSeq = useHistory(snapshot => snapshot.baseSeq)
|
||||
const inspection = useSession(snapshot =>
|
||||
snapshot.views.get('trajectory') ?? EMPTY_TRAJECTORY_SNAPSHOT)
|
||||
const historyLoading = useSession(snapshot =>
|
||||
snapshot.openState === 'loading' || snapshot.loadingOlder)
|
||||
const hasOlderHistory = useSession(snapshot => snapshot.hasMore)
|
||||
const nodes = inspection.eventNodes
|
||||
const eventLocations = inspection.eventLocations
|
||||
const historyBaseSeq = nodes[0]?.seq ?? 0
|
||||
const partial = inspection.partial
|
||||
const runningCalls = inspection.runningCalls
|
||||
const loadHistoryTailRef = useRef(loadHistoryTail)
|
||||
loadHistoryTailRef.current = loadHistoryTail
|
||||
const historyControllerRef = useRef<AbortController | null>(null)
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
historyControllerRef.current = controller
|
||||
void loadHistoryTailRef.current(controller.signal)
|
||||
return () => { controller.abort() }
|
||||
}, [])
|
||||
const requests = inspection.requests
|
||||
const callSchemas = inspection.callSchemas
|
||||
const historyContexts = inspection.contexts
|
||||
const interruptedNodes = inspection.interruptedNodes
|
||||
const contexts = useMemo<readonly ConversationContext[]>(
|
||||
() => historyContexts.length === 0
|
||||
? [{ id: 0, nodes }]
|
||||
: historyContexts,
|
||||
[historyContexts, nodes],
|
||||
)
|
||||
const branches = useMemo(
|
||||
() => deriveTrajectoryContextBranches(contexts),
|
||||
[contexts],
|
||||
)
|
||||
const currentBranch = branches.at(-1)
|
||||
if (currentBranch === undefined) throw new Error('trajectory branch projection must not be empty')
|
||||
const selectedNodes = useMemo(() => {
|
||||
const selected = new Map(currentBranch.nodes.map(node => [node.seq, node]))
|
||||
for (const node of interruptedNodes) {
|
||||
selected.set(node.seq, node)
|
||||
}
|
||||
return [...selected.values()].sort((left, right) => left.seq - right.seq)
|
||||
}, [currentBranch.nodes, interruptedNodes])
|
||||
const selectedRequests = useMemo(
|
||||
() => requests.filter(request =>
|
||||
trajectoryBranchContainsRequest(currentBranch, request),
|
||||
),
|
||||
[currentBranch, requests],
|
||||
)
|
||||
const requestNumbers = useMemo<readonly TrajectoryRequestNumber[]>(() => {
|
||||
const assistantsByStep = new Map<string, AssistantMessageNode>()
|
||||
for (const context of contexts) {
|
||||
for (const node of context.nodes) {
|
||||
if (node.kind !== 'assistant' || node.step <= 0) continue
|
||||
assistantsByStep.set(`${node.turn}\u0000${node.step}`, node)
|
||||
}
|
||||
}
|
||||
for (const node of nodes) {
|
||||
if (node.kind !== 'assistant' || node.step <= 0) continue
|
||||
assistantsByStep.set(`${node.turn}\u0000${node.step}`, node)
|
||||
@@ -357,24 +252,25 @@ export function TrajectoryView({
|
||||
|
||||
return numbered
|
||||
}, [
|
||||
contexts, nodes, requests,
|
||||
nodes, requests,
|
||||
])
|
||||
const partialTurn = partial?.turn ?? null
|
||||
const partialStep = partial?.step ?? null
|
||||
const finalized = useMemo(() => {
|
||||
const turns = deriveTrajectoryLayout({
|
||||
nodes: selectedNodes,
|
||||
nodes,
|
||||
eventLocations,
|
||||
partial: partialTurn === null || partialStep === null
|
||||
? null
|
||||
: { turn: partialTurn, step: partialStep, blocks: [] },
|
||||
runningCalls,
|
||||
requests: selectedRequests,
|
||||
requests,
|
||||
callSchemas,
|
||||
})
|
||||
return { turns, lastIndex: lastCellIndex(turns) }
|
||||
}, [
|
||||
selectedNodes, partialTurn, partialStep,
|
||||
runningCalls, selectedRequests, callSchemas,
|
||||
nodes, eventLocations, partialTurn, partialStep,
|
||||
runningCalls, requests, callSchemas,
|
||||
])
|
||||
const timelinePartialSignature = partialStructureSignature(partial)
|
||||
const timelinePartial = useMemo<ConversationSnapshot['partial']>(() => partial === null
|
||||
@@ -392,31 +288,60 @@ export function TrajectoryView({
|
||||
const timelineMode: TrajectoryTimelineMode = actualDuration
|
||||
? actualTime ? 'actual' : 'duration'
|
||||
: actualTime ? 'time' : 'sequence'
|
||||
const finalizedSearchMatches = useMemo(
|
||||
() => searchMatches(finalized.turns, searchQuery),
|
||||
[finalized, searchQuery],
|
||||
)
|
||||
const partialSearchTurns = useMemo(
|
||||
() => appendTrajectoryPartialLayout([], partial, finalized.lastIndex),
|
||||
[finalized.lastIndex, partial],
|
||||
)
|
||||
const searchLayouts = useMemo(
|
||||
() => [finalized.turns, partialSearchTurns] as const,
|
||||
[finalized, partialSearchTurns],
|
||||
)
|
||||
const latestSearchLayouts = useRef(searchLayouts)
|
||||
latestSearchLayouts.current = searchLayouts
|
||||
useEffect(() => {
|
||||
if (!searchIndexInitialized.current) {
|
||||
searchIndexInitialized.current = true
|
||||
if (searchIndex.update(searchLayouts)) {
|
||||
setSearchIndexRevision(revision => revision + 1)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (searchIndexTimer.current !== null) return
|
||||
searchIndexTimer.current = setTimeout(() => {
|
||||
searchIndexTimer.current = null
|
||||
if (searchIndex.update(latestSearchLayouts.current)) {
|
||||
setSearchIndexRevision(revision => revision + 1)
|
||||
}
|
||||
}, SEARCH_INDEX_THROTTLE_MS)
|
||||
}, [searchIndex, searchLayouts])
|
||||
useEffect(() => () => {
|
||||
if (searchIndexTimer.current !== null) clearTimeout(searchIndexTimer.current)
|
||||
}, [])
|
||||
const streamingCells = useMemo(
|
||||
() => partialSearchTurns.flatMap(turn =>
|
||||
turn.groups.flatMap(group => group.cells),
|
||||
),
|
||||
[partialSearchTurns],
|
||||
)
|
||||
const partialSearchMatches = useMemo(
|
||||
() => searchMatches(partialSearchTurns, searchQuery),
|
||||
[partialSearchTurns, searchQuery],
|
||||
const searchMatchRecordIds = useMemo(
|
||||
() => searchIndex.search(searchQuery),
|
||||
[searchIndex, searchIndexRevision, searchQuery],
|
||||
)
|
||||
const searchMatchIndexes = useMemo(
|
||||
() => mergeSearchMatches(finalizedSearchMatches, partialSearchMatches),
|
||||
[finalizedSearchMatches, partialSearchMatches],
|
||||
)
|
||||
const timelineRange = timelineSelection?.branchKey === currentBranch.key
|
||||
? timelineSelection.range
|
||||
: null
|
||||
const searchMatchIndexes = useMemo(() => {
|
||||
if (searchMatchRecordIds === null) return null
|
||||
const indexes = new Set<number>()
|
||||
for (const turns of searchLayouts) {
|
||||
for (const turn of turns) {
|
||||
for (const group of turn.groups) {
|
||||
for (const cell of group.cells) {
|
||||
if (searchMatchRecordIds.has(trajectoryRecordId(cell))) indexes.add(cell.index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return indexes
|
||||
}, [searchLayouts, searchMatchRecordIds])
|
||||
const timelineRange = timelineSelection
|
||||
const timelineFocusIndexes = useMemo(
|
||||
() => timelineRange === null
|
||||
? null
|
||||
@@ -432,11 +357,8 @@ export function TrajectoryView({
|
||||
}
|
||||
}, [timelineFocusIndexes])
|
||||
const handleTimelineRangeChange = useCallback((range: TrajectoryTimeRange | null) => {
|
||||
setTimelineSelection(range === null ? null : {
|
||||
branchKey: currentBranch.key,
|
||||
range,
|
||||
})
|
||||
}, [currentBranch.key])
|
||||
setTimelineSelection(range)
|
||||
}, [])
|
||||
const handleTimelineRecordSelect = useCallback((index: number) => {
|
||||
setTimelineSelection(null)
|
||||
setTimelineRecordSelection({ index })
|
||||
@@ -522,11 +444,8 @@ export function TrajectoryView({
|
||||
}
|
||||
|
||||
const loadEarlierHistory = useCallback(() => {
|
||||
const signal = historyControllerRef.current?.signal
|
||||
return signal?.aborted === false
|
||||
? loadOlderHistory(signal)
|
||||
: Promise.resolve(false)
|
||||
}, [loadOlderHistory])
|
||||
return loadOlder()
|
||||
}, [loadOlder])
|
||||
|
||||
const onExport = useCallback(() => {
|
||||
if (exporting) return
|
||||
@@ -584,7 +503,6 @@ export function TrajectoryView({
|
||||
/>
|
||||
<div className={css.ledger}>
|
||||
<TrajectoryTable
|
||||
key={currentBranch.key}
|
||||
requestNumbers={requestNumbers}
|
||||
turns={timelineTurns}
|
||||
streamingCells={streamingCells}
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
/** Rewind-delimited trajectory branches assembled across surface rewrites. */
|
||||
|
||||
import type {
|
||||
ConversationContext, ConversationNode, RequestView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** One continuous context branch; compactions stay inline while rewinds start a successor branch. */
|
||||
export interface TrajectoryContextBranch {
|
||||
id: number
|
||||
/** Identity stable when older context generations are prepended. */
|
||||
key: string
|
||||
contexts: readonly ConversationContext[]
|
||||
latest: ConversationContext
|
||||
nodes: readonly ConversationNode[]
|
||||
/** Seq that opened this branch; earlier requests require retained cited surface events. */
|
||||
startSeq: number
|
||||
/** Exact pre-rewind surface records inherited by this branch. */
|
||||
retainedSurfaceSeqs: ReadonlySet<number>
|
||||
}
|
||||
|
||||
interface MutableBranch {
|
||||
id: number
|
||||
key: string
|
||||
contexts: ConversationContext[]
|
||||
latest: ConversationContext
|
||||
nodes: Map<number, ConversationNode>
|
||||
startSeq: number
|
||||
retainedSurfaceSeqs: Set<number>
|
||||
}
|
||||
|
||||
function isCompactionCheckpoint(node: ConversationNode): boolean {
|
||||
if (node.kind !== 'context') return false
|
||||
const source = node.source
|
||||
return typeof source === 'object'
|
||||
&& source !== null
|
||||
&& 'kind' in source
|
||||
&& source.kind === 'plugin'
|
||||
&& 'plugin' in source
|
||||
&& source.plugin === 'compact'
|
||||
}
|
||||
|
||||
/**
|
||||
* Join context generations across compaction/rewrite operations and split only at rewind.
|
||||
* @param contexts - Append-only context generations from the runtime fold.
|
||||
* @returns Rewind-delimited branches in creation order.
|
||||
*/
|
||||
export function deriveTrajectoryContextBranches(
|
||||
contexts: readonly ConversationContext[],
|
||||
): readonly TrajectoryContextBranch[] {
|
||||
const mutable: MutableBranch[] = []
|
||||
for (const context of contexts) {
|
||||
const startsBranch = mutable.length === 0 || context.origin === 'rewind'
|
||||
if (startsBranch) {
|
||||
const previous = mutable.at(-1)
|
||||
const retainedSurfaceSeqs = new Set(
|
||||
context.nodes
|
||||
.filter(node =>
|
||||
context.originSeq !== undefined && node.seq < context.originSeq,
|
||||
)
|
||||
.map(node => node.seq),
|
||||
)
|
||||
const inheritedNodes = previous === undefined
|
||||
? []
|
||||
: [...previous.nodes.values()].filter(node =>
|
||||
retainedSurfaceSeqs.has(node.seq),
|
||||
)
|
||||
mutable.push({
|
||||
id: context.id,
|
||||
key: context.origin === 'rewind' && context.originSeq !== undefined
|
||||
? `rewind:${context.originSeq}`
|
||||
: 'root',
|
||||
contexts: [context],
|
||||
latest: context,
|
||||
nodes: new Map(
|
||||
[...inheritedNodes, ...context.nodes.filter(node => !isCompactionCheckpoint(node))]
|
||||
.map(node => [node.seq, node]),
|
||||
),
|
||||
startSeq: context.originSeq ?? Number.NEGATIVE_INFINITY,
|
||||
retainedSurfaceSeqs,
|
||||
})
|
||||
continue
|
||||
}
|
||||
const branch = mutable.at(-1)
|
||||
if (branch === undefined) continue
|
||||
branch.contexts.push(context)
|
||||
branch.latest = context
|
||||
for (const node of context.nodes) {
|
||||
if (!isCompactionCheckpoint(node)) branch.nodes.set(node.seq, node)
|
||||
}
|
||||
}
|
||||
return mutable.map(branch => ({
|
||||
id: branch.id,
|
||||
key: branch.key,
|
||||
contexts: branch.contexts,
|
||||
latest: branch.latest,
|
||||
nodes: [...branch.nodes.values()].sort((left, right) => left.seq - right.seq),
|
||||
startSeq: branch.startSeq,
|
||||
retainedSurfaceSeqs: branch.retainedSurfaceSeqs,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether a provider request belongs to one rewind branch.
|
||||
* @param branch - Branch carrying the exact inherited surface event seqs.
|
||||
* @param request - Provider request to classify.
|
||||
* @returns Whether the request began on this branch or produced a retained surface record.
|
||||
*/
|
||||
export function trajectoryBranchContainsRequest(
|
||||
branch: TrajectoryContextBranch,
|
||||
request: RequestView,
|
||||
): boolean {
|
||||
if (request.startSeq >= branch.startSeq) return true
|
||||
return (
|
||||
request.resultSeq !== undefined
|
||||
&& branch.retainedSurfaceSeqs.has(request.resultSeq)
|
||||
) || (
|
||||
request.purpose === 'compaction'
|
||||
&&
|
||||
request.replacementSeq !== undefined
|
||||
&& branch.retainedSurfaceSeqs.has(request.replacementSeq)
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* Browser trajectory plugin contributing one entry to the conversation view
|
||||
* slot without defining a service.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
@@ -10,12 +10,18 @@ import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
// owning package) must be in the program for the register calls to type.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { createTrajectoryDurationStore } from './duration-store.ts'
|
||||
import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.tsx'
|
||||
import { en, NS, zh } from './locales.ts'
|
||||
import { downloadBlob, sessionLogZipFilename } from './export-log.ts'
|
||||
import { en, NS, zh } from './locales.ts'
|
||||
import { registerTrajectoryAssistantDefinition } from './trajectory-assistant-definition.ts'
|
||||
import { registerTrajectoryCompactionDefinitions } from './trajectory-compaction-definition.ts'
|
||||
import { registerTrajectoryMessageDefinitions } from './trajectory-message-definitions.ts'
|
||||
import { registerTrajectoryRequestHeaderDefinition } from './trajectory-request-header-definition.ts'
|
||||
import { registerTrajectoryConversationView } from './trajectory-snapshot-builder.ts'
|
||||
import { registerTrajectoryToolDefinition } from './trajectory-tool-definition.ts'
|
||||
import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.tsx'
|
||||
|
||||
/** Required services: the conversation view slot, the independent history source, and the locale service. */
|
||||
export const inject = ['slots', 'sessionHistory', 'locale']
|
||||
/** Required services: the conversation slot, registries, ordinary Session paging, and the locale service. */
|
||||
export const inject = ['slots', 'conversationEvents', 'conversationViews', 'sessions', 'locale']
|
||||
|
||||
/**
|
||||
* Client plugin body: register the trajectory view tab. The registration
|
||||
@@ -29,6 +35,12 @@ export function apply(ctx: Context): void {
|
||||
// re-registration.
|
||||
const t = ctx.locale.bind(NS)
|
||||
const duration = createTrajectoryDurationStore()
|
||||
registerTrajectoryMessageDefinitions(ctx)
|
||||
registerTrajectoryRequestHeaderDefinition(ctx)
|
||||
registerTrajectoryAssistantDefinition(ctx)
|
||||
registerTrajectoryToolDefinition(ctx)
|
||||
registerTrajectoryCompactionDefinitions(ctx)
|
||||
registerTrajectoryConversationView(ctx)
|
||||
ctx.slots.inject('conversation.view', () => ctx.slots.register({
|
||||
name: 'conversation.view',
|
||||
id: 'trajectory',
|
||||
@@ -36,11 +48,17 @@ export function apply(ctx: Context): void {
|
||||
locale: NS,
|
||||
label: () => t('view.trajectory'),
|
||||
inject: (sessionId: SessionId): TrajectoryViewInjected => {
|
||||
const history = ctx.sessionHistory.source(sessionId)
|
||||
const session = ctx.sessions.binding(sessionId)?.session
|
||||
if (session === undefined) {
|
||||
throw new Error(`ui-trajectory: session "${sessionId}" is unavailable`)
|
||||
}
|
||||
return {
|
||||
hooks: { history, duration },
|
||||
loadHistoryTail: signal => history.loadTail(signal),
|
||||
loadOlderHistory: signal => history.loadOlder(signal),
|
||||
hooks: { duration },
|
||||
loadOlder: async () => {
|
||||
const before = session.getSnapshot().views.get('trajectory')
|
||||
await session.loadOlder()
|
||||
return session.getSnapshot().views.get('trajectory') !== before
|
||||
},
|
||||
setActualDuration: (value) => { duration.set(value) },
|
||||
exportLog: async () => {
|
||||
// The host streams the ZIP (root + descendant artifacts verbatim)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import type {
|
||||
AssistantBlock,
|
||||
AssistantMessageNode,
|
||||
ConversationLocation,
|
||||
ConversationSnapshot,
|
||||
RequestInspectionSnapshot,
|
||||
RequestPromptChange,
|
||||
@@ -12,7 +13,6 @@ import type {
|
||||
ToolCallBlock,
|
||||
ToolResultNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { extractMarkdownPlainText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type {
|
||||
TrajectoryCellProps,
|
||||
TrajectorySourceBlock,
|
||||
@@ -35,6 +35,7 @@ export interface TrajectoryTurnModel {
|
||||
/** Snapshot slice the trajectory view folds. */
|
||||
export interface TrajectoryLayoutInput {
|
||||
nodes: ConversationSnapshot['nodes']
|
||||
eventLocations?: ReadonlyMap<number, ConversationLocation>
|
||||
partial: ConversationSnapshot['partial']
|
||||
runningCalls: ConversationSnapshot['runningCalls']
|
||||
requests?: readonly RequestView[]
|
||||
@@ -70,12 +71,9 @@ interface TurnBucket {
|
||||
type AssistantRequestView = Extract<RequestView, { purpose: 'assistant' }>
|
||||
type CompactionRequestView = Extract<RequestView, { purpose: 'compaction' }>
|
||||
|
||||
const PREVIEW_SOURCE_CHARACTERS = 2_048
|
||||
const PREVIEW_OUTPUT_CHARACTERS = 512
|
||||
|
||||
type InputNode = Extract<
|
||||
ConversationSnapshot['nodes'][number],
|
||||
{ kind: 'user' | 'context' }
|
||||
{ kind: 'user' | 'steering' | 'context' }
|
||||
>
|
||||
|
||||
type OrderedLayoutEntry =
|
||||
@@ -110,10 +108,19 @@ function layoutEntryOrder(entry: OrderedLayoutEntry): number {
|
||||
|
||||
function inputCellDetail(node: InputNode): Pick<
|
||||
TrajectoryCellProps,
|
||||
'text' | 'sourceSeq' | 'messageSource' | 'inputDetail' | 'sourceBlocks' | 'timeSeconds' | 'startedAt'
|
||||
| 'text'
|
||||
| 'previewMarkdown'
|
||||
| 'sourceSeq'
|
||||
| 'messageSource'
|
||||
| 'inputDetail'
|
||||
| 'sourceBlocks'
|
||||
| 'timeSeconds'
|
||||
| 'startedAt'
|
||||
> {
|
||||
const previewMarkdown = previewContent(node.content)
|
||||
return {
|
||||
text: summarizeContent(node.content),
|
||||
text: '',
|
||||
...(previewMarkdown === undefined ? {} : { previewMarkdown }),
|
||||
sourceSeq: node.seq,
|
||||
messageSource: node.source,
|
||||
inputDetail: detailContent(node.content),
|
||||
@@ -130,12 +137,13 @@ function inputCellDetail(node: InputNode): Pick<
|
||||
*/
|
||||
export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly TrajectoryTurnModel[] {
|
||||
const {
|
||||
nodes, partial, runningCalls, requests = [], callSchemas,
|
||||
nodes, eventLocations, partial, runningCalls, requests = [], callSchemas,
|
||||
} = input
|
||||
const resultByCall = indexResults(nodes)
|
||||
const callById = new Map<string, ToolCallBlock>(resultByCall)
|
||||
for (const call of runningCalls) callById.set(call.callId, call)
|
||||
const emittedCallIds = indexAssistantCallIds(nodes)
|
||||
const followingAssistants = indexFollowingAssistants(nodes)
|
||||
const callStartById = new Map<string, number>()
|
||||
for (const result of resultByCall.values()) {
|
||||
const startedAt = finiteTime(result.callTime)
|
||||
@@ -180,6 +188,19 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
}
|
||||
groups.push({ title, laid: [...laid] })
|
||||
}
|
||||
const pushStepInput = (turn: number, step: number, laid: readonly LaidCell[]) => {
|
||||
if (laid.length === 0) return
|
||||
const groups = bucket(turn).groups
|
||||
const title = `Step ${step}`
|
||||
const existing = groups.find(group => group.title === title)
|
||||
if (existing === undefined) {
|
||||
groups.push({ title, laid: [...laid] })
|
||||
return
|
||||
}
|
||||
const request = existing.laid.findIndex(entry => entry.cell.requestOnly === true)
|
||||
if (request === -1) existing.laid.push(...laid)
|
||||
else existing.laid.splice(request, 0, ...laid)
|
||||
}
|
||||
|
||||
const representedRequests = new Set<string>()
|
||||
for (const node of nodes) {
|
||||
@@ -293,7 +314,10 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
? request.error ?? 'Compaction failed'
|
||||
: request.summary === undefined
|
||||
? 'Context compacted'
|
||||
: summarizeContent(request.summary),
|
||||
: '',
|
||||
...(request.status === 'complete' && request.summary !== undefined
|
||||
? previewContentProperty(request.summary)
|
||||
: {}),
|
||||
sourceSeq: request.startSeq,
|
||||
...(request.summary === undefined
|
||||
? {}
|
||||
@@ -330,7 +354,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
if (node.kind === 'user') {
|
||||
// user/message has no turn on the wire; enclose it in the next assistant
|
||||
// (or partial) turn, else open the turn after the last assistant.
|
||||
const turn = enclosingUserTurn(nodes, i, partial, lastAssistantTurn)
|
||||
const turn = enclosingUserTurn(followingAssistants[i], partial, lastAssistantTurn)
|
||||
pushMessage(turn, {
|
||||
absTime: finiteTime(node.time),
|
||||
cell: {
|
||||
@@ -343,6 +367,26 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
|
||||
continue
|
||||
}
|
||||
if (node.kind === 'steering') {
|
||||
const placement = steeringPlacement(
|
||||
followingAssistants[i],
|
||||
partial,
|
||||
lastAssistantTurn,
|
||||
eventLocations?.get(node.seq),
|
||||
)
|
||||
const laid = {
|
||||
absTime: finiteTime(node.time),
|
||||
cell: {
|
||||
index: ++index,
|
||||
kind: 'user' as const,
|
||||
...inputCellDetail(node),
|
||||
},
|
||||
}
|
||||
if (placement.step === undefined) pushMessage(placement.turn, laid)
|
||||
else pushStepInput(placement.turn, placement.step, [laid])
|
||||
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
|
||||
continue
|
||||
}
|
||||
if (node.kind === 'assistant') {
|
||||
const laidList = withSubCalls(
|
||||
expandAssistant(node, index + 1, prevAbsTime, resultByCall, callStartById, callById),
|
||||
@@ -356,7 +400,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
continue
|
||||
}
|
||||
if (node.kind === 'context') {
|
||||
const turn = enclosingUserTurn(nodes, i, partial, lastAssistantTurn)
|
||||
const turn = enclosingUserTurn(followingAssistants[i], partial, lastAssistantTurn)
|
||||
pushMessage(turn, {
|
||||
absTime: finiteTime(node.time),
|
||||
cell: {
|
||||
@@ -377,6 +421,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
if (node.kind === 'tool-result') {
|
||||
if (!emittedCallIds.has(node.callId)) {
|
||||
const toolName = node.call?.name
|
||||
const resultPreview = summarizeResult(node)
|
||||
const laidList: LaidCell[] = [{
|
||||
absTime: finiteTime(node.callTime ?? node.time),
|
||||
...(toolName !== undefined ? { toolName } : {}),
|
||||
@@ -386,13 +431,13 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
index: ++index,
|
||||
kind: 'tool',
|
||||
sourceSeq: node.seq,
|
||||
text: node.call !== null
|
||||
...(node.call !== null
|
||||
? summarizeCall(node.call.name, node.call.argsRaw)
|
||||
: summarizeResult(node),
|
||||
: resultAsText(resultPreview)),
|
||||
...(node.call !== null ? { inputDetail: node.call.argsRaw } : {}),
|
||||
outputDetail: detailResult(node),
|
||||
outputBlocks: node.content.map(block => sourceBlock(block)),
|
||||
result: summarizeResult(node),
|
||||
...resultPreview,
|
||||
callId: node.callId,
|
||||
isError: node.isError,
|
||||
timeSeconds: durationSeconds(node.time, node.callTime),
|
||||
@@ -440,7 +485,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
cell: {
|
||||
index: ++index,
|
||||
kind: 'tool',
|
||||
text: summarizeCall(call.name, call.argsRaw),
|
||||
...summarizeCall(call.name, call.argsRaw),
|
||||
inputDetail: call.argsRaw,
|
||||
callId: call.callId,
|
||||
timeSeconds: null,
|
||||
@@ -650,11 +695,14 @@ function expandAssistant(
|
||||
recordId: `assistant\u0000${node.turn}\u0000${node.step}`,
|
||||
kind: 'message',
|
||||
sourceSeq: node.seq,
|
||||
text: messageText !== ''
|
||||
? summarizeText(messageText)
|
||||
text: messageText !== '' || thinkingText !== ''
|
||||
? ''
|
||||
: summarizeAssistantActivity(node.blocks),
|
||||
...(messageText !== ''
|
||||
? { previewMarkdown: messageText }
|
||||
: thinkingText !== ''
|
||||
? summarizeText(thinkingText)
|
||||
: summarizeAssistantActivity(node.blocks),
|
||||
? { previewMarkdown: thinkingText }
|
||||
: {}),
|
||||
...(messageText !== '' ? { outputDetail: messageText } : {}),
|
||||
...(thinkingText !== '' ? { thinkingDetail: thinkingText } : {}),
|
||||
sourceBlocks: node.blocks.map(block => assistantSourceBlock(block)),
|
||||
@@ -681,6 +729,7 @@ function expandAssistant(
|
||||
: durationSeconds(result.time, result.callTime)
|
||||
const callAbs = finiteTime(callStarts.get(block.callId))
|
||||
const call = calls.get(block.callId)
|
||||
const resultPreview = result === undefined ? undefined : summarizeResult(result)
|
||||
out.push({
|
||||
absTime: callAbs,
|
||||
toolName: block.name,
|
||||
@@ -688,14 +737,14 @@ function expandAssistant(
|
||||
...(call === undefined ? {} : { subCalls: call.subCalls }),
|
||||
cell: {
|
||||
index: ++index, kind: 'tool',
|
||||
text: summarizeCall(block.name, block.argsRaw),
|
||||
...summarizeCall(block.name, block.argsRaw),
|
||||
inputDetail: block.argsRaw,
|
||||
callId: block.callId,
|
||||
...(result !== undefined
|
||||
? {
|
||||
outputDetail: detailResult(result),
|
||||
outputBlocks: result.content.map(block => sourceBlock(block)),
|
||||
result: summarizeResult(result),
|
||||
...resultPreview,
|
||||
isError: result.isError,
|
||||
}
|
||||
: {}),
|
||||
@@ -808,22 +857,53 @@ function stringifySourceValue(value: unknown): string {
|
||||
* in-flight partial, else the turn after the last finalized assistant (or 1).
|
||||
*/
|
||||
function enclosingUserTurn(
|
||||
nodes: ConversationSnapshot['nodes'],
|
||||
userIndex: number,
|
||||
followingAssistant: AssistantMessageNode | undefined,
|
||||
partial: ConversationSnapshot['partial'],
|
||||
lastAssistantTurn: number | null,
|
||||
): number {
|
||||
for (let i = userIndex + 1; i < nodes.length; i++) {
|
||||
const n = nodes[i]
|
||||
/* v8 ignore next -- dense-array guard: i stays within nodes.length, so the undefined arm needs a sparse array no caller builds. */
|
||||
if (n === undefined) continue
|
||||
if (n.kind === 'assistant') return n.turn
|
||||
}
|
||||
if (followingAssistant !== undefined) return followingAssistant.turn
|
||||
if (partial !== null) return partial.turn
|
||||
if (lastAssistantTurn !== null) return lastAssistantTurn + 1
|
||||
return 1
|
||||
}
|
||||
|
||||
function steeringPlacement(
|
||||
followingAssistant: AssistantMessageNode | undefined,
|
||||
partial: ConversationSnapshot['partial'],
|
||||
lastAssistantTurn: number | null,
|
||||
location: ConversationLocation | undefined,
|
||||
): { turn: number; step?: number } {
|
||||
if (location?.kind === 'step') {
|
||||
return { turn: location.turn.turn, step: location.step.step }
|
||||
}
|
||||
const locatedTurn = location?.kind === 'turn' ? location.turn.turn : undefined
|
||||
if (followingAssistant !== undefined
|
||||
&& (locatedTurn === undefined || followingAssistant.turn === locatedTurn)) {
|
||||
return {
|
||||
turn: followingAssistant.turn,
|
||||
...(followingAssistant.step > 0 ? { step: followingAssistant.step } : {}),
|
||||
}
|
||||
}
|
||||
if (partial !== null && (locatedTurn === undefined || partial.turn === locatedTurn)) {
|
||||
return { turn: partial.turn, ...(partial.step > 0 ? { step: partial.step } : {}) }
|
||||
}
|
||||
if (locatedTurn !== undefined) return { turn: locatedTurn }
|
||||
return { turn: lastAssistantTurn ?? 1 }
|
||||
}
|
||||
|
||||
function indexFollowingAssistants(
|
||||
nodes: ConversationSnapshot['nodes'],
|
||||
): readonly (AssistantMessageNode | undefined)[] {
|
||||
const following = new Array<AssistantMessageNode | undefined>(nodes.length)
|
||||
let assistant: AssistantMessageNode | undefined
|
||||
for (let index = nodes.length - 1; index >= 0; index--) {
|
||||
following[index] = assistant
|
||||
const node = nodes[index]
|
||||
if (node?.kind === 'assistant') assistant = node
|
||||
}
|
||||
return following
|
||||
}
|
||||
|
||||
function enclosingPromptTurn(
|
||||
nodes: ConversationSnapshot['nodes'],
|
||||
seq: number,
|
||||
@@ -919,6 +999,7 @@ function expandSubCalls(
|
||||
let index = startIndex
|
||||
for (const sub of subs) {
|
||||
const settled = 'kind' in sub
|
||||
const resultPreview = settled ? summarizeResult(sub) : undefined
|
||||
const laid: LaidCell = {
|
||||
absTime: settled ? finiteTime(sub.callTime ?? sub.time) : finiteTime(sub.time),
|
||||
toolName: settled ? sub.call?.name ?? sub.callId : sub.name,
|
||||
@@ -927,9 +1008,11 @@ function expandSubCalls(
|
||||
index: ++index,
|
||||
kind: 'subtool',
|
||||
callId: sub.callId,
|
||||
text: settled
|
||||
? (sub.call !== null ? summarizeCall(sub.call.name, sub.call.argsRaw) : summarizeResult(sub))
|
||||
: summarizeCall(sub.name, sub.argsRaw),
|
||||
...(settled
|
||||
? (sub.call !== null
|
||||
? summarizeCall(sub.call.name, sub.call.argsRaw)
|
||||
: resultAsText(resultPreview))
|
||||
: summarizeCall(sub.name, sub.argsRaw)),
|
||||
...(settled
|
||||
? (sub.call !== null ? { inputDetail: sub.call.argsRaw } : {})
|
||||
: { inputDetail: sub.argsRaw }),
|
||||
@@ -937,7 +1020,7 @@ function expandSubCalls(
|
||||
? {
|
||||
outputDetail: detailResult(sub),
|
||||
outputBlocks: sub.content.map(block => sourceBlock(block)),
|
||||
result: summarizeResult(sub),
|
||||
...resultPreview,
|
||||
isError: sub.isError,
|
||||
}
|
||||
: {}),
|
||||
@@ -958,22 +1041,39 @@ function expandSubCalls(
|
||||
return out
|
||||
}
|
||||
|
||||
function summarizeCall(name: string, argsRaw: string): string {
|
||||
const args = trajectoryPreviewText(argsRaw)
|
||||
if (args === '') return name
|
||||
return `${name} · ${args}`
|
||||
function summarizeCall(
|
||||
name: string,
|
||||
argsRaw: string,
|
||||
): Pick<TrajectoryCellProps, 'text' | 'previewMarkdown'> {
|
||||
return {
|
||||
text: name,
|
||||
...(argsRaw === '' ? {} : { previewMarkdown: argsRaw }),
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeResult(node: ToolResultNode): string {
|
||||
function summarizeResult(
|
||||
node: ToolResultNode,
|
||||
): Pick<TrajectoryCellProps, 'result' | 'resultPreviewMarkdown'> {
|
||||
if (node.isError) {
|
||||
return node.error?.code ?? 'error'
|
||||
return { result: node.error?.code ?? 'error' }
|
||||
}
|
||||
for (const block of node.content) {
|
||||
if (block.type === 'text' && typeof block.text === 'string' && block.text !== '') {
|
||||
return summarizeText(block.text)
|
||||
return { result: '', resultPreviewMarkdown: block.text }
|
||||
}
|
||||
}
|
||||
return 'No output'
|
||||
return { result: 'No output' }
|
||||
}
|
||||
|
||||
function resultAsText(
|
||||
result: Pick<TrajectoryCellProps, 'result' | 'resultPreviewMarkdown'> | undefined,
|
||||
): Pick<TrajectoryCellProps, 'text' | 'previewMarkdown'> {
|
||||
return {
|
||||
text: result?.result ?? '',
|
||||
...(result?.resultPreviewMarkdown === undefined
|
||||
? {}
|
||||
: { previewMarkdown: result.resultPreviewMarkdown }),
|
||||
}
|
||||
}
|
||||
|
||||
function detailResult(node: ToolResultNode): string {
|
||||
@@ -1009,28 +1109,18 @@ function detailReasoning(content: readonly { type: string; text?: string }[]): s
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function summarizeContent(content: readonly { type: string; text?: string }[]): string {
|
||||
function previewContent(
|
||||
content: readonly { type: string; text?: string }[],
|
||||
): string | undefined {
|
||||
for (const block of content) {
|
||||
if (block.type === 'text' && typeof block.text === 'string') return summarizeText(block.text)
|
||||
if (block.type === 'text' && typeof block.text === 'string') return block.text
|
||||
}
|
||||
return ''
|
||||
return undefined
|
||||
}
|
||||
|
||||
function summarizeText(text: string): string {
|
||||
return trajectoryPreviewText(text)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a bounded one-line ledger preview without parsing the complete Markdown document.
|
||||
* Full source remains on the cell for the inspector.
|
||||
* @param text - Untrusted message, reasoning, payload, or result text.
|
||||
* @returns A compact preview capped independently from the retained source.
|
||||
*/
|
||||
export function trajectoryPreviewText(text: string): string {
|
||||
const source = text.slice(0, PREVIEW_SOURCE_CHARACTERS)
|
||||
const compact = extractMarkdownPlainText(source).replace(/\s+/g, ' ').trim()
|
||||
const preview = compact.slice(0, PREVIEW_OUTPUT_CHARACTERS).trimEnd()
|
||||
return source.length < text.length || preview.length < compact.length
|
||||
? `${preview}…`
|
||||
: preview
|
||||
function previewContentProperty(
|
||||
content: readonly { type: string; text?: string }[],
|
||||
): Pick<TrajectoryCellProps, 'previewMarkdown'> {
|
||||
const previewMarkdown = previewContent(content)
|
||||
return previewMarkdown === undefined ? {} : { previewMarkdown }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
AssistantBlock, AssistantMessageNode, ConversationLocation, ConversationMatch,
|
||||
ConversationNodeContext, ConversationNodeDefinition, PartialAssistant, RequestView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
displayFailureMessage, emptyAssistantBlock, isTokenDelta, toAssistantBlock,
|
||||
toAssistantBlocks,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { trajectoryNode } from './trajectory-definition-common.ts'
|
||||
|
||||
/* jscpd:ignore-start -- Target-owned Definitions intentionally keep their event
|
||||
* state machines independent; see ../../../../../.agents/notes/implemented/
|
||||
* architecture/2026-08-09-client-conversation-node-assembly.md. */
|
||||
interface UsageValue {
|
||||
readonly inputTokens: number
|
||||
readonly outputTokens: number
|
||||
readonly cacheReadTokens?: number
|
||||
readonly cacheWriteTokens?: number
|
||||
readonly reasoningTokens?: number
|
||||
}
|
||||
|
||||
interface RetryValue {
|
||||
readonly message: string
|
||||
readonly retry: number
|
||||
readonly maxRetries?: number
|
||||
readonly delayMs: number
|
||||
}
|
||||
|
||||
interface AssistantState {
|
||||
readonly turn: number
|
||||
readonly step: number
|
||||
readonly startSeq: number
|
||||
readonly startTime: number
|
||||
readonly started: boolean
|
||||
readonly sawChunk: boolean
|
||||
readonly blocks: readonly (AssistantBlock | undefined)[]
|
||||
readonly firstVisibleSeq: number | undefined
|
||||
readonly firstVisibleTime: number | undefined
|
||||
readonly firstTokenTime: number | undefined
|
||||
readonly final: ConversationMatch | undefined
|
||||
readonly usage: UsageValue | undefined
|
||||
readonly retry: RetryValue | undefined
|
||||
readonly stepEnd: ConversationMatch | undefined
|
||||
}
|
||||
|
||||
function initialState(
|
||||
turn: number,
|
||||
step: number,
|
||||
startSeq: number,
|
||||
startTime: number,
|
||||
started: boolean,
|
||||
): AssistantState {
|
||||
return {
|
||||
turn,
|
||||
step,
|
||||
startSeq,
|
||||
startTime,
|
||||
started,
|
||||
sawChunk: false,
|
||||
blocks: [],
|
||||
firstVisibleSeq: undefined,
|
||||
firstVisibleTime: undefined,
|
||||
firstTokenTime: undefined,
|
||||
final: undefined,
|
||||
usage: undefined,
|
||||
retry: undefined,
|
||||
stepEnd: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function compactBlocks(blocks: readonly (AssistantBlock | undefined)[]): AssistantBlock[] {
|
||||
return blocks.filter((block): block is AssistantBlock => block !== undefined)
|
||||
}
|
||||
|
||||
function hasVisibleContent(blocks: readonly AssistantBlock[]): boolean {
|
||||
return blocks.some((block) => {
|
||||
if (block.kind === 'tool-call') return false
|
||||
if (block.kind === 'text' || block.kind === 'reasoning') return block.text.trim() !== ''
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function hasInterruptionEvidence(blocks: readonly AssistantBlock[]): boolean {
|
||||
return blocks.some((block) => {
|
||||
if (block.kind === 'text' || block.kind === 'reasoning') return block.text.trim() !== ''
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function addUsage(current: UsageValue | undefined, next: UsageValue): UsageValue {
|
||||
return {
|
||||
inputTokens: (current?.inputTokens ?? 0) + next.inputTokens,
|
||||
outputTokens: (current?.outputTokens ?? 0) + next.outputTokens,
|
||||
...(current?.cacheReadTokens === undefined && next.cacheReadTokens === undefined
|
||||
? {}
|
||||
: { cacheReadTokens: (current?.cacheReadTokens ?? 0) + (next.cacheReadTokens ?? 0) }),
|
||||
...(current?.cacheWriteTokens === undefined && next.cacheWriteTokens === undefined
|
||||
? {}
|
||||
: { cacheWriteTokens: (current?.cacheWriteTokens ?? 0) + (next.cacheWriteTokens ?? 0) }),
|
||||
...(current?.reasoningTokens === undefined && next.reasoningTokens === undefined
|
||||
? {}
|
||||
: { reasoningTokens: (current?.reasoningTokens ?? 0) + (next.reasoningTokens ?? 0) }),
|
||||
}
|
||||
}
|
||||
|
||||
function updateChunk(state: AssistantState, match: ConversationMatch): AssistantState {
|
||||
if (match.event.type !== 'assistant/chunk') return state
|
||||
const chunk = match.event.data.chunk
|
||||
if (chunk.type === 'usage') {
|
||||
return { ...state, sawChunk: true, usage: addUsage(state.usage, chunk.usage) }
|
||||
}
|
||||
const blocks = [...state.blocks]
|
||||
switch (chunk.type) {
|
||||
case 'block-start':
|
||||
blocks[chunk.index] = emptyAssistantBlock(chunk.blockType)
|
||||
break
|
||||
case 'text-delta': {
|
||||
const previous = blocks[chunk.index]
|
||||
blocks[chunk.index] = {
|
||||
kind: 'text',
|
||||
text: (previous?.kind === 'text' ? previous.text : '') + chunk.text,
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'reasoning-delta': {
|
||||
const previous = blocks[chunk.index]
|
||||
blocks[chunk.index] = {
|
||||
kind: 'reasoning',
|
||||
text: (previous?.kind === 'reasoning' ? previous.text : '') + chunk.text,
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'tool-call-delta': {
|
||||
const previous = blocks[chunk.index]
|
||||
const base = previous?.kind === 'tool-call'
|
||||
? previous
|
||||
: { kind: 'tool-call' as const, callId: '', name: '', argsRaw: '' }
|
||||
blocks[chunk.index] = {
|
||||
kind: 'tool-call',
|
||||
callId: base.callId || String(chunk.id),
|
||||
name: chunk.name ?? base.name,
|
||||
argsRaw: base.argsRaw + chunk.argumentsDelta,
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'block-end':
|
||||
blocks[chunk.index] = toAssistantBlock(chunk.block)
|
||||
break
|
||||
default:
|
||||
return { ...state, sawChunk: true }
|
||||
}
|
||||
const visible = hasVisibleContent(compactBlocks(blocks))
|
||||
return {
|
||||
...state,
|
||||
sawChunk: true,
|
||||
blocks,
|
||||
...(visible && state.firstVisibleSeq === undefined
|
||||
? { firstVisibleSeq: match.event.seq, firstVisibleTime: match.event.time }
|
||||
: {}),
|
||||
...(isTokenDelta(chunk) && state.firstTokenTime === undefined
|
||||
? { firstTokenTime: match.event.time }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
function closedBoundary(
|
||||
context: ConversationNodeContext<AssistantState>,
|
||||
): { seq: number; time: number } | undefined {
|
||||
if (context.state?.stepEnd?.event.type === 'step/end') return context.state.stepEnd.event
|
||||
const location: ConversationLocation | undefined = context.start?.location
|
||||
?? context.matches.at(-1)?.location
|
||||
if (location?.kind === 'step' && location.step.status === 'closed') return location.step.end
|
||||
if ((location?.kind === 'step' || location?.kind === 'turn')
|
||||
&& location.turn.status === 'closed') return location.turn.end
|
||||
return undefined
|
||||
}
|
||||
|
||||
function fallbackState(context: ConversationNodeContext<AssistantState>): AssistantState | undefined {
|
||||
let state: AssistantState | undefined
|
||||
for (const match of context.matches) {
|
||||
const event = match.event
|
||||
if (event.type === 'assistant/chunk') {
|
||||
state ??= initialState(event.data.turn, event.data.step, event.seq, event.time, false)
|
||||
state = updateChunk(state, match)
|
||||
} else if (event.type === 'assistant/message') {
|
||||
state ??= initialState(event.data.turn, event.data.step, event.seq, event.time, false)
|
||||
state = {
|
||||
...state,
|
||||
blocks: toAssistantBlocks(event.data.message.content),
|
||||
final: match,
|
||||
usage: state.usage ?? event.data.usage,
|
||||
}
|
||||
} else if (event.type === 'step/end' && state !== undefined) {
|
||||
state = { ...state, stepEnd: match }
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
function finalNode(
|
||||
state: AssistantState,
|
||||
context: ConversationNodeContext<AssistantState>,
|
||||
): AssistantMessageNode | undefined {
|
||||
const final = state.final
|
||||
if (final?.event.type === 'assistant/message') {
|
||||
const event = final.event
|
||||
return {
|
||||
kind: 'assistant',
|
||||
seq: event.seq,
|
||||
time: event.time,
|
||||
turn: state.turn,
|
||||
step: state.step,
|
||||
blocks: toAssistantBlocks(event.data.message.content),
|
||||
usage: event.data.usage,
|
||||
provenance: {
|
||||
provider: event.data.message.source.provider,
|
||||
model: event.data.message.source.model,
|
||||
},
|
||||
timing: {
|
||||
stepStartTime: state.started ? state.startTime : null,
|
||||
firstTokenTime: state.firstTokenTime ?? null,
|
||||
completedTime: event.time,
|
||||
},
|
||||
}
|
||||
}
|
||||
const boundary = closedBoundary(context)
|
||||
const blocks = compactBlocks(state.blocks)
|
||||
if (boundary === undefined || !hasInterruptionEvidence(blocks)) return undefined
|
||||
return {
|
||||
kind: 'assistant',
|
||||
seq: boundary.seq - 0.9,
|
||||
time: boundary.time,
|
||||
turn: state.turn,
|
||||
step: state.step,
|
||||
blocks,
|
||||
interrupted: true,
|
||||
}
|
||||
}
|
||||
|
||||
function assistantRequest(
|
||||
state: AssistantState,
|
||||
node: AssistantMessageNode | undefined,
|
||||
boundary: { seq: number; time: number } | undefined,
|
||||
): Extract<RequestView, { purpose: 'assistant' }> | undefined {
|
||||
if (!state.started) return undefined
|
||||
const status = node !== undefined && node.interrupted !== true
|
||||
? 'complete'
|
||||
: state.retry !== undefined || boundary !== undefined ? 'error' : 'running'
|
||||
return {
|
||||
purpose: 'assistant',
|
||||
startSeq: state.startSeq,
|
||||
turn: state.turn,
|
||||
step: state.step,
|
||||
startedAt: state.startTime,
|
||||
completedAt: node?.time ?? boundary?.time ?? null,
|
||||
status,
|
||||
...(state.retry === undefined
|
||||
? {}
|
||||
: {
|
||||
error: state.retry.message,
|
||||
retry: state.retry.retry,
|
||||
...(state.retry.maxRetries === undefined ? {} : { maxRetries: state.retry.maxRetries }),
|
||||
retryDelayMs: state.retry.delayMs,
|
||||
}),
|
||||
...(node === undefined || node.interrupted === true
|
||||
? {}
|
||||
: {
|
||||
resultSeq: node.seq,
|
||||
...(node.provenance === undefined ? {} : { provenance: node.provenance }),
|
||||
}),
|
||||
...(state.usage === undefined ? {} : { usage: state.usage }),
|
||||
}
|
||||
}
|
||||
|
||||
/** Trajectory-owned Assistant streaming, settlement, and request lifecycle. */
|
||||
const trajectoryAssistantDefinition: ConversationNodeDefinition<AssistantState> = {
|
||||
kind: 'trajectory-assistant-step',
|
||||
target: 'trajectory',
|
||||
match: (event) => {
|
||||
if (event.type === 'step/start') {
|
||||
return { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
|
||||
}
|
||||
if (event.type === 'assistant/chunk'
|
||||
|| event.type === 'assistant/message'
|
||||
|| event.type === 'llm/retry'
|
||||
|| event.type === 'step/end') {
|
||||
return { id: `${event.data.turn}:${event.data.step}`, role: 'update' }
|
||||
}
|
||||
return null
|
||||
},
|
||||
start: (_context, match) => {
|
||||
if (match.event.type !== 'step/start') {
|
||||
throw new Error('trajectory-assistant-step start requires step/start')
|
||||
}
|
||||
return initialState(
|
||||
match.event.data.turn,
|
||||
match.event.data.step,
|
||||
match.event.seq,
|
||||
match.event.time,
|
||||
true,
|
||||
)
|
||||
},
|
||||
update: (context, match) => {
|
||||
if (match.event.type === 'assistant/chunk') return updateChunk(context.state, match)
|
||||
if (match.event.type === 'assistant/message') {
|
||||
return {
|
||||
...context.state,
|
||||
blocks: toAssistantBlocks(match.event.data.message.content),
|
||||
final: match,
|
||||
usage: context.state.usage ?? match.event.data.usage,
|
||||
}
|
||||
}
|
||||
if (match.event.type === 'step/end') return { ...context.state, stepEnd: match }
|
||||
if (match.event.type !== 'llm/retry') return context.state
|
||||
const data = match.event.data
|
||||
return {
|
||||
...initialState(
|
||||
context.state.turn,
|
||||
context.state.step,
|
||||
context.state.startSeq,
|
||||
context.state.startTime,
|
||||
true,
|
||||
),
|
||||
firstTokenTime: context.state.firstTokenTime,
|
||||
usage: context.state.usage,
|
||||
retry: {
|
||||
message: displayFailureMessage(data.failure),
|
||||
retry: data.retry,
|
||||
...(data.mode === 'normal' ? { maxRetries: data.maxRetries } : {}),
|
||||
delayMs: data.delayMs,
|
||||
},
|
||||
}
|
||||
},
|
||||
publication: (match) => {
|
||||
if (match.event.type === 'step/start') return 'none'
|
||||
if (match.event.type !== 'assistant/chunk') return 'immediate'
|
||||
const type = match.event.data.chunk.type
|
||||
return type === 'usage' || type === 'finish' ? 'none' : 'animation-frame'
|
||||
},
|
||||
buildViewNode: (context) => {
|
||||
const state = context.state ?? fallbackState(context)
|
||||
if (state === undefined) return null
|
||||
const node = finalNode(state, context)
|
||||
const boundary = closedBoundary(context)
|
||||
const partial: PartialAssistant | null = node === undefined && boundary === undefined && state.sawChunk
|
||||
? { turn: state.turn, step: state.step, blocks: compactBlocks(state.blocks) }
|
||||
: null
|
||||
const request = assistantRequest(state, node, boundary)
|
||||
if (node === undefined && partial === null && request === undefined) return null
|
||||
return trajectoryNode(context, state.startSeq, {
|
||||
kind: 'assistant',
|
||||
...(node === undefined ? {} : { node }),
|
||||
partial,
|
||||
...(request === undefined ? {} : { request }),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
interface TurnEndState {
|
||||
readonly turn: number
|
||||
readonly seq: number
|
||||
readonly time: number
|
||||
readonly error?: string
|
||||
}
|
||||
|
||||
const trajectoryTurnEndDefinition: ConversationNodeDefinition<TurnEndState> = {
|
||||
kind: 'trajectory-turn-end',
|
||||
target: 'trajectory',
|
||||
match: event => event.type === 'turn/end'
|
||||
? { id: String(event.seq), role: 'start' }
|
||||
: null,
|
||||
start: (_context, match) => {
|
||||
if (match.event.type !== 'turn/end') {
|
||||
throw new Error('trajectory-turn-end start requires turn/end')
|
||||
}
|
||||
const reason = match.event.data.reason
|
||||
return {
|
||||
turn: match.event.data.turn,
|
||||
seq: match.event.seq,
|
||||
time: match.event.time,
|
||||
...(reason.kind === 'error' ? { error: displayFailureMessage(reason.error) } : {}),
|
||||
}
|
||||
},
|
||||
update: context => context.state,
|
||||
buildViewNode: context => context.state === undefined
|
||||
? null
|
||||
: trajectoryNode(context, context.state.seq, {
|
||||
kind: 'turn-end',
|
||||
turn: context.state.turn,
|
||||
time: context.state.time,
|
||||
...(context.state.error === undefined ? {} : { error: context.state.error }),
|
||||
}),
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/**
|
||||
* Register the Trajectory Assistant lifecycle.
|
||||
*
|
||||
* @param ctx - Plugin context receiving the Definitions.
|
||||
*/
|
||||
export function registerTrajectoryAssistantDefinition(ctx: Context): void {
|
||||
ctx.conversationEvents.register(trajectoryAssistantDefinition)
|
||||
ctx.conversationEvents.register(trajectoryTurnEndDefinition)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
ConversationMatch, ConversationNodeDefinition, RequestView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {} from '@deepseek-ai/dsh-compact/types'
|
||||
import { trajectoryNode } from './trajectory-definition-common.ts'
|
||||
|
||||
interface CompactionState {
|
||||
readonly start: ConversationMatch
|
||||
readonly summary?: ConversationMatch
|
||||
readonly end?: ConversationMatch
|
||||
readonly checkpoint?: ConversationMatch
|
||||
}
|
||||
|
||||
function checkpointId(
|
||||
event: Parameters<ConversationNodeDefinition['match']>[0],
|
||||
): string | undefined {
|
||||
if (event.type !== 'user/message') return undefined
|
||||
const source = event.data.source as unknown as {
|
||||
readonly kind?: unknown
|
||||
readonly plugin?: unknown
|
||||
readonly compactionId?: unknown
|
||||
}
|
||||
return source.kind === 'plugin' && source.plugin === 'compact'
|
||||
&& typeof source.compactionId === 'string' && source.compactionId !== ''
|
||||
? source.compactionId
|
||||
: undefined
|
||||
}
|
||||
|
||||
function eventCompactionId(
|
||||
event: Parameters<ConversationNodeDefinition['match']>[0],
|
||||
): string | undefined {
|
||||
if (event.type !== 'compact/start'
|
||||
&& event.type !== 'compact/summary'
|
||||
&& event.type !== 'compact/end') return undefined
|
||||
const value: unknown = event.data.compactionId
|
||||
return typeof value === 'string' && value !== '' ? value : undefined
|
||||
}
|
||||
|
||||
function requestFromState(
|
||||
state: CompactionState,
|
||||
): Extract<RequestView, { purpose: 'compaction' }> | undefined {
|
||||
const start = state.start.event
|
||||
if (start.type !== 'compact/start') return undefined
|
||||
const summary = state.summary?.event
|
||||
const end = state.end?.event
|
||||
const checkpoint = state.checkpoint?.event
|
||||
return {
|
||||
purpose: 'compaction',
|
||||
startSeq: start.seq,
|
||||
turn: start.data.turn,
|
||||
step: 0,
|
||||
startedAt: start.time,
|
||||
completedAt: end?.type === 'compact/end' ? end.time : null,
|
||||
status: end?.type !== 'compact/end'
|
||||
? 'running'
|
||||
: end.data.error === undefined ? 'complete' : 'error',
|
||||
...(end?.type === 'compact/end' && end.data.error !== undefined
|
||||
? { error: end.data.error }
|
||||
: {}),
|
||||
...(summary?.type !== 'compact/summary'
|
||||
? {}
|
||||
: {
|
||||
resultSeq: summary.seq,
|
||||
summary: summary.data.summary,
|
||||
...(summary.data.rawOutput === undefined ? {} : { rawOutput: summary.data.rawOutput }),
|
||||
provenance: { provider: summary.data.provider, model: summary.data.model },
|
||||
requestConfig: {
|
||||
provider: summary.data.provider,
|
||||
model: summary.data.model,
|
||||
purpose: 'compaction',
|
||||
...(summary.data.maxTokens === undefined ? {} : { maxTokens: summary.data.maxTokens }),
|
||||
},
|
||||
...(summary.data.usage === undefined ? {} : { usage: summary.data.usage }),
|
||||
}),
|
||||
...(checkpoint?.type === 'user/message' ? { replacementSeq: checkpoint.seq } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
const trajectoryCompactionDefinition: ConversationNodeDefinition<CompactionState> = {
|
||||
kind: 'trajectory-compaction',
|
||||
target: 'trajectory',
|
||||
match: (event) => {
|
||||
const compactId = eventCompactionId(event)
|
||||
if (compactId !== undefined) {
|
||||
return { id: compactId, role: event.type === 'compact/start' ? 'start' : 'update' }
|
||||
}
|
||||
const checkpoint = checkpointId(event)
|
||||
return checkpoint === undefined ? null : { id: checkpoint, role: 'update' }
|
||||
},
|
||||
start: (_context, match) => {
|
||||
if (match.event.type !== 'compact/start') {
|
||||
throw new Error('trajectory-compaction start requires compact/start')
|
||||
}
|
||||
return { start: match }
|
||||
},
|
||||
update: (context, match) => {
|
||||
if (match.event.type === 'compact/summary') return { ...context.state, summary: match }
|
||||
if (match.event.type === 'compact/end') return { ...context.state, end: match }
|
||||
return checkpointId(match.event) === undefined
|
||||
? context.state
|
||||
: { ...context.state, checkpoint: match }
|
||||
},
|
||||
buildViewNode: (context) => {
|
||||
if (context.state === undefined) return null
|
||||
const request = requestFromState(context.state)
|
||||
return request === undefined
|
||||
? null
|
||||
: trajectoryNode(context, request.startSeq, { kind: 'compaction', request })
|
||||
},
|
||||
}
|
||||
|
||||
interface SessionEndState {
|
||||
readonly seq: number
|
||||
readonly time: number
|
||||
}
|
||||
|
||||
const trajectorySessionEndDefinition: ConversationNodeDefinition<SessionEndState> = {
|
||||
kind: 'trajectory-session-end',
|
||||
target: 'trajectory',
|
||||
match: event => event.type === 'session/end-seed'
|
||||
? { id: String(event.seq), role: 'start' }
|
||||
: null,
|
||||
start: (_context, match) => ({ seq: match.event.seq, time: match.event.time }),
|
||||
update: context => context.state,
|
||||
buildViewNode: context => context.state === undefined
|
||||
? null
|
||||
: trajectoryNode(context, context.state.seq, {
|
||||
kind: 'session-end',
|
||||
seq: context.state.seq,
|
||||
time: context.state.time,
|
||||
}),
|
||||
}
|
||||
|
||||
/**
|
||||
* Register Trajectory compaction requests and session boundaries.
|
||||
*
|
||||
* @param ctx - Plugin context receiving the Definitions.
|
||||
*/
|
||||
export function registerTrajectoryCompactionDefinitions(ctx: Context): void {
|
||||
ctx.conversationEvents.register(trajectoryCompactionDefinition)
|
||||
ctx.conversationEvents.register(trajectorySessionEndDefinition)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type {
|
||||
AssistantMessageNode, ConversationLocation, ConversationNode,
|
||||
ConversationPromptSnapshot, ConversationViewNode, PartialAssistant,
|
||||
RequestPromptChange, RequestView, RunningToolCall, ToolCallBlock,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** Request-header facts retained by the Trajectory target. */
|
||||
export interface TrajectoryRequestHeaderState {
|
||||
readonly seq: number
|
||||
readonly time: number
|
||||
readonly prompt: ConversationPromptSnapshot
|
||||
readonly change?: RequestPromptChange
|
||||
readonly location: ConversationLocation
|
||||
}
|
||||
|
||||
/** One independently assembled contribution to the legacy Trajectory ledger. */
|
||||
export type TrajectoryContribution =
|
||||
| {
|
||||
readonly kind: 'node'
|
||||
readonly node: ConversationNode
|
||||
}
|
||||
| {
|
||||
readonly kind: 'assistant'
|
||||
readonly node?: AssistantMessageNode
|
||||
readonly partial: PartialAssistant | null
|
||||
readonly request?: Extract<RequestView, { purpose: 'assistant' }>
|
||||
}
|
||||
| {
|
||||
readonly kind: 'tool'
|
||||
readonly root: ToolCallBlock
|
||||
}
|
||||
| {
|
||||
readonly kind: 'request-header'
|
||||
readonly header: TrajectoryRequestHeaderState
|
||||
}
|
||||
| {
|
||||
readonly kind: 'compaction'
|
||||
readonly request: Extract<RequestView, { purpose: 'compaction' }>
|
||||
}
|
||||
| {
|
||||
readonly kind: 'session-end'
|
||||
readonly seq: number
|
||||
readonly time: number
|
||||
}
|
||||
| {
|
||||
readonly kind: 'turn-end'
|
||||
readonly turn: number
|
||||
readonly time: number
|
||||
readonly error?: string
|
||||
}
|
||||
|
||||
/** Target envelope consumed by the Trajectory snapshot builder. */
|
||||
export interface TrajectoryConversationViewNode extends ConversationViewNode {
|
||||
readonly target: 'trajectory'
|
||||
readonly anchorSeq: number
|
||||
readonly location: ConversationLocation
|
||||
readonly data: TrajectoryContribution
|
||||
}
|
||||
|
||||
/** Stage-oriented Trajectory data assembled from registered business Contexts. */
|
||||
export interface TrajectorySnapshot {
|
||||
readonly eventNodes: readonly ConversationNode[]
|
||||
readonly eventLocations: ReadonlyMap<number, ConversationLocation>
|
||||
readonly requests: readonly RequestView[]
|
||||
readonly callSchemas: ReadonlyMap<string, ConversationPromptSnapshot['tools'][number]>
|
||||
readonly partial: PartialAssistant | null
|
||||
readonly runningCalls: readonly RunningToolCall[]
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-runtime/client' {
|
||||
interface ConversationViewSnapshotMap {
|
||||
/** Independently assembled data consumed by the Trajectory view. */
|
||||
trajectory: TrajectorySnapshot
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ConversationNodeContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
TrajectoryContribution, TrajectoryConversationViewNode,
|
||||
} from './trajectory-contract.ts'
|
||||
|
||||
/**
|
||||
* Wrap one contribution in the Engine-owned target envelope.
|
||||
*
|
||||
* @param context - Context that owns the contribution identity.
|
||||
* @param anchorSeq - Sequence used to order the contribution.
|
||||
* @param data - Trajectory-specific contribution payload.
|
||||
* @returns The contribution wrapped as a Trajectory view node.
|
||||
*/
|
||||
export function trajectoryNode(
|
||||
context: ConversationNodeContext,
|
||||
anchorSeq: number,
|
||||
data: TrajectoryContribution,
|
||||
): TrajectoryConversationViewNode {
|
||||
return {
|
||||
key: context.key,
|
||||
kind: context.kind,
|
||||
id: context.id,
|
||||
target: 'trajectory',
|
||||
anchorSeq,
|
||||
location: context.start?.location ?? { kind: 'unresolved' },
|
||||
data,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
ContextMessageNode, ConversationNodeDefinition, ConversationPreviousContext,
|
||||
SteeringMessageNode, UserMessageNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
contextForm, contextProvenance,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {} from '@deepseek-ai/dsh-agent/types'
|
||||
import { trajectoryNode } from './trajectory-definition-common.ts'
|
||||
|
||||
/* jscpd:ignore-start -- Target-owned Definitions intentionally keep their event
|
||||
* state machines independent; see ../../../../../.agents/notes/implemented/
|
||||
* architecture/2026-08-09-client-conversation-node-assembly.md. */
|
||||
interface InboxIdentity {
|
||||
readonly id: string
|
||||
}
|
||||
|
||||
interface InboxSplice {
|
||||
readonly start: number
|
||||
readonly removedCount?: number
|
||||
readonly inserted: readonly InboxIdentity[]
|
||||
readonly outcome?: 'canceled'
|
||||
}
|
||||
|
||||
interface InboxState {
|
||||
readonly pending: readonly InboxIdentity[]
|
||||
readonly claimed: ReadonlySet<string>
|
||||
}
|
||||
|
||||
type MessageNode = UserMessageNode | SteeringMessageNode | ContextMessageNode
|
||||
|
||||
function applySplice(
|
||||
previous: ConversationPreviousContext<InboxState> | undefined,
|
||||
splice: InboxSplice,
|
||||
): InboxState {
|
||||
const pending = [...(previous?.state.pending ?? [])]
|
||||
const claimed = new Set(previous?.state.claimed ?? [])
|
||||
const removed = pending.splice(splice.start, splice.removedCount ?? 0, ...splice.inserted)
|
||||
for (const identity of splice.inserted) claimed.delete(identity.id)
|
||||
if (splice.outcome !== 'canceled') {
|
||||
for (const identity of removed) claimed.add(identity.id)
|
||||
}
|
||||
return { pending, claimed }
|
||||
}
|
||||
|
||||
const trajectoryInboxDefinition: ConversationNodeDefinition<InboxState> = {
|
||||
kind: 'trajectory-inbox-next-step',
|
||||
match: event => event.type === 'agent/inbox/spliced'
|
||||
&& event.data.target === 'next-step'
|
||||
? { id: String(event.seq), role: 'start' }
|
||||
: null,
|
||||
start: (_context, match, reader) => {
|
||||
if (match.event.type !== 'agent/inbox/spliced') {
|
||||
throw new Error('trajectory-inbox-next-step start requires agent/inbox/spliced')
|
||||
}
|
||||
return applySplice(
|
||||
reader.previous<InboxState>('trajectory-inbox-next-step'),
|
||||
match.event.data,
|
||||
)
|
||||
},
|
||||
update: context => context.state,
|
||||
publication: () => 'none',
|
||||
}
|
||||
|
||||
const trajectoryMessageDefinition: ConversationNodeDefinition<MessageNode> = {
|
||||
kind: 'trajectory-input-message',
|
||||
target: 'trajectory',
|
||||
match: event => event.type === 'user/message'
|
||||
? { id: String(event.seq), role: 'start' }
|
||||
: null,
|
||||
start: (_context, match, reader) => {
|
||||
if (match.event.type !== 'user/message') {
|
||||
throw new Error('trajectory-input-message start requires user/message')
|
||||
}
|
||||
const event = match.event
|
||||
if (event.data.source.kind !== 'user') {
|
||||
return {
|
||||
kind: 'context',
|
||||
seq: event.seq,
|
||||
time: event.time,
|
||||
content: event.data.content,
|
||||
source: event.data.source,
|
||||
provenance: contextProvenance(event.data.source),
|
||||
form: contextForm(event.data.source),
|
||||
}
|
||||
}
|
||||
const claimed = reader.previous<InboxState>('trajectory-inbox-next-step')
|
||||
?.state.claimed.has(String(event.data.id)) === true
|
||||
return claimed
|
||||
? {
|
||||
kind: 'steering',
|
||||
messageId: event.data.id,
|
||||
seq: event.seq,
|
||||
time: event.time,
|
||||
content: event.data.content,
|
||||
source: event.data.source,
|
||||
}
|
||||
: {
|
||||
kind: 'user',
|
||||
seq: event.seq,
|
||||
time: event.time,
|
||||
content: event.data.content,
|
||||
source: event.data.source,
|
||||
}
|
||||
},
|
||||
update: context => context.state,
|
||||
buildViewNode: context => context.state === undefined
|
||||
? null
|
||||
: trajectoryNode(context, context.state.seq, { kind: 'node', node: context.state }),
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/**
|
||||
* Register Trajectory-owned inbox classification and message records.
|
||||
*
|
||||
* @param ctx - Plugin context receiving the Definitions.
|
||||
*/
|
||||
export function registerTrajectoryMessageDefinitions(ctx: Context): void {
|
||||
ctx.conversationEvents.register(trajectoryInboxDefinition)
|
||||
ctx.conversationEvents.register(trajectoryMessageDefinition)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/** Bounded Markdown-to-text projection shared by trajectory consumers. */
|
||||
|
||||
import { extractMarkdownPlainText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
|
||||
const PREVIEW_SOURCE_CHARACTERS = 2_048
|
||||
const PREVIEW_OUTPUT_CHARACTERS = 512
|
||||
|
||||
/**
|
||||
* Build a bounded one-line preview without parsing the complete Markdown document.
|
||||
* @param text - Untrusted message, reasoning, payload, or result text.
|
||||
* @returns A compact preview capped independently from the retained source.
|
||||
*/
|
||||
export function trajectoryPreviewText(text: string): string {
|
||||
const source = text.slice(0, PREVIEW_SOURCE_CHARACTERS)
|
||||
const compact = extractMarkdownPlainText(source).replace(/\s+/g, ' ').trim()
|
||||
const preview = compact.slice(0, PREVIEW_OUTPUT_CHARACTERS).trimEnd()
|
||||
return source.length < text.length || preview.length < compact.length
|
||||
? `${preview}…`
|
||||
: preview
|
||||
}
|
||||
@@ -40,8 +40,10 @@ export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/** Projection-stable identity when no single source event owns the record lifecycle. */
|
||||
recordId?: string
|
||||
kind: TrajectoryCellKind
|
||||
/** Single-line summary; CSS ellipsis when it overflows. */
|
||||
/** Non-Markdown summary or prefix; CSS ellipsis when it overflows. */
|
||||
text: string
|
||||
/** Raw Markdown source converted into the single-line summary at its consumer. */
|
||||
previewMarkdown?: string
|
||||
/** Whether this user record opens a new model turn. */
|
||||
opensTurn?: boolean
|
||||
/** Source session-event seq for cross-record navigation. */
|
||||
@@ -71,6 +73,8 @@ export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {
|
||||
assistantMetrics?: AssistantMetricDetail
|
||||
/** Tool-only result summary paired with the call in the same record. */
|
||||
result?: string
|
||||
/** Raw Markdown source converted into the tool-result summary at its consumer. */
|
||||
resultPreviewMarkdown?: string
|
||||
/** Tool call id used to link message source blocks to tool records. */
|
||||
callId?: string
|
||||
/** Tool-only result failure state. */
|
||||
@@ -112,7 +116,8 @@ export function trajectoryRecordId(cell: TrajectoryCellProps): string {
|
||||
*/
|
||||
export function formatDurationMillis(milliseconds: number | null): string {
|
||||
if (milliseconds === null || !Number.isFinite(milliseconds)) return '—'
|
||||
return `${Math.round(milliseconds).toLocaleString('en-US')} ms`
|
||||
const integer = String(Math.round(milliseconds))
|
||||
return `${integer.replace(/\B(?=(\d{3})+(?!\d))/g, ',')} ms`
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
ConversationMatch, ConversationNodeDefinition, ConversationPromptSnapshot,
|
||||
RequestPromptChange,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { trajectoryNode } from './trajectory-definition-common.ts'
|
||||
import type { TrajectoryRequestHeaderState } from './trajectory-contract.ts'
|
||||
|
||||
function requestPrompt(match: ConversationMatch): ConversationPromptSnapshot {
|
||||
if (match.event.type !== 'request/header') {
|
||||
throw new Error('trajectory-request-header start requires request/header')
|
||||
}
|
||||
const header = match.event.data.header
|
||||
const tools: unknown = header.tools
|
||||
return {
|
||||
config: header.config,
|
||||
system: header.system ?? '',
|
||||
tools: Array.isArray(tools) ? tools as ConversationPromptSnapshot['tools'] : [],
|
||||
}
|
||||
}
|
||||
|
||||
function promptChange(
|
||||
previous: ConversationPromptSnapshot | undefined,
|
||||
prompt: ConversationPromptSnapshot,
|
||||
match: ConversationMatch,
|
||||
): RequestPromptChange | undefined {
|
||||
if (match.event.type !== 'request/header') return undefined
|
||||
if (previous === undefined && match.event.data.reason !== 'initial') return undefined
|
||||
const systemChanged = previous !== undefined && previous.system !== prompt.system
|
||||
const toolsChanged = previous !== undefined
|
||||
&& JSON.stringify(previous.tools) !== JSON.stringify(prompt.tools)
|
||||
if (previous !== undefined && !systemChanged && !toolsChanged) return undefined
|
||||
return {
|
||||
seq: match.event.seq,
|
||||
time: match.event.time,
|
||||
kind: previous === undefined
|
||||
? 'initial'
|
||||
: systemChanged && toolsChanged
|
||||
? 'system-and-tools'
|
||||
: systemChanged ? 'system' : 'tools',
|
||||
...(previous === undefined ? {} : { previous }),
|
||||
}
|
||||
}
|
||||
|
||||
const trajectoryRequestHeaderDefinition: ConversationNodeDefinition<TrajectoryRequestHeaderState> = {
|
||||
kind: 'trajectory-request-header',
|
||||
target: 'trajectory',
|
||||
match: event => event.type === 'request/header'
|
||||
? { id: String(event.seq), role: 'start' }
|
||||
: null,
|
||||
start: (_context, match, reader) => {
|
||||
const prompt = requestPrompt(match)
|
||||
const previous = reader.previous<TrajectoryRequestHeaderState>('trajectory-request-header')
|
||||
?.state.prompt
|
||||
const change = promptChange(previous, prompt, match)
|
||||
return {
|
||||
seq: match.event.seq,
|
||||
time: match.event.time,
|
||||
prompt,
|
||||
location: match.location,
|
||||
...(change === undefined ? {} : { change }),
|
||||
}
|
||||
},
|
||||
update: context => context.state,
|
||||
buildViewNode: context => context.state === undefined
|
||||
? null
|
||||
: trajectoryNode(context, context.state.seq, {
|
||||
kind: 'request-header',
|
||||
header: context.state,
|
||||
}),
|
||||
}
|
||||
|
||||
/**
|
||||
* Register Trajectory request-header facts.
|
||||
*
|
||||
* @param ctx - Plugin context receiving the Definition.
|
||||
*/
|
||||
export function registerTrajectoryRequestHeaderDefinition(ctx: Context): void {
|
||||
ctx.conversationEvents.register(trajectoryRequestHeaderDefinition)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/** Incremental full-text index for the trajectory ledger. */
|
||||
|
||||
import type { TrajectoryTurnModel } from './layout.ts'
|
||||
import type { TrajectoryCellProps } from './trajectory-record.ts'
|
||||
import { trajectoryRecordId } from './trajectory-record.ts'
|
||||
import { trajectoryPreviewText } from './trajectory-preview.ts'
|
||||
|
||||
interface SearchEntry {
|
||||
readonly sources: readonly string[]
|
||||
readonly text: string
|
||||
}
|
||||
|
||||
function searchableJson(value: unknown): string {
|
||||
if (value === undefined) return ''
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function sameSources(left: readonly string[], right: readonly string[]): boolean {
|
||||
return left.length === right.length && left.every((value, index) => value === right[index])
|
||||
}
|
||||
|
||||
function markdownPreview(cell: TrajectoryCellProps): string {
|
||||
if (cell.previewMarkdown === undefined) return ''
|
||||
const preview = trajectoryPreviewText(cell.previewMarkdown)
|
||||
if (cell.text === '') return preview
|
||||
return preview === '' ? cell.text : `${cell.text} · ${preview}`
|
||||
}
|
||||
|
||||
function resultPreview(cell: TrajectoryCellProps): string {
|
||||
return cell.resultPreviewMarkdown === undefined
|
||||
? cell.result ?? ''
|
||||
: trajectoryPreviewText(cell.resultPreviewMarkdown)
|
||||
}
|
||||
|
||||
function recordSources(
|
||||
turn: number | null,
|
||||
group: string,
|
||||
cell: TrajectoryCellProps,
|
||||
): readonly string[] {
|
||||
const blocks = [
|
||||
...(cell.sourceBlocks ?? []),
|
||||
...(cell.outputBlocks ?? []),
|
||||
]
|
||||
return [
|
||||
turn === null ? 'between turns' : `turn ${turn}`,
|
||||
group,
|
||||
cell.kind,
|
||||
cell.kind === 'message' ? 'assistant' : '',
|
||||
cell.text,
|
||||
cell.previewMarkdown ?? '',
|
||||
cell.inputDetail ?? '',
|
||||
cell.outputDetail ?? '',
|
||||
cell.thinkingDetail ?? '',
|
||||
cell.schemaDetail ?? '',
|
||||
cell.result ?? '',
|
||||
cell.resultPreviewMarkdown ?? '',
|
||||
cell.callId ?? '',
|
||||
...blocks.flatMap(block => [
|
||||
block.type,
|
||||
block.content,
|
||||
block.callId ?? '',
|
||||
block.toolName ?? '',
|
||||
block.imageAlt ?? '',
|
||||
]),
|
||||
searchableJson(cell.messageSource),
|
||||
searchableJson(cell.promptDetail),
|
||||
searchableJson(cell.previousPromptDetail),
|
||||
]
|
||||
}
|
||||
|
||||
/** Session-view-local index that reparses Markdown only when one record's source changes. */
|
||||
export class TrajectorySearchIndex {
|
||||
private readonly entries = new Map<string, SearchEntry>()
|
||||
private layouts: readonly (readonly TrajectoryTurnModel[])[] | undefined
|
||||
|
||||
/**
|
||||
* Incrementally synchronize one or more current trajectory layout slices.
|
||||
* @param layouts - Finalized and optional streaming layouts from the same view.
|
||||
* @returns Whether the indexed layout version changed.
|
||||
*/
|
||||
update(layouts: readonly (readonly TrajectoryTurnModel[])[]): boolean {
|
||||
if (this.layouts === layouts) return false
|
||||
this.layouts = layouts
|
||||
const seen = new Set<string>()
|
||||
for (const turns of layouts) {
|
||||
for (const turn of turns) {
|
||||
for (const group of turn.groups) {
|
||||
for (const cell of group.cells) {
|
||||
if (cell.requestOnly === true) continue
|
||||
const id = trajectoryRecordId(cell)
|
||||
const sources = recordSources(turn.turn, group.title, cell)
|
||||
const previous = this.entries.get(id)
|
||||
const entry = previous !== undefined && sameSources(previous.sources, sources)
|
||||
? previous
|
||||
: {
|
||||
sources,
|
||||
text: [
|
||||
...sources,
|
||||
markdownPreview(cell),
|
||||
resultPreview(cell),
|
||||
].join('\n').toLocaleLowerCase(),
|
||||
}
|
||||
this.entries.set(id, entry)
|
||||
seen.add(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const id of this.entries.keys()) {
|
||||
if (!seen.has(id)) this.entries.delete(id)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a query against the latest committed index version.
|
||||
* @param query - Space-separated case-insensitive search terms.
|
||||
* @returns Matching stable record identities, or `null` without a query.
|
||||
*/
|
||||
search(query: string): ReadonlySet<string> | null {
|
||||
const terms = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean)
|
||||
if (terms.length === 0) return null
|
||||
const matches = new Set<string>()
|
||||
for (const [id, entry] of this.entries) {
|
||||
if (terms.every(term => entry.text.includes(term))) matches.add(id)
|
||||
}
|
||||
return matches
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
AssistantMessageNode, ConversationNode, ConversationPromptSnapshot,
|
||||
ConversationViewBuilder, ConversationViewDefinition, RequestView,
|
||||
ToolCallBlock,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
TrajectoryConversationViewNode, TrajectoryRequestHeaderState,
|
||||
TrajectorySnapshot,
|
||||
} from './trajectory-contract.ts'
|
||||
|
||||
const EMPTY_LIST: readonly never[] = []
|
||||
type AssistantRequest = Extract<RequestView, { purpose: 'assistant' }>
|
||||
type ToolSchema = ConversationPromptSnapshot['tools'][number]
|
||||
|
||||
/** Stable empty target used until a Session has assembled Trajectory records. */
|
||||
export const EMPTY_TRAJECTORY_SNAPSHOT: TrajectorySnapshot = {
|
||||
eventNodes: EMPTY_LIST,
|
||||
eventLocations: new Map(),
|
||||
requests: EMPTY_LIST,
|
||||
callSchemas: new Map(),
|
||||
partial: null,
|
||||
runningCalls: EMPTY_LIST,
|
||||
}
|
||||
|
||||
function stepKey(turn: number, step: number): string {
|
||||
return `${turn}\u0000${step}`
|
||||
}
|
||||
|
||||
function headerStepKey(header: TrajectoryRequestHeaderState): string | undefined {
|
||||
const location = header.location
|
||||
return location.kind === 'step'
|
||||
? stepKey(location.turn.turn, location.step.step)
|
||||
: undefined
|
||||
}
|
||||
|
||||
function headerFor(
|
||||
request: AssistantRequest,
|
||||
headersByStep: ReadonlyMap<string, TrajectoryRequestHeaderState>,
|
||||
previous: TrajectoryRequestHeaderState | undefined,
|
||||
): TrajectoryRequestHeaderState | undefined {
|
||||
return headersByStep.get(stepKey(request.turn, request.step))
|
||||
?? (previous !== undefined && previous.seq < request.startSeq ? previous : undefined)
|
||||
}
|
||||
|
||||
function applyHeader(
|
||||
request: AssistantRequest,
|
||||
header: TrajectoryRequestHeaderState | undefined,
|
||||
includeChange: boolean,
|
||||
): AssistantRequest {
|
||||
return header === undefined
|
||||
? request
|
||||
: {
|
||||
...request,
|
||||
prompt: header.prompt,
|
||||
requestConfig: header.prompt.config,
|
||||
...(includeChange && header.change !== undefined ? { promptChange: header.change } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function withRequestConfig(
|
||||
node: AssistantMessageNode,
|
||||
prompt: ConversationPromptSnapshot | undefined,
|
||||
): AssistantMessageNode {
|
||||
return prompt === undefined ? node : { ...node, requestConfig: prompt.config }
|
||||
}
|
||||
|
||||
function captureSchemas(
|
||||
block: ToolCallBlock,
|
||||
toolsByName: ReadonlyMap<string, ToolSchema>,
|
||||
output: Map<string, ToolSchema>,
|
||||
): void {
|
||||
const name = 'kind' in block ? block.call?.name : block.name
|
||||
const schema = name === undefined ? undefined : toolsByName.get(name)
|
||||
if (schema !== undefined) output.set(block.callId, schema)
|
||||
for (const child of block.subCalls) captureSchemas(child, toolsByName, output)
|
||||
}
|
||||
|
||||
function indexTools(tools: readonly ToolSchema[]): ReadonlyMap<string, ToolSchema> {
|
||||
return new Map(tools.map(tool => [tool.name, tool]))
|
||||
}
|
||||
|
||||
function interruptCompactions(
|
||||
requests: RequestView[],
|
||||
boundaries: readonly { seq: number; time: number }[],
|
||||
): void {
|
||||
let nextRequest = 0
|
||||
const runningCompactions: number[] = []
|
||||
for (const boundary of boundaries) {
|
||||
while (nextRequest < requests.length) {
|
||||
const request = requests[nextRequest]
|
||||
if (request === undefined || request.startSeq >= boundary.seq) break
|
||||
if (request.purpose === 'compaction' && request.status === 'running') {
|
||||
runningCompactions.push(nextRequest)
|
||||
}
|
||||
nextRequest++
|
||||
}
|
||||
let index = runningCompactions.pop()
|
||||
while (index !== undefined && requests[index]?.status !== 'running') {
|
||||
index = runningCompactions.pop()
|
||||
}
|
||||
if (index === undefined) continue
|
||||
const request = requests[index]
|
||||
if (request?.purpose !== 'compaction') continue
|
||||
requests[index] = {
|
||||
...request,
|
||||
completedAt: boundary.time,
|
||||
status: 'error',
|
||||
error: 'Compaction was interrupted before completion.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyTurnErrors(
|
||||
requests: RequestView[],
|
||||
endings: readonly { turn: number; time: number; error?: string }[],
|
||||
): void {
|
||||
const lastAssistantByTurn = new Map<number, number>()
|
||||
for (const [index, request] of requests.entries()) {
|
||||
if (request.purpose === 'assistant') lastAssistantByTurn.set(request.turn, index)
|
||||
}
|
||||
for (const ending of endings) {
|
||||
if (ending.error === undefined) continue
|
||||
const index = lastAssistantByTurn.get(ending.turn)
|
||||
if (index === undefined) continue
|
||||
const request = requests[index]
|
||||
if (request?.purpose !== 'assistant') continue
|
||||
requests[index] = {
|
||||
...request,
|
||||
completedAt: request.completedAt ?? ending.time,
|
||||
status: 'error',
|
||||
error: ending.error,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Simple keyed adapter retaining the old Trajectory snapshot and stage layout. */
|
||||
export class TrajectorySnapshotBuilder implements ConversationViewBuilder<
|
||||
TrajectoryConversationViewNode,
|
||||
TrajectorySnapshot
|
||||
> {
|
||||
private readonly nodes = new Map<string, TrajectoryConversationViewNode>()
|
||||
private readonly positions = new Map<string, number>()
|
||||
private contributions: TrajectoryConversationViewNode[] = []
|
||||
readonly empty = EMPTY_TRAJECTORY_SNAPSHOT
|
||||
|
||||
replace(input: {
|
||||
readonly nodes: readonly TrajectoryConversationViewNode[]
|
||||
}): TrajectorySnapshot {
|
||||
this.nodes.clear()
|
||||
for (const node of input.nodes) this.nodes.set(node.key, node)
|
||||
this.rebuildContributions()
|
||||
return this.snapshot()
|
||||
}
|
||||
|
||||
apply(input: {
|
||||
readonly upserts: readonly TrajectoryConversationViewNode[]
|
||||
}): TrajectorySnapshot {
|
||||
let structural = false
|
||||
for (const node of input.upserts) {
|
||||
const previous = this.nodes.get(node.key)
|
||||
this.nodes.set(node.key, node)
|
||||
if (previous === undefined || previous.anchorSeq !== node.anchorSeq) {
|
||||
structural = true
|
||||
continue
|
||||
}
|
||||
const position = this.positions.get(node.key)
|
||||
if (position === undefined) structural = true
|
||||
else this.contributions[position] = node
|
||||
}
|
||||
if (structural) this.rebuildContributions()
|
||||
return this.snapshot()
|
||||
}
|
||||
|
||||
private snapshot(): TrajectorySnapshot {
|
||||
const headersByStep = new Map<string, TrajectoryRequestHeaderState>()
|
||||
for (const contribution of this.contributions) {
|
||||
if (contribution.data.kind !== 'request-header') continue
|
||||
const key = headerStepKey(contribution.data.header)
|
||||
if (key !== undefined) headersByStep.set(key, contribution.data.header)
|
||||
}
|
||||
const finalized: ConversationNode[] = []
|
||||
const eventLocations = new Map<number, TrajectoryConversationViewNode['location']>()
|
||||
const requests: RequestView[] = []
|
||||
const boundaries: { seq: number; time: number }[] = []
|
||||
const turnEndings: { turn: number; time: number; error?: string }[] = []
|
||||
const callSchemas = new Map<string, ToolSchema>()
|
||||
const consumedPromptChanges = new Set<number>()
|
||||
let previousHeader: TrajectoryRequestHeaderState | undefined
|
||||
let previousTools: ReadonlyMap<string, ToolSchema> = new Map()
|
||||
let partial: TrajectorySnapshot['partial'] = null
|
||||
const runningCalls: TrajectorySnapshot['runningCalls'][number][] = []
|
||||
|
||||
for (const contribution of this.contributions) {
|
||||
const data = contribution.data
|
||||
if (data.kind === 'request-header') {
|
||||
previousHeader = data.header
|
||||
previousTools = indexTools(data.header.prompt.tools)
|
||||
continue
|
||||
}
|
||||
if (data.kind === 'node') {
|
||||
finalized.push(data.node)
|
||||
eventLocations.set(data.node.seq, contribution.location)
|
||||
continue
|
||||
}
|
||||
if (data.kind === 'assistant') {
|
||||
const header = data.request === undefined
|
||||
? undefined
|
||||
: headerFor(data.request, headersByStep, previousHeader)
|
||||
if (data.node !== undefined) finalized.push(withRequestConfig(data.node, header?.prompt))
|
||||
if (data.partial !== null) partial = data.partial
|
||||
if (data.request !== undefined) {
|
||||
const includeChange = header?.change !== undefined
|
||||
&& !consumedPromptChanges.has(header.seq)
|
||||
requests.push(applyHeader(data.request, header, includeChange))
|
||||
if (includeChange) consumedPromptChanges.add(header.seq)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (data.kind === 'tool') {
|
||||
if ('kind' in data.root) finalized.push(data.root)
|
||||
else runningCalls.push(data.root)
|
||||
if (previousHeader !== undefined && previousHeader.seq < contribution.anchorSeq) {
|
||||
captureSchemas(data.root, previousTools, callSchemas)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (data.kind === 'compaction') {
|
||||
requests.push(data.request)
|
||||
continue
|
||||
}
|
||||
if (data.kind === 'session-end') {
|
||||
boundaries.push({ seq: data.seq, time: data.time })
|
||||
continue
|
||||
}
|
||||
turnEndings.push({
|
||||
turn: data.turn,
|
||||
time: data.time,
|
||||
...(data.error === undefined ? {} : { error: data.error }),
|
||||
})
|
||||
}
|
||||
|
||||
requests.sort((left, right) => left.startSeq - right.startSeq)
|
||||
interruptCompactions(requests, boundaries)
|
||||
applyTurnErrors(requests, turnEndings)
|
||||
finalized.sort((left, right) => left.seq - right.seq)
|
||||
const eventNodes = finalized
|
||||
return {
|
||||
eventNodes,
|
||||
eventLocations,
|
||||
requests,
|
||||
callSchemas,
|
||||
partial,
|
||||
runningCalls,
|
||||
}
|
||||
}
|
||||
|
||||
private rebuildContributions(): void {
|
||||
this.contributions = [...this.nodes.values()]
|
||||
.sort((left, right) => left.anchorSeq - right.anchorSeq || left.key.localeCompare(right.key))
|
||||
this.positions.clear()
|
||||
for (const [index, contribution] of this.contributions.entries()) {
|
||||
this.positions.set(contribution.key, index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Trajectory target factory preserving the existing stage-oriented view model. */
|
||||
export const trajectoryViewDefinition: ConversationViewDefinition<
|
||||
TrajectoryConversationViewNode,
|
||||
TrajectorySnapshot
|
||||
> = {
|
||||
target: 'trajectory',
|
||||
create: () => new TrajectorySnapshotBuilder(),
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the stage-oriented Trajectory target builder.
|
||||
*
|
||||
* @param ctx - Plugin context receiving the view Definition.
|
||||
*/
|
||||
export function registerTrajectoryConversationView(ctx: Context): void {
|
||||
ctx.conversationViews.register(trajectoryViewDefinition)
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
ConversationMatch, ConversationNodeContext, ConversationNodeDefinition,
|
||||
RunningToolCall, ToolCallBlock, ToolResultNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {} from '@deepseek-ai/dsh-tools/types'
|
||||
import { trajectoryNode } from './trajectory-definition-common.ts'
|
||||
|
||||
/* jscpd:ignore-start -- Target-owned Definitions intentionally keep their event
|
||||
* state machines independent; see ../../../../../.agents/notes/implemented/
|
||||
* architecture/2026-08-09-client-conversation-node-assembly.md. */
|
||||
const MAX_DEPTH = 256
|
||||
|
||||
interface ToolState {
|
||||
readonly rootId: string
|
||||
readonly calls: ReadonlyMap<string, ToolCallBlock>
|
||||
readonly children: ReadonlyMap<string, readonly string[]>
|
||||
readonly parents: ReadonlyMap<string, string>
|
||||
}
|
||||
|
||||
interface DispatchData {
|
||||
readonly parentCallId: string
|
||||
readonly subCallId: string
|
||||
readonly name: string
|
||||
readonly arguments: unknown
|
||||
readonly isError?: boolean
|
||||
readonly content?: ToolResultNode['content']
|
||||
}
|
||||
|
||||
function rootCall(match: ConversationMatch): RunningToolCall {
|
||||
if (match.event.type !== 'tool/call') {
|
||||
throw new Error('trajectory-tool-call start requires tool/call')
|
||||
}
|
||||
return {
|
||||
callId: String(match.event.data.callId),
|
||||
name: match.event.data.name,
|
||||
argsRaw: match.event.data.arguments,
|
||||
turn: match.event.data.turn,
|
||||
step: match.event.data.step,
|
||||
time: match.event.time,
|
||||
callView: match.view?.for === 'call' ? match.view.view : null,
|
||||
subCalls: [],
|
||||
}
|
||||
}
|
||||
|
||||
function rootResult(
|
||||
match: ConversationMatch,
|
||||
previous?: RunningToolCall,
|
||||
): ToolResultNode | undefined {
|
||||
if (match.event.type !== 'tool/result') return undefined
|
||||
const result = match.event.data.message.content[0]
|
||||
return {
|
||||
kind: 'tool-result',
|
||||
seq: match.event.seq,
|
||||
time: match.event.time,
|
||||
callId: String(match.event.data.message.source.callId),
|
||||
call: previous === undefined ? null : { name: previous.name, argsRaw: previous.argsRaw },
|
||||
callTime: previous?.time ?? null,
|
||||
content: result.content,
|
||||
isError: result.isError === true,
|
||||
...(match.event.data.error === undefined ? {} : { error: match.event.data.error }),
|
||||
meta: match.event.data.meta,
|
||||
callView: previous?.callView ?? null,
|
||||
resultView: match.view?.for === 'result' ? match.view.view : null,
|
||||
subCalls: [],
|
||||
}
|
||||
}
|
||||
|
||||
function locationTurn(match: ConversationMatch): number {
|
||||
return match.location.kind === 'step' || match.location.kind === 'turn'
|
||||
? match.location.turn.turn
|
||||
: 0
|
||||
}
|
||||
|
||||
function locationStep(match: ConversationMatch): number {
|
||||
return match.location.kind === 'step' ? match.location.step.step : 0
|
||||
}
|
||||
|
||||
function childCall(match: ConversationMatch, data: DispatchData): RunningToolCall {
|
||||
return {
|
||||
callId: data.subCallId,
|
||||
name: data.name,
|
||||
argsRaw: JSON.stringify(data.arguments),
|
||||
turn: locationTurn(match),
|
||||
step: locationStep(match),
|
||||
time: match.event.time,
|
||||
callView: null,
|
||||
subCalls: [],
|
||||
}
|
||||
}
|
||||
|
||||
function childResult(
|
||||
match: ConversationMatch,
|
||||
data: DispatchData,
|
||||
previous?: ToolCallBlock,
|
||||
): ToolResultNode {
|
||||
return {
|
||||
kind: 'tool-result',
|
||||
seq: match.event.seq,
|
||||
time: match.event.time,
|
||||
callId: data.subCallId,
|
||||
call: { name: data.name, argsRaw: JSON.stringify(data.arguments) },
|
||||
callTime: previous === undefined || 'kind' in previous ? null : previous.time,
|
||||
content: data.content ?? [],
|
||||
isError: data.isError === true,
|
||||
callView: null,
|
||||
resultView: null,
|
||||
subCalls: [],
|
||||
}
|
||||
}
|
||||
|
||||
function acceptsEdge(state: ToolState, parent: string, child: string): boolean {
|
||||
if (parent === child || state.parents.has(child)) return false
|
||||
let cursor: string | undefined = parent
|
||||
let parentDepth = 0
|
||||
const ancestors = new Set<string>()
|
||||
while (cursor !== undefined) {
|
||||
if (cursor === child || ancestors.has(cursor)) return false
|
||||
ancestors.add(cursor)
|
||||
parentDepth++
|
||||
cursor = state.parents.get(cursor)
|
||||
}
|
||||
const pending = [{ callId: child, depth: 1 }]
|
||||
const descendants = new Set<string>()
|
||||
let subtreeDepth = 0
|
||||
for (const candidate of pending) {
|
||||
if (descendants.has(candidate.callId)) return false
|
||||
descendants.add(candidate.callId)
|
||||
subtreeDepth = Math.max(subtreeDepth, candidate.depth)
|
||||
for (const nested of state.children.get(candidate.callId) ?? []) {
|
||||
pending.push({ callId: nested, depth: candidate.depth + 1 })
|
||||
}
|
||||
}
|
||||
return parentDepth + subtreeDepth <= MAX_DEPTH
|
||||
}
|
||||
|
||||
function updateDispatch(state: ToolState, match: ConversationMatch): ToolState {
|
||||
const event = match.event
|
||||
if (event.type !== 'tool/code-dispatch-start' && event.type !== 'tool/code-dispatch') return state
|
||||
const data = event.data
|
||||
const parentId = String(data.parentCallId)
|
||||
const childId = String(data.subCallId)
|
||||
const siblings = state.children.get(parentId) ?? []
|
||||
const index = siblings.indexOf(childId)
|
||||
if (index < 0 && !acceptsEdge(state, parentId, childId)) return state
|
||||
if (event.type === 'tool/code-dispatch-start' && index >= 0) return state
|
||||
|
||||
const calls = new Map(state.calls)
|
||||
calls.set(childId, event.type === 'tool/code-dispatch-start'
|
||||
? childCall(match, data)
|
||||
: childResult(match, data, calls.get(childId)))
|
||||
if (index >= 0) return { ...state, calls }
|
||||
const children = new Map(state.children)
|
||||
children.set(parentId, [...siblings, childId])
|
||||
const parents = new Map(state.parents)
|
||||
parents.set(childId, parentId)
|
||||
return { ...state, calls, children, parents }
|
||||
}
|
||||
|
||||
function interruption(
|
||||
context: ConversationNodeContext<ToolState>,
|
||||
): { seq: number; time: number } | undefined {
|
||||
const location = context.start?.location
|
||||
if (location?.kind === 'step' && location.step.status === 'closed') return location.step.end
|
||||
if ((location?.kind === 'step' || location?.kind === 'turn')
|
||||
&& location.turn.status === 'closed') return location.turn.end
|
||||
return undefined
|
||||
}
|
||||
|
||||
function projectCall(
|
||||
state: ToolState,
|
||||
callId: string,
|
||||
interruptedAt: { seq: number; time: number } | undefined,
|
||||
visited = new Set<string>(),
|
||||
depth = 1,
|
||||
): ToolCallBlock | undefined {
|
||||
const block = state.calls.get(callId)
|
||||
if (block === undefined) return undefined
|
||||
if (visited.has(callId) || depth > MAX_DEPTH) return { ...block, subCalls: [] }
|
||||
const nextVisited = new Set(visited)
|
||||
nextVisited.add(callId)
|
||||
const subCalls = (state.children.get(callId) ?? [])
|
||||
.flatMap((childId) => {
|
||||
const child = projectCall(state, childId, interruptedAt, nextVisited, depth + 1)
|
||||
return child === undefined ? [] : [child]
|
||||
})
|
||||
if ('kind' in block || interruptedAt === undefined) return { ...block, subCalls }
|
||||
return {
|
||||
kind: 'tool-result',
|
||||
seq: interruptedAt.seq - 0.8,
|
||||
time: interruptedAt.time,
|
||||
callId: block.callId,
|
||||
call: { name: block.name, argsRaw: block.argsRaw },
|
||||
callTime: block.time,
|
||||
content: [],
|
||||
isError: true,
|
||||
error: { name: 'Interrupted', code: 'interrupted' },
|
||||
callView: block.callView,
|
||||
resultView: null,
|
||||
subCalls,
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackState(context: ConversationNodeContext<ToolState>): ToolState | undefined {
|
||||
const resultMatch = context.matches.find(match => match.event.type === 'tool/result')
|
||||
const root = resultMatch === undefined ? undefined : rootResult(resultMatch)
|
||||
if (root === undefined) return undefined
|
||||
let state: ToolState = {
|
||||
rootId: root.callId,
|
||||
calls: new Map([[root.callId, root]]),
|
||||
children: new Map(),
|
||||
parents: new Map(),
|
||||
}
|
||||
for (const match of context.matches) state = updateDispatch(state, match)
|
||||
return state
|
||||
}
|
||||
|
||||
/** Trajectory-owned root Tool lifecycle with nested Code Dispatch calls. */
|
||||
const trajectoryToolDefinition: ConversationNodeDefinition<ToolState> = {
|
||||
kind: 'trajectory-tool-call',
|
||||
target: 'trajectory',
|
||||
match: (event) => {
|
||||
if (event.type === 'tool/call') return { id: String(event.data.callId), role: 'start' }
|
||||
if (event.type === 'tool/result') {
|
||||
return { id: String(event.data.message.source.callId), role: 'update' }
|
||||
}
|
||||
if (event.type === 'tool/code-dispatch-start' || event.type === 'tool/code-dispatch') {
|
||||
const rootCallId: unknown = event.data.rootCallId
|
||||
return typeof rootCallId === 'string' && rootCallId !== ''
|
||||
? { id: rootCallId, role: 'update' }
|
||||
: null
|
||||
}
|
||||
return null
|
||||
},
|
||||
start: (_context, match) => {
|
||||
const root = rootCall(match)
|
||||
return {
|
||||
rootId: root.callId,
|
||||
calls: new Map([[root.callId, root]]),
|
||||
children: new Map(),
|
||||
parents: new Map(),
|
||||
}
|
||||
},
|
||||
update: (context, match) => {
|
||||
if (match.event.type !== 'tool/result') return updateDispatch(context.state, match)
|
||||
const previous = context.state.calls.get(context.state.rootId)
|
||||
const running = previous !== undefined && !('kind' in previous) ? previous : undefined
|
||||
const result = rootResult(match, running)
|
||||
if (result === undefined) return context.state
|
||||
const calls = new Map(context.state.calls)
|
||||
calls.set(context.state.rootId, result)
|
||||
return { ...context.state, calls }
|
||||
},
|
||||
buildViewNode: (context) => {
|
||||
const state = context.state ?? fallbackState(context)
|
||||
if (state === undefined) return null
|
||||
const root = projectCall(state, state.rootId, interruption(context))
|
||||
if (root === undefined) return null
|
||||
const anchorSeq = context.start?.event.seq
|
||||
?? ('kind' in root ? root.seq : context.matches[0]?.event.seq ?? 0)
|
||||
return trajectoryNode(context, anchorSeq, { kind: 'tool', root })
|
||||
},
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/**
|
||||
* Register the Trajectory Tool lifecycle.
|
||||
*
|
||||
* @param ctx - Plugin context receiving the Definition.
|
||||
*/
|
||||
export function registerTrajectoryToolDefinition(ctx: Context): void {
|
||||
ctx.conversationEvents.register(trajectoryToolDefinition)
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-trajectory'
|
||||
|
||||
Reference in New Issue
Block a user