feat(trajectory): remember raw result display mode

This commit is contained in:
_Kerman committed 2026-10-23 18:40:21 +08:00
1 parent 102b42d14a
commit 22c2493322
14 files changed
+166 -11

No files matched your search

@@ -24,8 +24,8 @@
en: 0f4ee2189324ac06
zh: 12e76fed22c09bc1
/deepseek-ai-dsh-client-ui-trajectory/understand-the-implementation:
en: 422f0f91eeab6707
zh: 3516d868cfbb0e02
en: 9b8478a403486fca
zh: 8679d31b30594774
/deepseek-ai-dsh-client-ui-trajectory/understand-the-implementation/virtual-rows:
en: c85e342bc7eee4e6
zh: 9feab66ef1755b94
+1 -1
View File
@@ -57,7 +57,7 @@ The view is a pure projection: Trajectory-owned Definitions assemble business re
User, steering, and context inputs and request prompt changes share their request's Step group in log order, before the request separator and assistant output.
Native and nested PTC Tool results retain their raw structured error details and presentation metadata. Failed records show the error code in the ledger and the error name and code in the inspector.
Native and nested PTC Tool results retain their raw structured error details and presentation metadata. The Result tab switches between rendered content and the recorded canonical value with a compact toggle. Rendered content is the default; the browser-wide preference survives record, tab, and Session changes and reloads. Missing values in older or failed records show an explicit notice without resetting the preference. JSON primitives, including `null`, remain inspectable and copyable. Summary previews continue to show rendered content. Failed records show the error code in the ledger and the error name and code in the inspector.
Tool records begin at durable tool/call events and retain complete argument text with a lazy `args` view, reused by paired results. Chat's transient preparing stage does not create Trajectory tool rows or alter historical tool timing.
+1 -1
View File
@@ -57,7 +57,7 @@ Trajectory 标签页让你以按轮次组织的事件记录表和交互式时间
用户消息、中途引导、上下文输入及请求提示词变更按日志顺序放在所属请求的 Step 分组内,位于请求分隔行和助手输出之前。
原生与嵌套 PTC 工具结果保留原始结构化错误详情和展示元数据。失败记录在记录表中显示错误代码,在检查器中显示错误名称和代码。
原生与嵌套 PTC 工具结果保留原始结构化错误详情和展示元数据。结果标签页通过紧凑按钮切换渲染内容与已记录的规范值。默认显示渲染内容;浏览器级偏好在切换记录、标签页、Session 及刷新页面后保留。旧记录或失败记录缺少原始值时显示明确提示,不重置偏好。包括 `null` 在内的 JSON 基本值均可查看和复制。概述预览仍显示渲染内容。失败记录在记录表中显示错误代码,在检查器中显示错误名称和代码。
工具记录从持久 tool/call 事件开始,保留完整参数文本和由配对结果复用的懒计算 `args` 视图。Chat 的瞬态 preparing 阶段不创建 Trajectory 工具行,也不改变历史工具计时。
@@ -50,6 +50,7 @@ export function TrajectoryCell({
promptDetail: _promptDetail,
previousPromptDetail: _previousPromptDetail,
outputDetail: _outputDetail,
resultValue: _resultValue,
thinkingDetail: _thinkingDetail,
sourceBlocks: _sourceBlocks,
outputBlocks: _outputBlocks,
@@ -388,6 +388,10 @@ function AssistantTimingPanel({
/** Props for the trajectory ledger. */
export interface TrajectoryTableProps {
/** Browser-wide result mode, independent of record and inspector tab selection. */
rawResult?: boolean
/** Persist an explicit result mode selection. */
onResultModeChange?: (raw: boolean) => void
/** Wrapping control shared by all JSON inspectors. */
stringWrapping?: JsonTreeProps['stringWrapping']
/** Trajectory locale seat. */
@@ -2083,6 +2087,8 @@ function OverviewSection({
* @returns The ledger and an optional local record inspector.
*/
export function TrajectoryTable({
rawResult = false,
onResultModeChange,
t,
stringWrapping,
renderSlot,
@@ -3520,9 +3526,39 @@ export function TrajectoryTable({
stringWrapping={stringWrapping} t={t} />
)}
{!promptSelected && selected !== undefined && activeTab === 'output' && (
selectedProgram === undefined
? <RecordPayload record={selected} direction="output" renderSlot={renderSlot} loadImage={loadImage} stringWrapping={stringWrapping} t={t} />
: <ProgramOutput record={selected} stringWrapping={stringWrapping} t={t} />
<>
{(selected.cell.kind === 'tool' || selected.cell.kind === 'subtool') && (
<header className={css.overviewHeading}>
<span>{t(rawResult ? 'record.rawResult' : 'record.renderedResult')}</span>
<span className={css.programActions}>
<Tooltip label={t('record.rawResult')}>
<button type="button" className={css.programAction}
aria-label={t('record.rawResult')} aria-pressed={rawResult}
aria-controls="trajectory-result-content"
onClick={() => { onResultModeChange?.(!rawResult) }}>
<IconCodeOutlineRegular size={12} />
</button>
</Tooltip>
{rawResult && selected.cell.resultValue !== undefined && (
<InspectorCopyButton text={JSON.stringify(selected.cell.resultValue, null, 2)}
label={t('copy.json')} t={t} />
)}
</span>
</header>
)}
<div id="trajectory-result-content">
{rawResult && (selected.cell.kind === 'tool' || selected.cell.kind === 'subtool')
? selected.cell.resultValue === undefined
? <p className={css.noPayload}>{t('record.rawResultUnavailable')}</p>
: selected.cell.resultValue !== null && typeof selected.cell.resultValue === 'object'
? <JsonTree data={selected.cell.resultValue} label={t('record.resultJson')}
labels={jsonTreeLabels(t)} stringWrapping={stringWrapping} />
: <pre className={css.jsonPayload}>{JSON.stringify(selected.cell.resultValue)}</pre>
: selectedProgram === undefined
? <RecordPayload record={selected} direction="output" renderSlot={renderSlot} loadImage={loadImage} stringWrapping={stringWrapping} t={t} />
: <ProgramOutput record={selected} stringWrapping={stringWrapping} t={t} />}
</div>
</>
)}
{!promptSelected && selected !== undefined && activeTab === 'schema' && (
<RecordSchema record={selected} stringWrapping={stringWrapping} t={t} />
@@ -79,10 +79,12 @@ export interface TrajectoryViewInjected {
jsonStringWrapping?: Omit<NonNullable<JsonTreeProps['stringWrapping']>, 'label'>
hooks: {
duration: SnapshotStore<boolean>
resultMode: SnapshotStore<boolean>
}
loadOlder: () => Promise<boolean>
loadImage: MessageImageLoader
setActualDuration: (actualDuration: boolean) => void
setResultMode: (raw: boolean) => void
}
interface UsageLike {
@@ -130,7 +132,7 @@ function addUsage(
}
export function TrajectoryView({
useSession, useTrajectory, useDuration, loadOlder, loadImage, setActualDuration,
useSession, useTrajectory, useDuration, useResultMode, setResultMode, loadOlder, loadImage, setActualDuration,
viewRequest, completeViewRequest, renderSlot, t, jsonStringWrapping,
}: ConvViewProps
& Pick<PropsRenderSlots<'conversation.trajectory.images'>, 'renderSlot'>
@@ -141,6 +143,7 @@ export function TrajectoryView({
useState<ReadonlySet<string>>(EMPTY_RECORD_IDS)
const [timelineSelection, setTimelineSelection] = useState<TrajectoryTimeRange | null>(null)
const actualDuration = useDuration(value => value)
const rawResult = useResultMode(value => value)
const [actualTime, setActualTime] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [searchIndex] = useState(() => new TrajectorySearchIndex())
@@ -546,6 +549,8 @@ export function TrajectoryView({
<div className={css.ledger}>
<TrajectoryTable
t={t}
rawResult={rawResult}
onResultModeChange={setResultMode}
stringWrapping={jsonStringWrapping === undefined ? undefined : {
...jsonStringWrapping,
label: t('record.wrapLines'),
@@ -14,6 +14,7 @@ import type {} from '@deepseek-ai/dsh-client-locale/client'
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import type {} from '@deepseek-ai/dsh-client-ui-session/client'
import { createTrajectoryResultModeStore } from './result-mode-store.ts'
import { createTrajectoryDurationStore } from './duration-store.ts'
import { createTrajectoryStringWrappingStore } from './string-wrapping-store.ts'
import { en, ja, es, fr, de, ru, pt, NS, zh } from './locales.ts'
@@ -64,6 +65,7 @@ export function apply(ctx: Context): void {
// translate as a thunk, so it follows the active locale without
// re-registration.
const t = ctx.locale.bind(NS)
const resultMode = createTrajectoryResultModeStore()
const duration = createTrajectoryDurationStore()
const stringWrapping = createTrajectoryStringWrappingStore()
registerTrajectoryMessageDefinitions(ctx)
@@ -90,7 +92,8 @@ export function apply(ctx: Context): void {
}
const trajectory = ctx.uiConversation.binding(sessionId).target('trajectory')
return {
hooks: { duration },
hooks: { duration, resultMode },
setResultMode: (value) => { resultMode.set(value) },
jsonStringWrapping: {
getDefault: () => stringWrapping.getSnapshot(),
setDefault: (value) => { stringWrapping.set(value) },
@@ -465,6 +465,7 @@ export function deriveTrajectoryLayout(
: resultAsText(resultPreview)),
...(node.call !== null ? { inputDetail: node.call.argsRaw } : {}),
outputDetail: detailResult(node, t),
...node.value === undefined ? {} : { resultValue: node.value },
outputBlocks: node.content.map(block => sourceBlock(block)),
...resultPreview,
callId: node.callId,
@@ -785,6 +786,7 @@ function expandAssistant(
...(result !== undefined
? {
outputDetail: detailResult(result, t),
...result.value === undefined ? {} : { resultValue: result.value },
outputBlocks: result.content.map(block => sourceBlock(block)),
...resultPreview,
isError: result.isError,
@@ -1032,6 +1034,7 @@ function expandSubCalls(
...(settled
? {
outputDetail: detailResult(sub, t),
...sub.value === undefined ? {} : { resultValue: sub.value },
outputBlocks: sub.content.map(block => sourceBlock(block)),
...resultPreview,
isError: sub.isError,
@@ -108,6 +108,9 @@ export const zh = {
'record.schemaUnavailable': 'Schema 不可用',
'record.parameters': '参数',
'record.resultJson': '结果 JSON',
'record.rawResult': '原始结果',
'record.renderedResult': '渲染结果',
'record.rawResultUnavailable': '此记录未保存原始结果',
'record.json': 'JSON',
'record.parametersJson': '参数 JSON',
'record.namedParametersJson': '{name} 参数 JSON',
@@ -318,6 +321,9 @@ export const en: Record<TrajectoryKey, string> = {
'record.schemaUnavailable': 'Schema unavailable',
'record.parameters': 'Parameters',
'record.resultJson': 'Result JSON',
'record.rawResult': 'Raw result',
'record.renderedResult': 'Rendered result',
'record.rawResultUnavailable': 'No raw result was recorded',
'record.json': 'JSON',
'record.parametersJson': 'parameters JSON',
'record.namedParametersJson': '{name} parameters JSON',
@@ -514,6 +520,9 @@ export const ja = {
'record.schemaUnavailable': 'スキーマを利用できません',
'record.parameters': 'パラメータ',
'record.resultJson': '結果 JSON',
'record.rawResult': '元の結果',
'record.renderedResult': '表示用の結果',
'record.rawResultUnavailable': '元の結果は記録されていません',
'record.json': 'JSON',
'record.parametersJson': 'パラメータ JSON',
'record.namedParametersJson': '{name} パラメータ JSON',
@@ -710,6 +719,9 @@ export const es = {
'record.schemaUnavailable': 'Esquema no disponible',
'record.parameters': 'Parámetros',
'record.resultJson': 'Resultado JSON',
'record.rawResult': 'Resultado original',
'record.renderedResult': 'Resultado renderizado',
'record.rawResultUnavailable': 'No se registró ningún resultado original',
'record.json': 'JSON',
'record.parametersJson': 'parámetros JSON',
'record.namedParametersJson': 'Parámetros de {name} JSON',
@@ -906,6 +918,9 @@ export const fr = {
'record.schemaUnavailable': 'Schéma indisponible',
'record.parameters': 'Paramètres',
'record.resultJson': 'Résultat JSON',
'record.rawResult': 'Résultat brut',
'record.renderedResult': 'Résultat rendu',
'record.rawResultUnavailable': 'Aucun résultat brut enregistré',
'record.json': 'JSON',
'record.parametersJson': 'paramètres JSON',
'record.namedParametersJson': 'Paramètres {name} JSON',
@@ -1102,6 +1117,9 @@ export const de = {
'record.schemaUnavailable': 'Schema nicht verfügbar',
'record.parameters': 'Parameter',
'record.resultJson': 'Ergebnis JSON',
'record.rawResult': 'Rohergebnis',
'record.renderedResult': 'Gerendertes Ergebnis',
'record.rawResultUnavailable': 'Kein Rohergebnis aufgezeichnet',
'record.json': 'JSON',
'record.parametersJson': 'Parameter JSON',
'record.namedParametersJson': '{name} Parameter JSON',
@@ -1298,6 +1316,9 @@ export const ru = {
'record.schemaUnavailable': 'Схема недоступна',
'record.parameters': 'Параметры',
'record.resultJson': 'Результат JSON',
'record.rawResult': 'Исходный результат',
'record.renderedResult': 'Отображаемый результат',
'record.rawResultUnavailable': 'Исходный результат не записан',
'record.json': 'JSON',
'record.parametersJson': 'параметры JSON',
'record.namedParametersJson': '{name} параметры JSON',
@@ -1494,6 +1515,9 @@ export const pt = {
'record.schemaUnavailable': 'Esquema indisponível',
'record.parameters': 'Parâmetros',
'record.resultJson': 'Resultado JSON',
'record.rawResult': 'Resultado original',
'record.renderedResult': 'Resultado renderizado',
'record.rawResultUnavailable': 'Nenhum resultado original foi registrado',
'record.json': 'JSON',
'record.parametersJson': 'parâmetros JSON',
'record.namedParametersJson': '{name} parâmetros JSON',
@@ -0,0 +1,10 @@
/** Browser-wide preference for canonical tool result inspection. */
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
/**
* Create the persisted result mode shared by trajectory views.
* @returns True for canonical values, false for rendered content (the default).
*/
export function createTrajectoryResultModeStore(): SnapshotStore<boolean> {
return createSnapshotStore(false, { persist: { name: 'dsh.trajectory.rawResult' } })
}
@@ -1,6 +1,7 @@
/** Shared trajectory record data and formatting contracts. */
import type { HTMLAttributes } from 'react'
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type { ConversationPromptSnapshot } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { TrajectoryTranslate } from './locales.ts'
@@ -66,6 +67,8 @@ export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {
previousPromptDetail?: ConversationPromptSnapshot
/** Full assistant/tool result content for the details panel. */
outputDetail?: string
/** Final canonical tool value; absent on failures and records created before value recording. */
resultValue?: JsonValue
/** Full assistant reasoning content for the details panel. */
thinkingDetail?: string
/** Original message blocks in source order for the details panel. */
@@ -0,0 +1,26 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, expect, it } from 'vitest'
import { createTrajectoryResultModeStore } from '../src/client/result-mode-store.ts'
const key = 'dsh.trajectory.jsonResultMode'
let original: string | null
beforeEach(() => {
original = localStorage.getItem(key)
localStorage.removeItem(key)
})
afterEach(() => {
if (original === null) localStorage.removeItem(key)
else localStorage.setItem(key, original)
})
it('restores the last result mode when the view is recreated', () => {
const first = createTrajectoryResultModeStore()
expect(first.getSnapshot()).toBe(false)
first.set(true)
const second = createTrajectoryResultModeStore()
expect(second.getSnapshot()).toBe(true)
second.set(false)
expect(createTrajectoryResultModeStore().getSnapshot()).toBe(false)
})
@@ -1133,6 +1133,38 @@ describe('TrajectoryTable', () => {
expect(turnLabel.textContent).toContain('#1')
})
it.each([null, false, 0, '', { answer: 42 }])('switches to the recorded value %j and retains the mode across records and tabs', (value) => {
const turns: readonly TrajectoryTurnModel[] = [{ turn: 1, groups: [{ title: 'Step 1', cells: [
{ index: 1, kind: 'tool', text: 'recorded', outputDetail: 'rendered answer', resultValue: value, timeSeconds: 0.1 },
{ index: 2, kind: 'subtool', text: 'historical', outputDetail: 'old answer', timeSeconds: 0.1 },
] }] }]
const changes = vi.fn()
function Harness() {
const [raw, setRaw] = useState(false)
return <TrajectoryTable turns={turns} {...FOLD_PROPS} rawResult={raw}
onResultModeChange={(next) => { changes(next); setRaw(next) }} />
}
render(<Harness />)
fireEvent.click(screen.getByRole('row', { name: /recorded/ }))
fireEvent.click(screen.getByRole('tab', { name: 'Result' }))
expect(screen.getByText('rendered answer')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Raw result' }))
expect(document.getElementById('trajectory-result-content')?.textContent).toContain(
typeof value === 'object' && value !== null ? 'answer:' : JSON.stringify(value),
)
fireEvent.click(screen.getByRole('row', { name: /historical/ }))
expect(screen.getByText('No raw result was recorded')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Raw result' }).getAttribute('aria-pressed')).toBe('true')
fireEvent.click(screen.getByRole('tab', { name: 'Summary' }))
fireEvent.click(screen.getByRole('tab', { name: 'Result' }))
fireEvent.click(screen.getByRole('row', { name: /recorded/ }))
expect(screen.queryByText('No raw result was recorded')).toBeNull()
expect(changes.mock.calls).toEqual([[true]])
fireEvent.click(screen.getByRole('button', { name: 'Raw result' }))
expect(screen.getByText('rendered answer')).toBeTruthy()
expect(changes.mock.calls).toEqual([[true], [false]])
})
it('renders a single-text JSON tool result as a JSON tree', () => {
const turns: readonly TrajectoryTurnModel[] = [{
turn: 1,
@@ -162,10 +162,13 @@ function standaloneHistory(
}
function standaloneDuration(): Pick<
ComponentProps<typeof TrajectoryView>, 'useDuration' | 'setActualDuration'
ComponentProps<typeof TrajectoryView>, 'useDuration' | 'setActualDuration' | 'useResultMode' | 'setResultMode'
> {
const duration = createSnapshotStore(false)
const resultMode = createSnapshotStore(false)
return {
useResultMode: bindSnapshotSelector(resultMode),
setResultMode: (value) => { resultMode.set(value) },
useDuration: bindSnapshotSelector(duration),
setActualDuration: (value) => { duration.set(value) },
}
@@ -204,7 +207,7 @@ const useProjection: UseProjection = emptyProjection
type StandaloneBaseProps = Omit<
ComponentProps<typeof TrajectoryView>,
'useSession' | 'useTrajectory' | 'useDuration' | 'loadOlder' | 'setActualDuration'
'useSession' | 'useTrajectory' | 'useDuration' | 'loadOlder' | 'setActualDuration' | 'useResultMode' | 'setResultMode'
>
/** Standalone view props: the session-scope standard kit the outlet would bake. */
@@ -391,6 +394,8 @@ function mount(fixture: Awaited<ReturnType<typeof bench>>) {
return {
loadOlder: trajectory.loadOlder,
loadImage: trajectory.loadImage,
setResultMode: trajectory.setResultMode,
useResultMode: bindSnapshotSelector(trajectory.hooks.resultMode),
setActualDuration: trajectory.setActualDuration,
useDuration: bindSnapshotSelector(trajectory.hooks.duration),
t: tZh,
@@ -496,6 +501,11 @@ describe('plugin registration', () => {
await reference.ready
const second = injectEntry(reference.sessionId)
expect(second.hooks.resultMode).toBe(first.hooks.resultMode)
first.setResultMode(true)
expect(second.hooks.resultMode.getSnapshot()).toBe(true)
expect(localStorage.getItem('dsh.trajectory.rawResult')).toBe('true')
first.setResultMode(false)
expect(second.hooks.duration).toBe(first.hooks.duration)
first.setActualDuration(true)
expect(second.hooks.duration.getSnapshot()).toBe(true)
@@ -1541,6 +1551,7 @@ describe('TrajectoryView state', () => {
const first = render(
<TrajectoryView
{...commonProps}
{...standaloneDuration()}
useDuration={bindSnapshotSelector(firstDuration)}
setActualDuration={(value) => { firstDuration.set(value) }}
/>,
@@ -1556,6 +1567,7 @@ describe('TrajectoryView state', () => {
render(
<TrajectoryView
{...commonProps}
{...standaloneDuration()}
useDuration={bindSnapshotSelector(restoredDuration)}
setActualDuration={(value) => { restoredDuration.set(value) }}
/>,