From f99bcb79e704806e609cfff42d039384cd928b4d Mon Sep 17 00:00:00 2001 From: wizardchen Date: Wed, 27 May 2026 22:35:16 +0800 Subject: [PATCH] feat(timeline): scale axis to async tail, enrich span payloads, polish UI The post-process stage closes in ~9ms (just enqueue work) but its async subspans (postprocess.summary, postprocess.question, postprocess.graph) keep producing rows for tens of seconds AFTER the root finalizes. The old timeline used trace.duration_ms as the time axis maximum, which clipped those subspan bars past the right edge. Timeline: - totalMs now always takes max(trace.duration_ms, observed-tail), so the axis stretches to fit the latest descendant end regardless of parse_status. - Render a faint dashed wrapping outline behind a parent span when its descendants extend past its own finished_at, so the postprocess stage row visibly spans the full window without overloading the 9ms self-time bar. - Tree expand/collapse caret bumped from 10 to 14px in a 16x16 hit area; copy icons in detail panel bumped from 11/14 to 14/18px; .kp-kv-copy button grown from 18 to 22px. - Short input/output payloads (<= 8 entries / <= 600 bytes JSON) auto-expand inline so users see the actual data without an extra click; longer payloads keep the click-to-expand summary. Span payloads (subspans only - root keeps the canonical identity, no duplicate knowledge_id/kb_id/tenant_id on every child): - extract.go: graph subspan output gains chunk_chars, chunk_preview, sample_nodes, sample_relations. - summary subspan output gains model_id, summary_preview. - question subspan output gains model_id and a sample_question captured from the first non-empty LLM response. i18n: new key knowledgeStages.detail.includingChildren for the wrapping-bar tooltip. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../knowledge-processing-timeline.vue | 197 ++++++++++++++++-- frontend/src/i18n/locales/en-US.ts | 1 + frontend/src/i18n/locales/ko-KR.ts | 1 + frontend/src/i18n/locales/zh-CN.ts | 1 + internal/application/service/extract.go | 33 +++ .../application/service/knowledge_process.go | 34 ++- 6 files changed, 247 insertions(+), 20 deletions(-) diff --git a/frontend/src/components/knowledge-processing-timeline.vue b/frontend/src/components/knowledge-processing-timeline.vue index 7c9677b8..3346fee9 100644 --- a/frontend/src/components/knowledge-processing-timeline.vue +++ b/frontend/src/components/knowledge-processing-timeline.vue @@ -360,11 +360,18 @@ const tEnd = computed(() => { const totalMs = computed(() => { if (t0.value === null || tEnd.value === null) return 0 + // The trace's own duration_ms only covers the parsing pipeline up to + // FinalizeAttempt. Async post-processing subspans (summary / question / + // graph) keep producing rows AFTER the root closes — so the time axis + // must scale to the latest descendant end, otherwise their bars get + // clipped past the right edge. Take the max of (root duration, observed + // span tail) regardless of polling state. + const observed = Math.max(0, tEnd.value - t0.value) const traceDur = data.value?.trace?.duration_ms - if (typeof traceDur === 'number' && traceDur > 0 && !isPolling(data.value?.parse_status)) { - return traceDur + if (typeof traceDur === 'number' && traceDur > 0) { + return Math.max(traceDur, observed) } - return Math.max(0, tEnd.value - t0.value) + return observed }) const showRuler = computed(() => totalMs.value >= 50) @@ -472,6 +479,48 @@ function barStyle(node: SpanNode): Record { } } +// Wrapping outline bar — when a span's children extend past the parent's +// own finished_at (typical for postprocess: stage closes in ~9ms but its +// async summary/question subspans run for tens of seconds), we render a +// faint outline from the parent's start to the latest descendant end. +// This makes "this stage's downstream work took N seconds total" visible +// at a glance without conflating it with the stage's self-duration. +function descendantMaxEnd(node: SpanNode): number | null { + const ends: number[] = [] + for (const c of node.children || []) { + collectEnds(c, ends) + } + if (ends.length === 0) return null + return Math.max(...ends) +} + +function wrapStyle(node: SpanNode): Record | null { + const total = totalMs.value + if (!total || t0.value === null) return null + const start = nodeStart(node) + if (start === null) return null + const selfEnd = nodeEnd(node) ?? start + const childEnd = descendantMaxEnd(node) + if (childEnd === null) return null + // Only render the wrapping bar when descendants extend at least 50ms + // past the parent — otherwise the outline is indistinguishable from + // the solid self-bar and only adds visual noise. + if (childEnd - selfEnd < 50) return null + const leftPct = ((start - t0.value) / total) * 100 + const widthPct = Math.max(0.4, ((childEnd - start) / total) * 100) + return { + left: `${Math.max(0, Math.min(100, leftPct))}%`, + width: `${Math.min(100 - Math.max(0, leftPct), widthPct)}%`, + } +} + +function wrapDurationMs(node: SpanNode): number { + const start = nodeStart(node) + const childEnd = descendantMaxEnd(node) + if (start === null || childEnd === null) return 0 + return Math.max(0, childEnd - start) +} + function barOffsetPct(node: SpanNode): number | null { const total = totalMs.value if (!total || t0.value === null) return null @@ -606,6 +655,12 @@ interface KvEntry { kind: 'scalar' | 'bool' | 'array' | 'object' display: string raw: any + // True for short payloads — the panel skips the click-to-expand + // affordance and renders the JSON inline directly so users see the + // values without an extra click. Long payloads stay folded so the + // panel doesn't blow up vertically when an output has hundreds of + // entries. + defaultExpanded: boolean } function buildKvEntries(obj: any): KvEntry[] { @@ -617,28 +672,64 @@ function buildKvEntries(obj: any): KvEntry[] { return entries } +// Threshold for inline auto-expansion. Short payloads — small arrays / +// shallow objects — render inline directly. Anything larger keeps the +// click-to-expand summary so the detail panel doesn't grow without bound. +const KV_INLINE_ARRAY_LIMIT = 8 +const KV_INLINE_OBJECT_KEY_LIMIT = 8 +const KV_INLINE_JSON_BYTES_LIMIT = 600 + +function shouldInlineExpand(value: any): boolean { + if (Array.isArray(value)) { + if (value.length > KV_INLINE_ARRAY_LIMIT) return false + try { + return JSON.stringify(value).length <= KV_INLINE_JSON_BYTES_LIMIT + } catch { + return false + } + } + if (value && typeof value === 'object') { + const keys = Object.keys(value) + if (keys.length > KV_INLINE_OBJECT_KEY_LIMIT) return false + try { + return JSON.stringify(value).length <= KV_INLINE_JSON_BYTES_LIMIT + } catch { + return false + } + } + return false +} + function toKvEntry(key: string, value: any): KvEntry { const label = humanizeKey(key) if (value === null || value === undefined) { - return { key, label, kind: 'scalar', display: '—', raw: value } + return { key, label, kind: 'scalar', display: '—', raw: value, defaultExpanded: false } } if (typeof value === 'boolean') { - return { key, label, kind: 'bool', display: value ? 'true' : 'false', raw: value } + return { key, label, kind: 'bool', display: value ? 'true' : 'false', raw: value, defaultExpanded: false } } if (typeof value === 'number') { - return { key, label, kind: 'scalar', display: value.toLocaleString(), raw: value } + return { key, label, kind: 'scalar', display: value.toLocaleString(), raw: value, defaultExpanded: false } } if (typeof value === 'string') { - return { key, label, kind: 'scalar', display: value, raw: value } + return { key, label, kind: 'scalar', display: value, raw: value, defaultExpanded: false } } if (Array.isArray(value)) { - return { key, label, kind: 'array', display: `Array · ${value.length}`, raw: value } + return { + key, label, kind: 'array', + display: `Array · ${value.length}`, raw: value, + defaultExpanded: shouldInlineExpand(value), + } } if (typeof value === 'object') { const n = Object.keys(value as object).length - return { key, label, kind: 'object', display: `Object · ${n} keys`, raw: value } + return { + key, label, kind: 'object', + display: `Object · ${n} keys`, raw: value, + defaultExpanded: shouldInlineExpand(value), + } } - return { key, label, kind: 'scalar', display: String(value), raw: value } + return { key, label, kind: 'scalar', display: String(value), raw: value, defaultExpanded: false } } interface AttemptTab { @@ -964,6 +1055,24 @@ const stageBreakdown = computed(() => { />