perf(ui-trajectory): defer trajectory text processing
This commit is contained in:
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||||
# after editing either side, bring the other along and re-record with:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md
|
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md
|
||||||
2026-08-09-client-conversation-node-assembly.md: 1d5fd20bfa8c3b370f736937d54a668ca7f19ca3
|
2026-08-09-client-conversation-node-assembly.md: 3b02fde8b5c8da0c7086a2de65a5ae8eea8b2526
|
||||||
2026-08-09-client-conversation-node-assembly.zh.md: 6f0acc448950cdedcb249ada8cfd931a21765b3e
|
2026-08-09-client-conversation-node-assembly.zh.md: 2ddb14c35b3ac5aeba5b00e7d56b3a4e69a97adb
|
||||||
|
|||||||
@@ -330,6 +330,8 @@ The concrete Tool renderer remains governed by the [`ui-tool ownership decision`
|
|||||||
|
|
||||||
Trajectory registers its own target and business Definitions against the same Assembler and Session event window as Chat. Its target builder preserves the stage-oriented read model without consuming the Chat Builder's legacy slice or running an independent history fold. The Chat Builder retains its legacy slice for StatsLine and the top-level public compatibility fields; target-specific Definitions do not change the shared Context, Reader, or Location contracts.
|
Trajectory registers its own target and business Definitions against the same Assembler and Session event window as Chat. Its target builder preserves the stage-oriented read model without consuming the Chat Builder's legacy slice or running an independent history fold. The Chat Builder retains its legacy slice for StatsLine and the top-level public compatibility fields; target-specific Definitions do not change the shared Context, Reader, or Location contracts.
|
||||||
|
|
||||||
|
Trajectory stage/layout processing retains raw summary sources and structural data without parsing Markdown. A stable Record presentation in the Table memoizes each one-line summary by content and shares the result across body text, title, and aria-label; Detail renders only the selected record. Timeline timing labels invoke their formatters only after the delayed Tooltip opens. Search owns an independent per-view `TrajectorySearchIndex` keyed by stable Record identity with each source signature and normalized text. The initial window is indexed immediately, and a three-second throttle commits later new or changed Records in batches. Queries read only the latest committed index version, so a prepended page enters results atomically with the next batch; neither prepend nor append reparses unchanged historical Markdown. Display caching and search indexing do not share lifecycles.
|
||||||
|
|
||||||
## Runtime and render path
|
## Runtime and render path
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
@@ -330,6 +330,8 @@ Assistant streaming 到 final、Tool running 到 settled 只更新同一个 Seat
|
|||||||
|
|
||||||
Trajectory 针对与 Chat 相同的 Assembler 和 Session 事件窗口注册自己的 target 与业务 Definition。它的 target builder 保留 stage-oriented read model,既不消费 Chat Builder 的 legacy slice,也不运行独立 history fold。Chat Builder 为 StatsLine 和顶层公共兼容字段保留 legacy slice;target 专属 Definition 不改变共享的 Context、Reader 或 Location 契约。
|
Trajectory 针对与 Chat 相同的 Assembler 和 Session 事件窗口注册自己的 target 与业务 Definition。它的 target builder 保留 stage-oriented read model,既不消费 Chat Builder 的 legacy slice,也不运行独立 history fold。Chat Builder 为 StatsLine 和顶层公共兼容字段保留 legacy slice;target 专属 Definition 不改变共享的 Context、Reader 或 Location 契约。
|
||||||
|
|
||||||
|
Trajectory 的 stage/layout 只保留原始摘要来源和结构数据,不解析 Markdown。Table 的稳定 Record presentation 按内容 memo 单行摘要,并把同一结果用于正文、title 与 aria-label;Detail 只渲染当前选中记录。Timeline 的时序标签只在延迟 Tooltip 实际打开后执行格式化。搜索拥有独立的 per-view `TrajectorySearchIndex`,按稳定 Record identity 保存来源签名和标准化文本;初始窗口立即建立索引,后续新增或变化的 Record 由三秒 throttle 批量提交。查询只读取最近一次提交的索引版本,分页的新一页随下一批一次性进入结果;prepend 与 append 都不会重复解析未变化的历史 Markdown。展示缓存与搜索索引互不借用生命周期。
|
||||||
|
|
||||||
## Runtime and render path
|
## Runtime and render path
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
@@ -24,9 +24,11 @@ interface AnchorProps {
|
|||||||
onBlur?: FocusEventHandler | undefined
|
onBlur?: FocusEventHandler | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TooltipLabel = string | (() => string)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attach a hover/focus tooltip to an anchor element.
|
* Attach a hover/focus tooltip to an anchor element.
|
||||||
* @param props.label - bubble text.
|
* @param props.label - bubble text, or a resolver evaluated only while the bubble is visible.
|
||||||
* @param props.side - placement relative to the anchor (default 'right').
|
* @param props.side - placement relative to the anchor (default 'right').
|
||||||
* @param props.delayMs - hover delay in milliseconds; keyboard focus remains immediate.
|
* @param props.delayMs - hover delay in milliseconds; keyboard focus remains immediate.
|
||||||
* @param props.disabled - suppress the bubble while true; the anchor renders identically so
|
* @param props.disabled - suppress the bubble while true; the anchor renders identically so
|
||||||
@@ -34,7 +36,7 @@ interface AnchorProps {
|
|||||||
* @param props.children - a single anchor element; its own ref (callback or object) is forwarded alongside the tooltip's.
|
* @param props.children - a single anchor element; its own ref (callback or object) is forwarded alongside the tooltip's.
|
||||||
* @returns the cloned anchor plus a fixed-position bubble while hovered/focused.
|
* @returns the cloned anchor plus a fixed-position bubble while hovered/focused.
|
||||||
*/
|
*/
|
||||||
export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, children }: { label: string; side?: TooltipSide; delayMs?: number; disabled?: boolean; children: ReactElement<AnchorProps> }) {
|
export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, children }: { label: TooltipLabel; side?: TooltipSide; delayMs?: number; disabled?: boolean; children: ReactElement<AnchorProps> }) {
|
||||||
const anchor = useRef<HTMLElement | null>(null)
|
const anchor = useRef<HTMLElement | null>(null)
|
||||||
// React 18 keeps the element's ref outside props; forward it so wrapping an
|
// React 18 keeps the element's ref outside props; forward it so wrapping an
|
||||||
// anchor in Tooltip never silently severs the owner's ref.
|
// anchor in Tooltip never silently severs the owner's ref.
|
||||||
@@ -46,6 +48,9 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false,
|
|||||||
}, [childRef])
|
}, [childRef])
|
||||||
const [pos, setPos] = useState<{ x: number; y: number } | null>(null)
|
const [pos, setPos] = useState<{ x: number; y: number } | null>(null)
|
||||||
const bubble = useRef<HTMLSpanElement | null>(null)
|
const bubble = useRef<HTMLSpanElement | null>(null)
|
||||||
|
const resolvedLabel = pos === null
|
||||||
|
? null
|
||||||
|
: typeof label === 'function' ? label() : label
|
||||||
// Horizontal viewport clamp: fixed positioning knows nothing about edges, so
|
// Horizontal viewport clamp: fixed positioning knows nothing about edges, so
|
||||||
// a centered bubble near the right edge would clip. Each measurement resets
|
// a centered bubble near the right edge would clip. Each measurement resets
|
||||||
// the base position before applying a direct style offset, allowing a shorter
|
// the base position before applying a direct style offset, allowing a shorter
|
||||||
@@ -67,7 +72,7 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false,
|
|||||||
clamp()
|
clamp()
|
||||||
window.addEventListener('resize', clamp)
|
window.addEventListener('resize', clamp)
|
||||||
return () => { window.removeEventListener('resize', clamp) }
|
return () => { window.removeEventListener('resize', clamp) }
|
||||||
}, [label, pos])
|
}, [pos, resolvedLabel])
|
||||||
const showTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const showTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
// Hover and focus are independent triggers: the bubble hides only after
|
// Hover and focus are independent triggers: the bubble hides only after
|
||||||
// BOTH clear (hovering away from a focused anchor must not drop it).
|
// BOTH clear (hovering away from a focused anchor must not drop it).
|
||||||
@@ -128,7 +133,7 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false,
|
|||||||
})}
|
})}
|
||||||
{pos !== null && (
|
{pos !== null && (
|
||||||
<span ref={bubble} className={css.bubble} data-side={side} style={{ left: pos.x, top: pos.y }} role="tooltip">
|
<span ref={bubble} className={css.bubble} data-side={side} style={{ left: pos.x, top: pos.y }} role="tooltip">
|
||||||
{label}
|
{resolvedLabel}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -6,6 +6,27 @@ import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
|||||||
afterEach(cleanup)
|
afterEach(cleanup)
|
||||||
|
|
||||||
describe('Tooltip', () => {
|
describe('Tooltip', () => {
|
||||||
|
it('resolves lazy labels only after the bubble becomes visible', () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
try {
|
||||||
|
const label = vi.fn(() => 'Timing details')
|
||||||
|
render(
|
||||||
|
<Tooltip label={label} delayMs={500}>
|
||||||
|
<button type="button">anchor</button>
|
||||||
|
</Tooltip>,
|
||||||
|
)
|
||||||
|
expect(label).not.toHaveBeenCalled()
|
||||||
|
fireEvent.mouseEnter(screen.getByText('anchor'))
|
||||||
|
act(() => { vi.advanceTimersByTime(499) })
|
||||||
|
expect(label).not.toHaveBeenCalled()
|
||||||
|
act(() => { vi.advanceTimersByTime(1) })
|
||||||
|
expect(screen.getByRole('tooltip').textContent).toBe('Timing details')
|
||||||
|
expect(label).toHaveBeenCalledOnce()
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
it('can delay pointer hover without delaying keyboard focus', () => {
|
it('can delay pointer hover without delaying keyboard focus', () => {
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ import {
|
|||||||
groupTrajectoryVirtualRows, trajectoryVirtualRecordKey,
|
groupTrajectoryVirtualRows, trajectoryVirtualRecordKey,
|
||||||
} from './trajectory-virtual-rows.ts'
|
} from './trajectory-virtual-rows.ts'
|
||||||
import type { TrajectoryVirtualRow } 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'
|
import css from './TrajectoryTable.module.css'
|
||||||
|
|
||||||
const BOTTOM_FOLLOW_THRESHOLD_PX = 2
|
const BOTTOM_FOLLOW_THRESHOLD_PX = 2
|
||||||
@@ -903,6 +904,11 @@ function detailTabs(record: TableRecord): readonly DetailTabItem[] {
|
|||||||
|
|
||||||
function recordDisplayText(cell: TrajectoryCellProps): string {
|
function recordDisplayText(cell: TrajectoryCellProps): string {
|
||||||
if (isToolCallOnly(cell)) return ''
|
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
|
if (cell.text !== '') return cell.text
|
||||||
const markdown = cell.kind === 'user' || cell.kind === 'context'
|
const markdown = cell.kind === 'user' || cell.kind === 'context'
|
||||||
? cell.inputDetail
|
? cell.inputDetail
|
||||||
@@ -912,6 +918,12 @@ function recordDisplayText(cell: TrajectoryCellProps): string {
|
|||||||
return markdown === undefined ? '' : trajectoryPreviewText(markdown)
|
return markdown === undefined ? '' : trajectoryPreviewText(markdown)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function recordResultText(cell: TrajectoryCellProps): string | undefined {
|
||||||
|
return cell.resultPreviewMarkdown === undefined
|
||||||
|
? cell.result
|
||||||
|
: trajectoryPreviewText(cell.resultPreviewMarkdown)
|
||||||
|
}
|
||||||
|
|
||||||
function toolCallTextParts(
|
function toolCallTextParts(
|
||||||
kind: TrajectoryCellKind,
|
kind: TrajectoryCellKind,
|
||||||
text: string,
|
text: string,
|
||||||
@@ -932,6 +944,71 @@ function isToolCallOnly(cell: TrajectoryCellProps): boolean {
|
|||||||
&& cell.text === 'Tool call only'
|
&& 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({
|
function MarkdownFragment({
|
||||||
text,
|
text,
|
||||||
rendered,
|
rendered,
|
||||||
@@ -2131,15 +2208,12 @@ export function TrajectoryTable({
|
|||||||
/>
|
/>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
{renderedRecords.map(({ record, position, terminalRequestBoundary }) => {
|
{renderedRecords.map(({ record, position, terminalRequestBoundary }) => (
|
||||||
const displayText = recordDisplayText(record.cell)
|
<RecordPresentation
|
||||||
const toolCallOnly = isToolCallOnly(record.cell)
|
key={trajectoryVirtualRecordKey(record)}
|
||||||
const toolCallText = toolCallTextParts(record.cell.kind, displayText)
|
cell={record.cell}
|
||||||
const listDisplayText = toolCallOnly
|
>
|
||||||
? '(tool call only)'
|
{({ displayText, listDisplayText, resultText, toolCallOnly, toolCallText }) => {
|
||||||
: toolCallText === undefined
|
|
||||||
? displayText
|
|
||||||
: [toolCallText.name, toolCallText.args].filter(Boolean).join(' ')
|
|
||||||
const isCollapsedSummary = record.collapsedSummary !== undefined
|
const isCollapsedSummary = record.collapsedSummary !== undefined
|
||||||
const isRequestOnly = record.cell.requestOnly === true
|
const isRequestOnly = record.cell.requestOnly === true
|
||||||
const isInitialSystem = record.cell.kind === 'system'
|
const isInitialSystem = record.cell.kind === 'system'
|
||||||
@@ -2169,7 +2243,6 @@ export function TrajectoryTable({
|
|||||||
: activeTurn === record.turn
|
: activeTurn === record.turn
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={trajectoryVirtualRecordKey(record)}
|
|
||||||
tabIndex={isRequestOnly ? -1 : 0}
|
tabIndex={isRequestOnly ? -1 : 0}
|
||||||
aria-rowindex={position + 1}
|
aria-rowindex={position + 1}
|
||||||
aria-label={isCollapsedSummary
|
aria-label={isCollapsedSummary
|
||||||
@@ -2348,37 +2421,26 @@ export function TrajectoryTable({
|
|||||||
)
|
)
|
||||||
: (
|
: (
|
||||||
<span
|
<span
|
||||||
className={record.cell.result === undefined ? css.contentText : css.resultPreview}
|
className={resultText === undefined ? css.contentText : css.resultPreview}
|
||||||
title={record.cell.result === undefined
|
title={resultText === undefined
|
||||||
? listDisplayText
|
? listDisplayText
|
||||||
: `${listDisplayText} → ${record.cell.result}`}
|
: `${listDisplayText} → ${resultText}`}
|
||||||
>
|
>
|
||||||
<span className={record.cell.result === undefined ? undefined : css.resultRequest}>
|
<span className={resultText === undefined ? undefined : css.resultRequest}>
|
||||||
{toolCallOnly
|
<RecordListText
|
||||||
? <span className={css.toolCallOnly}>(tool call only)</span>
|
displayText={displayText}
|
||||||
: toolCallText === undefined
|
toolCallOnly={toolCallOnly}
|
||||||
? listDisplayText || '—'
|
toolCallText={toolCallText}
|
||||||
: (
|
/>
|
||||||
<>
|
|
||||||
<span className={css.toolCallNameTypeface}>
|
|
||||||
{toolCallText.name || '—'}
|
|
||||||
</span>
|
|
||||||
{toolCallText.args !== undefined && (
|
|
||||||
<span className={css.toolCallPayload}>
|
|
||||||
{toolCallText.args}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
{record.cell.result !== undefined && (
|
{resultText !== undefined && (
|
||||||
<span className={record.cell.isError ? `${css.inlineResult} ${css.error}` : css.inlineResult}>
|
<span className={record.cell.isError ? `${css.inlineResult} ${css.error}` : css.inlineResult}>
|
||||||
<span className={css.arrow}>→</span>
|
<span className={css.arrow}>→</span>
|
||||||
<span className={record.cell.result === 'No output'
|
<span className={resultText === 'No output'
|
||||||
? `${css.inlineResultText} ${css.noOutputText}`
|
? `${css.inlineResultText} ${css.noOutputText}`
|
||||||
: css.inlineResultText}
|
: css.inlineResultText}
|
||||||
>
|
>
|
||||||
{record.cell.result}
|
{resultText}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -2387,7 +2449,9 @@ export function TrajectoryTable({
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)
|
)
|
||||||
})}
|
}}
|
||||||
|
</RecordPresentation>
|
||||||
|
))}
|
||||||
{virtualBottom > 0 && (
|
{virtualBottom > 0 && (
|
||||||
<tr className={css.virtualSpacer} data-virtual-spacer="bottom" aria-hidden="true">
|
<tr className={css.virtualSpacer} data-virtual-spacer="bottom" aria-hidden="true">
|
||||||
<td
|
<td
|
||||||
|
|||||||
@@ -687,7 +687,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
|||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
key={span.index}
|
key={span.index}
|
||||||
label={timelineTooltipLabel(span.kind, detail)}
|
label={() => timelineTooltipLabel(span.kind, detail)}
|
||||||
side="bottom"
|
side="bottom"
|
||||||
delayMs={TIMELINE_TOOLTIP_DELAY_MS}
|
delayMs={TIMELINE_TOOLTIP_DELAY_MS}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/** Trajectory view: compact summary over a turn-aware event ledger. */
|
/** Trajectory view: compact summary over a turn-aware event ledger. */
|
||||||
|
|
||||||
import { useCallback, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||||
import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots'
|
import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots'
|
||||||
import type {
|
import type {
|
||||||
@@ -24,11 +24,13 @@ import {
|
|||||||
type TrajectoryTimeRange,
|
type TrajectoryTimeRange,
|
||||||
} from './timeline.ts'
|
} from './timeline.ts'
|
||||||
import { trajectoryRecordId } from './trajectory-record.ts'
|
import { trajectoryRecordId } from './trajectory-record.ts'
|
||||||
|
import { TrajectorySearchIndex } from './trajectory-search-index.ts'
|
||||||
import { EMPTY_TRAJECTORY_SNAPSHOT } from './trajectory-snapshot-builder.ts'
|
import { EMPTY_TRAJECTORY_SNAPSHOT } from './trajectory-snapshot-builder.ts'
|
||||||
import css from './views.module.css'
|
import css from './views.module.css'
|
||||||
|
|
||||||
const EMPTY_TURN_IDS: ReadonlySet<number> = new Set()
|
const EMPTY_TURN_IDS: ReadonlySet<number> = new Set()
|
||||||
const EMPTY_RECORD_IDS: ReadonlySet<string> = new Set()
|
const EMPTY_RECORD_IDS: ReadonlySet<string> = new Set()
|
||||||
|
const SEARCH_INDEX_THROTTLE_MS = 3_000
|
||||||
|
|
||||||
function lastCellIndex(turns: readonly TrajectoryTurnModel[]): number {
|
function lastCellIndex(turns: readonly TrajectoryTurnModel[]): number {
|
||||||
let last = 0
|
let last = 0
|
||||||
@@ -115,70 +117,6 @@ 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({
|
export function TrajectoryView({
|
||||||
useSession, useDuration, loadOlder, setActualDuration,
|
useSession, useDuration, loadOlder, setActualDuration,
|
||||||
inspect, onInspectDone,
|
inspect, onInspectDone,
|
||||||
@@ -190,6 +128,10 @@ export function TrajectoryView({
|
|||||||
const actualDuration = useDuration(value => value)
|
const actualDuration = useDuration(value => value)
|
||||||
const [actualTime, setActualTime] = useState(false)
|
const [actualTime, setActualTime] = useState(false)
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
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 [selectedTimelineIndex, setSelectedTimelineIndex] = useState<number | null>(null)
|
||||||
const [timelineRecordSelection, setTimelineRecordSelection] = useState<{
|
const [timelineRecordSelection, setTimelineRecordSelection] = useState<{
|
||||||
readonly index: number
|
readonly index: number
|
||||||
@@ -340,28 +282,59 @@ export function TrajectoryView({
|
|||||||
const timelineMode: TrajectoryTimelineMode = actualDuration
|
const timelineMode: TrajectoryTimelineMode = actualDuration
|
||||||
? actualTime ? 'actual' : 'duration'
|
? actualTime ? 'actual' : 'duration'
|
||||||
: actualTime ? 'time' : 'sequence'
|
: actualTime ? 'time' : 'sequence'
|
||||||
const finalizedSearchMatches = useMemo(
|
|
||||||
() => searchMatches(finalized.turns, searchQuery),
|
|
||||||
[finalized, searchQuery],
|
|
||||||
)
|
|
||||||
const partialSearchTurns = useMemo(
|
const partialSearchTurns = useMemo(
|
||||||
() => appendTrajectoryPartialLayout([], partial, finalized.lastIndex),
|
() => appendTrajectoryPartialLayout([], partial, finalized.lastIndex),
|
||||||
[finalized.lastIndex, partial],
|
[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(
|
const streamingCells = useMemo(
|
||||||
() => partialSearchTurns.flatMap(turn =>
|
() => partialSearchTurns.flatMap(turn =>
|
||||||
turn.groups.flatMap(group => group.cells),
|
turn.groups.flatMap(group => group.cells),
|
||||||
),
|
),
|
||||||
[partialSearchTurns],
|
[partialSearchTurns],
|
||||||
)
|
)
|
||||||
const partialSearchMatches = useMemo(
|
const searchMatchRecordIds = useMemo(
|
||||||
() => searchMatches(partialSearchTurns, searchQuery),
|
() => searchIndex.search(searchQuery),
|
||||||
[partialSearchTurns, searchQuery],
|
[searchIndex, searchIndexRevision, searchQuery],
|
||||||
)
|
|
||||||
const searchMatchIndexes = useMemo(
|
|
||||||
() => mergeSearchMatches(finalizedSearchMatches, partialSearchMatches),
|
|
||||||
[finalizedSearchMatches, partialSearchMatches],
|
|
||||||
)
|
)
|
||||||
|
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 timelineRange = timelineSelection
|
||||||
const timelineFocusIndexes = useMemo(
|
const timelineFocusIndexes = useMemo(
|
||||||
() => timelineRange === null
|
() => timelineRange === null
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import type {
|
|||||||
ToolCallBlock,
|
ToolCallBlock,
|
||||||
ToolResultNode,
|
ToolResultNode,
|
||||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
import { extractMarkdownPlainText } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
||||||
import type {
|
import type {
|
||||||
TrajectoryCellProps,
|
TrajectoryCellProps,
|
||||||
TrajectorySourceBlock,
|
TrajectorySourceBlock,
|
||||||
@@ -70,9 +69,6 @@ interface TurnBucket {
|
|||||||
type AssistantRequestView = Extract<RequestView, { purpose: 'assistant' }>
|
type AssistantRequestView = Extract<RequestView, { purpose: 'assistant' }>
|
||||||
type CompactionRequestView = Extract<RequestView, { purpose: 'compaction' }>
|
type CompactionRequestView = Extract<RequestView, { purpose: 'compaction' }>
|
||||||
|
|
||||||
const PREVIEW_SOURCE_CHARACTERS = 2_048
|
|
||||||
const PREVIEW_OUTPUT_CHARACTERS = 512
|
|
||||||
|
|
||||||
type InputNode = Extract<
|
type InputNode = Extract<
|
||||||
ConversationSnapshot['nodes'][number],
|
ConversationSnapshot['nodes'][number],
|
||||||
{ kind: 'user' | 'context' }
|
{ kind: 'user' | 'context' }
|
||||||
@@ -110,10 +106,19 @@ function layoutEntryOrder(entry: OrderedLayoutEntry): number {
|
|||||||
|
|
||||||
function inputCellDetail(node: InputNode): Pick<
|
function inputCellDetail(node: InputNode): Pick<
|
||||||
TrajectoryCellProps,
|
TrajectoryCellProps,
|
||||||
'text' | 'sourceSeq' | 'messageSource' | 'inputDetail' | 'sourceBlocks' | 'timeSeconds' | 'startedAt'
|
| 'text'
|
||||||
|
| 'previewMarkdown'
|
||||||
|
| 'sourceSeq'
|
||||||
|
| 'messageSource'
|
||||||
|
| 'inputDetail'
|
||||||
|
| 'sourceBlocks'
|
||||||
|
| 'timeSeconds'
|
||||||
|
| 'startedAt'
|
||||||
> {
|
> {
|
||||||
|
const previewMarkdown = previewContent(node.content)
|
||||||
return {
|
return {
|
||||||
text: summarizeContent(node.content),
|
text: '',
|
||||||
|
...(previewMarkdown === undefined ? {} : { previewMarkdown }),
|
||||||
sourceSeq: node.seq,
|
sourceSeq: node.seq,
|
||||||
messageSource: node.source,
|
messageSource: node.source,
|
||||||
inputDetail: detailContent(node.content),
|
inputDetail: detailContent(node.content),
|
||||||
@@ -293,7 +298,10 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
|||||||
? request.error ?? 'Compaction failed'
|
? request.error ?? 'Compaction failed'
|
||||||
: request.summary === undefined
|
: request.summary === undefined
|
||||||
? 'Context compacted'
|
? 'Context compacted'
|
||||||
: summarizeContent(request.summary),
|
: '',
|
||||||
|
...(request.status === 'complete' && request.summary !== undefined
|
||||||
|
? previewContentProperty(request.summary)
|
||||||
|
: {}),
|
||||||
sourceSeq: request.startSeq,
|
sourceSeq: request.startSeq,
|
||||||
...(request.summary === undefined
|
...(request.summary === undefined
|
||||||
? {}
|
? {}
|
||||||
@@ -377,6 +385,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
|||||||
if (node.kind === 'tool-result') {
|
if (node.kind === 'tool-result') {
|
||||||
if (!emittedCallIds.has(node.callId)) {
|
if (!emittedCallIds.has(node.callId)) {
|
||||||
const toolName = node.call?.name
|
const toolName = node.call?.name
|
||||||
|
const resultPreview = summarizeResult(node)
|
||||||
const laidList: LaidCell[] = [{
|
const laidList: LaidCell[] = [{
|
||||||
absTime: finiteTime(node.callTime ?? node.time),
|
absTime: finiteTime(node.callTime ?? node.time),
|
||||||
...(toolName !== undefined ? { toolName } : {}),
|
...(toolName !== undefined ? { toolName } : {}),
|
||||||
@@ -386,13 +395,13 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
|||||||
index: ++index,
|
index: ++index,
|
||||||
kind: 'tool',
|
kind: 'tool',
|
||||||
sourceSeq: node.seq,
|
sourceSeq: node.seq,
|
||||||
text: node.call !== null
|
...(node.call !== null
|
||||||
? summarizeCall(node.call.name, node.call.argsRaw)
|
? summarizeCall(node.call.name, node.call.argsRaw)
|
||||||
: summarizeResult(node),
|
: resultAsText(resultPreview)),
|
||||||
...(node.call !== null ? { inputDetail: node.call.argsRaw } : {}),
|
...(node.call !== null ? { inputDetail: node.call.argsRaw } : {}),
|
||||||
outputDetail: detailResult(node),
|
outputDetail: detailResult(node),
|
||||||
outputBlocks: node.content.map(block => sourceBlock(block)),
|
outputBlocks: node.content.map(block => sourceBlock(block)),
|
||||||
result: summarizeResult(node),
|
...resultPreview,
|
||||||
callId: node.callId,
|
callId: node.callId,
|
||||||
isError: node.isError,
|
isError: node.isError,
|
||||||
timeSeconds: durationSeconds(node.time, node.callTime),
|
timeSeconds: durationSeconds(node.time, node.callTime),
|
||||||
@@ -440,7 +449,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
|||||||
cell: {
|
cell: {
|
||||||
index: ++index,
|
index: ++index,
|
||||||
kind: 'tool',
|
kind: 'tool',
|
||||||
text: summarizeCall(call.name, call.argsRaw),
|
...summarizeCall(call.name, call.argsRaw),
|
||||||
inputDetail: call.argsRaw,
|
inputDetail: call.argsRaw,
|
||||||
callId: call.callId,
|
callId: call.callId,
|
||||||
timeSeconds: null,
|
timeSeconds: null,
|
||||||
@@ -650,11 +659,14 @@ function expandAssistant(
|
|||||||
recordId: `assistant\u0000${node.turn}\u0000${node.step}`,
|
recordId: `assistant\u0000${node.turn}\u0000${node.step}`,
|
||||||
kind: 'message',
|
kind: 'message',
|
||||||
sourceSeq: node.seq,
|
sourceSeq: node.seq,
|
||||||
text: messageText !== ''
|
text: messageText !== '' || thinkingText !== ''
|
||||||
? summarizeText(messageText)
|
? ''
|
||||||
|
: summarizeAssistantActivity(node.blocks),
|
||||||
|
...(messageText !== ''
|
||||||
|
? { previewMarkdown: messageText }
|
||||||
: thinkingText !== ''
|
: thinkingText !== ''
|
||||||
? summarizeText(thinkingText)
|
? { previewMarkdown: thinkingText }
|
||||||
: summarizeAssistantActivity(node.blocks),
|
: {}),
|
||||||
...(messageText !== '' ? { outputDetail: messageText } : {}),
|
...(messageText !== '' ? { outputDetail: messageText } : {}),
|
||||||
...(thinkingText !== '' ? { thinkingDetail: thinkingText } : {}),
|
...(thinkingText !== '' ? { thinkingDetail: thinkingText } : {}),
|
||||||
sourceBlocks: node.blocks.map(block => assistantSourceBlock(block)),
|
sourceBlocks: node.blocks.map(block => assistantSourceBlock(block)),
|
||||||
@@ -681,6 +693,7 @@ function expandAssistant(
|
|||||||
: durationSeconds(result.time, result.callTime)
|
: durationSeconds(result.time, result.callTime)
|
||||||
const callAbs = finiteTime(callStarts.get(block.callId))
|
const callAbs = finiteTime(callStarts.get(block.callId))
|
||||||
const call = calls.get(block.callId)
|
const call = calls.get(block.callId)
|
||||||
|
const resultPreview = result === undefined ? undefined : summarizeResult(result)
|
||||||
out.push({
|
out.push({
|
||||||
absTime: callAbs,
|
absTime: callAbs,
|
||||||
toolName: block.name,
|
toolName: block.name,
|
||||||
@@ -688,14 +701,14 @@ function expandAssistant(
|
|||||||
...(call === undefined ? {} : { subCalls: call.subCalls }),
|
...(call === undefined ? {} : { subCalls: call.subCalls }),
|
||||||
cell: {
|
cell: {
|
||||||
index: ++index, kind: 'tool',
|
index: ++index, kind: 'tool',
|
||||||
text: summarizeCall(block.name, block.argsRaw),
|
...summarizeCall(block.name, block.argsRaw),
|
||||||
inputDetail: block.argsRaw,
|
inputDetail: block.argsRaw,
|
||||||
callId: block.callId,
|
callId: block.callId,
|
||||||
...(result !== undefined
|
...(result !== undefined
|
||||||
? {
|
? {
|
||||||
outputDetail: detailResult(result),
|
outputDetail: detailResult(result),
|
||||||
outputBlocks: result.content.map(block => sourceBlock(block)),
|
outputBlocks: result.content.map(block => sourceBlock(block)),
|
||||||
result: summarizeResult(result),
|
...resultPreview,
|
||||||
isError: result.isError,
|
isError: result.isError,
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
@@ -919,6 +932,7 @@ function expandSubCalls(
|
|||||||
let index = startIndex
|
let index = startIndex
|
||||||
for (const sub of subs) {
|
for (const sub of subs) {
|
||||||
const settled = 'kind' in sub
|
const settled = 'kind' in sub
|
||||||
|
const resultPreview = settled ? summarizeResult(sub) : undefined
|
||||||
const laid: LaidCell = {
|
const laid: LaidCell = {
|
||||||
absTime: settled ? finiteTime(sub.callTime ?? sub.time) : finiteTime(sub.time),
|
absTime: settled ? finiteTime(sub.callTime ?? sub.time) : finiteTime(sub.time),
|
||||||
toolName: settled ? sub.call?.name ?? sub.callId : sub.name,
|
toolName: settled ? sub.call?.name ?? sub.callId : sub.name,
|
||||||
@@ -927,9 +941,11 @@ function expandSubCalls(
|
|||||||
index: ++index,
|
index: ++index,
|
||||||
kind: 'subtool',
|
kind: 'subtool',
|
||||||
callId: sub.callId,
|
callId: sub.callId,
|
||||||
text: settled
|
...(settled
|
||||||
? (sub.call !== null ? summarizeCall(sub.call.name, sub.call.argsRaw) : summarizeResult(sub))
|
? (sub.call !== null
|
||||||
: summarizeCall(sub.name, sub.argsRaw),
|
? summarizeCall(sub.call.name, sub.call.argsRaw)
|
||||||
|
: resultAsText(resultPreview))
|
||||||
|
: summarizeCall(sub.name, sub.argsRaw)),
|
||||||
...(settled
|
...(settled
|
||||||
? (sub.call !== null ? { inputDetail: sub.call.argsRaw } : {})
|
? (sub.call !== null ? { inputDetail: sub.call.argsRaw } : {})
|
||||||
: { inputDetail: sub.argsRaw }),
|
: { inputDetail: sub.argsRaw }),
|
||||||
@@ -937,7 +953,7 @@ function expandSubCalls(
|
|||||||
? {
|
? {
|
||||||
outputDetail: detailResult(sub),
|
outputDetail: detailResult(sub),
|
||||||
outputBlocks: sub.content.map(block => sourceBlock(block)),
|
outputBlocks: sub.content.map(block => sourceBlock(block)),
|
||||||
result: summarizeResult(sub),
|
...resultPreview,
|
||||||
isError: sub.isError,
|
isError: sub.isError,
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
@@ -958,22 +974,39 @@ function expandSubCalls(
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
function summarizeCall(name: string, argsRaw: string): string {
|
function summarizeCall(
|
||||||
const args = trajectoryPreviewText(argsRaw)
|
name: string,
|
||||||
if (args === '') return name
|
argsRaw: string,
|
||||||
return `${name} · ${args}`
|
): 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) {
|
if (node.isError) {
|
||||||
return node.error?.code ?? 'error'
|
return { result: node.error?.code ?? 'error' }
|
||||||
}
|
}
|
||||||
for (const block of node.content) {
|
for (const block of node.content) {
|
||||||
if (block.type === 'text' && typeof block.text === 'string' && block.text !== '') {
|
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 {
|
function detailResult(node: ToolResultNode): string {
|
||||||
@@ -1009,28 +1042,18 @@ function detailReasoning(content: readonly { type: string; text?: string }[]): s
|
|||||||
.join('\n')
|
.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) {
|
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 {
|
function previewContentProperty(
|
||||||
return trajectoryPreviewText(text)
|
content: readonly { type: string; text?: string }[],
|
||||||
}
|
): Pick<TrajectoryCellProps, 'previewMarkdown'> {
|
||||||
|
const previewMarkdown = previewContent(content)
|
||||||
/**
|
return previewMarkdown === undefined ? {} : { previewMarkdown }
|
||||||
* 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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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. */
|
/** Projection-stable identity when no single source event owns the record lifecycle. */
|
||||||
recordId?: string
|
recordId?: string
|
||||||
kind: TrajectoryCellKind
|
kind: TrajectoryCellKind
|
||||||
/** Single-line summary; CSS ellipsis when it overflows. */
|
/** Non-Markdown summary or prefix; CSS ellipsis when it overflows. */
|
||||||
text: string
|
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. */
|
/** Whether this user record opens a new model turn. */
|
||||||
opensTurn?: boolean
|
opensTurn?: boolean
|
||||||
/** Source session-event seq for cross-record navigation. */
|
/** Source session-event seq for cross-record navigation. */
|
||||||
@@ -71,6 +73,8 @@ export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {
|
|||||||
assistantMetrics?: AssistantMetricDetail
|
assistantMetrics?: AssistantMetricDetail
|
||||||
/** Tool-only result summary paired with the call in the same record. */
|
/** Tool-only result summary paired with the call in the same record. */
|
||||||
result?: string
|
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. */
|
/** Tool call id used to link message source blocks to tool records. */
|
||||||
callId?: string
|
callId?: string
|
||||||
/** Tool-only result failure state. */
|
/** Tool-only result failure state. */
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user