mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
perf(ui-trajectory): defer trajectory text processing
This commit is contained in:
+2
-2
@@ -2,5 +2,5 @@
|
||||
# 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:
|
||||
# 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.zh.md: 6f0acc448950cdedcb249ada8cfd931a21765b3e
|
||||
2026-08-09-client-conversation-node-assembly.md: 3b02fde8b5c8da0c7086a2de65a5ae8eea8b2526
|
||||
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 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
|
||||
|
||||
```text
|
||||
|
||||
+2
@@ -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 的 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
|
||||
|
||||
```text
|
||||
|
||||
@@ -24,9 +24,11 @@ interface AnchorProps {
|
||||
onBlur?: FocusEventHandler | undefined
|
||||
}
|
||||
|
||||
type TooltipLabel = string | (() => string)
|
||||
|
||||
/**
|
||||
* 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.delayMs - hover delay in milliseconds; keyboard focus remains immediate.
|
||||
* @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.
|
||||
* @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)
|
||||
// React 18 keeps the element's ref outside props; forward it so wrapping an
|
||||
// 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])
|
||||
const [pos, setPos] = useState<{ x: number; y: number } | 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
|
||||
// a centered bubble near the right edge would clip. Each measurement resets
|
||||
// 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()
|
||||
window.addEventListener('resize', clamp)
|
||||
return () => { window.removeEventListener('resize', clamp) }
|
||||
}, [label, pos])
|
||||
}, [pos, resolvedLabel])
|
||||
const showTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
// Hover and focus are independent triggers: the bubble hides only after
|
||||
// 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 && (
|
||||
<span ref={bubble} className={css.bubble} data-side={side} style={{ left: pos.x, top: pos.y }} role="tooltip">
|
||||
{label}
|
||||
{resolvedLabel}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -6,6 +6,27 @@ import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
afterEach(cleanup)
|
||||
|
||||
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', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
|
||||
@@ -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
|
||||
@@ -903,6 +904,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 +918,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 +944,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,
|
||||
@@ -2131,15 +2208,12 @@ 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(' ')
|
||||
{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'
|
||||
@@ -2169,7 +2243,6 @@ export function TrajectoryTable({
|
||||
: activeTurn === record.turn
|
||||
return (
|
||||
<tr
|
||||
key={trajectoryVirtualRecordKey(record)}
|
||||
tabIndex={isRequestOnly ? -1 : 0}
|
||||
aria-rowindex={position + 1}
|
||||
aria-label={isCollapsedSummary
|
||||
@@ -2348,37 +2421,26 @@ export function TrajectoryTable({
|
||||
)
|
||||
: (
|
||||
<span
|
||||
className={record.cell.result === undefined ? css.contentText : css.resultPreview}
|
||||
title={record.cell.result === undefined
|
||||
className={resultText === undefined ? css.contentText : css.resultPreview}
|
||||
title={resultText === undefined
|
||||
? listDisplayText
|
||||
: `${listDisplayText} → ${record.cell.result}`}
|
||||
: `${listDisplayText} → ${resultText}`}
|
||||
>
|
||||
<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 className={resultText === undefined ? undefined : css.resultRequest}>
|
||||
<RecordListText
|
||||
displayText={displayText}
|
||||
toolCallOnly={toolCallOnly}
|
||||
toolCallText={toolCallText}
|
||||
/>
|
||||
</span>
|
||||
{record.cell.result !== undefined && (
|
||||
{resultText !== undefined && (
|
||||
<span className={record.cell.isError ? `${css.inlineResult} ${css.error}` : css.inlineResult}>
|
||||
<span className={css.arrow}>→</span>
|
||||
<span className={record.cell.result === 'No output'
|
||||
<span className={resultText === 'No output'
|
||||
? `${css.inlineResultText} ${css.noOutputText}`
|
||||
: css.inlineResultText}
|
||||
>
|
||||
{record.cell.result}
|
||||
{resultText}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
@@ -2387,7 +2449,9 @@ export function TrajectoryTable({
|
||||
</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}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** 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 { InjectFace } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {
|
||||
@@ -24,11 +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
|
||||
@@ -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({
|
||||
useSession, useDuration, loadOlder, setActualDuration,
|
||||
inspect, onInspectDone,
|
||||
@@ -190,6 +128,10 @@ export function TrajectoryView({
|
||||
const actualDuration = useDuration(value => value)
|
||||
const [actualTime, setActualTime] = useState(false)
|
||||
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 [timelineRecordSelection, setTimelineRecordSelection] = useState<{
|
||||
readonly index: number
|
||||
@@ -340,28 +282,59 @@ 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 searchMatchIndexes = useMemo(
|
||||
() => mergeSearchMatches(finalizedSearchMatches, partialSearchMatches),
|
||||
[finalizedSearchMatches, partialSearchMatches],
|
||||
const searchMatchRecordIds = useMemo(
|
||||
() => searchIndex.search(searchQuery),
|
||||
[searchIndex, searchIndexRevision, searchQuery],
|
||||
)
|
||||
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
|
||||
|
||||
@@ -12,7 +12,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,
|
||||
@@ -70,9 +69,6 @@ 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' }
|
||||
@@ -110,10 +106,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),
|
||||
@@ -293,7 +298,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
|
||||
? {}
|
||||
@@ -377,6 +385,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 +395,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 +449,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 +659,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 +693,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 +701,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,
|
||||
}
|
||||
: {}),
|
||||
@@ -919,6 +932,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 +941,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 +953,7 @@ function expandSubCalls(
|
||||
? {
|
||||
outputDetail: detailResult(sub),
|
||||
outputBlocks: sub.content.map(block => sourceBlock(block)),
|
||||
result: summarizeResult(sub),
|
||||
...resultPreview,
|
||||
isError: sub.isError,
|
||||
}
|
||||
: {}),
|
||||
@@ -958,22 +974,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 +1042,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,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. */
|
||||
|
||||
@@ -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