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) <noreply@anthropic.com>
This commit is contained in:
wizardchen
2026-05-27 22:35:16 +08:00
committed by lyingbug
parent bd029b6d19
commit f99bcb79e7
6 changed files with 247 additions and 20 deletions

View File

@@ -360,11 +360,18 @@ const tEnd = computed<number | null>(() => {
const totalMs = computed<number>(() => {
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<string, string> {
}
}
// 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<string, string> | 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<StageRowSummary[]>(() => {
/>
<div v-if="isPlaceholder(row.node)" class="kp-bar kp-bar-placeholder" />
<template v-else>
<!-- Wrapping outline: descendants extend past this
span's own end (e.g. async postprocess subspans
under a closed stage). Renders behind the solid
self-bar so both are visible. -->
<div
v-if="wrapStyle(row.node)"
class="kp-bar-wrap"
:class="['kp-bar-wrap-' + row.node.status]"
:style="wrapStyle(row.node) || {}"
>
<span class="kp-bar-tip">
<span class="kp-bar-tip-name">{{ rowLabel(row) }}</span>
<span class="kp-bar-tip-sep">·</span>
<span class="kp-mono">{{ formatDuration(wrapDurationMs(row.node)) }}</span>
<span class="kp-bar-tip-sep">·</span>
<span>{{ t('knowledgeStages.detail.includingChildren') }}</span>
</span>
</div>
<div
class="kp-bar"
:class="['kp-bar-' + row.node.status, { 'kp-bar-running-anim': row.node.status === 'running' }]"
@@ -1021,10 +1130,10 @@ const stageBreakdown = computed<StageRowSummary[]>(() => {
</div>
<div class="kp-detail-actions">
<button type="button" class="kp-icon-btn" :title="t('knowledgeStages.copyDetails')" @click.stop="copySpan(selectedRow.node)">
<t-icon name="copy" size="14px" />
<t-icon name="copy" size="18px" />
</button>
<button type="button" class="kp-icon-btn" :title="t('knowledgeStages.close')" @click="closeDetail">
<t-icon name="close" size="14px" />
<t-icon name="close" size="18px" />
</button>
</div>
</div>
@@ -1122,7 +1231,7 @@ const stageBreakdown = computed<StageRowSummary[]>(() => {
:title="t('knowledgeStages.copy')"
@click.stop="copyValue(entry.value)"
>
<t-icon name="copy" size="11px" />
<t-icon name="copy" size="14px" />
</button>
</span>
</div>
@@ -1183,7 +1292,7 @@ const stageBreakdown = computed<StageRowSummary[]>(() => {
class="kp-section-action"
@click="copyValue((selectedRow.node as any)[detailTab])"
>
<t-icon name="copy" size="11px" />
<t-icon name="copy" size="14px" />
<span>{{ t('knowledgeStages.copy') }}</span>
</button>
</div>
@@ -1198,6 +1307,14 @@ const stageBreakdown = computed<StageRowSummary[]>(() => {
<div class="kp-kv-val kp-kv-multiline">
<span v-if="entry.kind === 'bool'" class="kp-mono" :class="{ 'kp-bool-true': entry.raw, 'kp-bool-false': !entry.raw }">{{ entry.display }}</span>
<span v-else-if="entry.kind === 'scalar'" class="kp-kv-scalar">{{ entry.display }}</span>
<!-- Short payloads render inline so the user
sees the data without an extra click. The
summary chip ("Array · 3") is shown above
the JSON for context. -->
<div v-else-if="entry.defaultExpanded" class="kp-kv-inline">
<span class="kp-kv-summary kp-mono kp-kv-summary-static">{{ entry.display }}</span>
<pre class="kp-json kp-mono">{{ prettyJSON(entry.raw) }}</pre>
</div>
<div v-else class="kp-kv-collapsible">
<button
type="button"
@@ -1231,7 +1348,7 @@ const stageBreakdown = computed<StageRowSummary[]>(() => {
class="kp-section-action"
@click="copyValue(selectedRow.node)"
>
<t-icon name="copy" size="11px" />
<t-icon name="copy" size="14px" />
<span>{{ t('knowledgeStages.copy') }}</span>
</button>
</div>
@@ -1625,8 +1742,10 @@ const stageBreakdown = computed<StageRowSummary[]>(() => {
padding: 0;
cursor: pointer;
color: var(--td-text-color-placeholder);
font-size: 10px;
font-size: 14px;
line-height: 1;
width: 16px;
height: 16px;
transition: transform 150ms ease, color 120ms ease;
flex-shrink: 0;
border-radius: var(--td-radius-small);
@@ -1637,7 +1756,7 @@ const stageBreakdown = computed<StageRowSummary[]>(() => {
background: var(--td-bg-color-container-hover);
}
.kp-tree-toggle-open { transform: rotate(90deg); }
.kp-tree-toggle-spacer { width: 14px; height: 14px; display: inline-block; flex-shrink: 0; }
.kp-tree-toggle-spacer { width: 16px; height: 16px; display: inline-block; flex-shrink: 0; }
.kp-status-dot {
width: 7px;
@@ -1784,6 +1903,32 @@ const stageBreakdown = computed<StageRowSummary[]>(() => {
background: var(--td-brand-color);
}
/* Wrapping outline bar — shows the full window from this span's start
to the latest descendant end. Used when async children extend past
the parent's own finished_at (e.g. postprocess stage closes fast but
its summary/question subspans run for a long time). */
.kp-bar-wrap {
position: absolute;
top: 9px;
height: 14px;
border: 1px dashed var(--td-component-border);
border-radius: var(--td-radius-small);
background: transparent;
min-width: 4px;
z-index: 1;
pointer-events: auto;
transition: left 800ms cubic-bezier(0.2, 0.8, 0.2, 1),
width 800ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.kp-bar-wrap:hover { border-color: var(--td-text-color-secondary); }
.kp-bar-wrap:hover .kp-bar-tip { opacity: 1; }
.kp-bar-wrap-done { border-color: rgba(7, 192, 95, 0.35); }
.kp-bar-wrap-failed { border-color: rgba(229, 87, 64, 0.5); }
.kp-bar-wrap-running { border-color: rgba(7, 192, 95, 0.4); }
.kp-bar-wrap-cancelled { border-color: rgba(229, 87, 64, 0.3); }
/* Indeterminate sweep on the running bar — gives obvious motion while
waiting for the next poll. */
.kp-bar-running-anim {
@@ -2225,8 +2370,8 @@ const stageBreakdown = computed<StageRowSummary[]>(() => {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
width: 22px;
height: 22px;
border: none;
background: transparent;
color: var(--td-text-color-placeholder);
@@ -2256,6 +2401,20 @@ const stageBreakdown = computed<StageRowSummary[]>(() => {
min-width: 0;
}
/* Same vertical layout as collapsible, but with no toggle button —
used for short payloads that auto-expand inline. */
.kp-kv-inline {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.kp-kv-summary-static {
color: var(--td-text-color-placeholder);
font-size: 11px;
}
.kp-kv-toggle {
border: none;
background: transparent;

View File

@@ -443,6 +443,7 @@ export default {
placeholderHint: 'This stage has no detailed span record; only the inferred state is shown.',
showJson: 'Expand JSON',
hideJson: 'Collapse JSON',
includingChildren: 'incl. children',
},
stage: {
docreader: 'Document parsing',

View File

@@ -449,6 +449,7 @@ export default {
placeholderHint: "이 단계에는 상세한 span 기록이 없으며, 추정된 상태만 표시됩니다.",
showJson: "JSON 펼치기",
hideJson: "JSON 접기",
includingChildren: "하위 작업 포함",
},
stage: {
docreader: "문서 파싱",

View File

@@ -446,6 +446,7 @@ export default {
placeholderHint: "此阶段未记录详细 span仅展示推断状态。",
showJson: "展开 JSON",
hideJson: "收起 JSON",
includingChildren: "含子任务",
},
stage: {
docreader: "文档解析",

View File

@@ -236,6 +236,13 @@ func (s *ChunkExtractService) Handle(ctx context.Context, t *asynq.Task) error {
handleErr = err
return err
}
// Capture chunk content shape on output — lets traces answer "WHAT
// did the LLM call see?" without joining back to the chunk store.
// Preview is truncated to keep span rows reasonable.
if gSpan != nil {
graphOut["chunk_chars"] = len([]rune(chunk.Content))
graphOut["chunk_preview"] = previewText(chunk.Content, 200)
}
kb, err := s.knowledgeBaseRepo.GetKnowledgeBaseByID(ctx, chunk.KnowledgeBaseID)
if err != nil {
logger.Errorf(ctx, "failed to get knowledge base: %v", err)
@@ -293,6 +300,32 @@ func (s *ChunkExtractService) Handle(ctx context.Context, t *asynq.Task) error {
}
graphOut["nodes_added"] = len(graph.Node)
graphOut["relations_added"] = len(graph.Relation)
// Capture a couple of sample nodes/relations so the trace viewer can
// answer "what did the LLM actually extract?" without round-tripping
// to the graph store. Cap to two each — anything more bloats span
// rows and the full graph is queryable elsewhere.
if len(graph.Node) > 0 {
samples := graph.Node
if len(samples) > 2 {
samples = samples[:2]
}
names := make([]string, 0, len(samples))
for _, n := range samples {
names = append(names, n.Name)
}
graphOut["sample_nodes"] = names
}
if len(graph.Relation) > 0 {
samples := graph.Relation
if len(samples) > 2 {
samples = samples[:2]
}
out := make([]string, 0, len(samples))
for _, r := range samples {
out = append(out, fmt.Sprintf("%s --[%s]--> %s", r.Node1, r.Type, r.Node2))
}
graphOut["sample_relations"] = out
}
return nil
}

View File

@@ -893,7 +893,9 @@ func (s *knowledgeService) ProcessSummaryGeneration(ctx context.Context, t *asyn
// Closes via the deferred handler below — every return path lands in
// the defer, including the early returns ahead.
span := s.beginPostprocessSubspan(ctx, payload.KnowledgeID, payload.Attempt, "postprocess.summary",
types.JSONMap{"language": payload.Language})
types.JSONMap{
"language": payload.Language,
})
var summaryErr error
summaryOut := types.JSONMap{}
defer func() {
@@ -914,6 +916,11 @@ func (s *knowledgeService) ProcessSummaryGeneration(ctx context.Context, t *asyn
summaryErr = err
return nil
}
// Capture the resolved model id on the span output the moment we
// know it — debugging "summary stage took 60s" benefits hugely from
// seeing WHICH chat model was actually used (kb config drift, fall-
// throughs to a slow upstream, etc.).
summaryOut["model_id"] = kb.SummaryModelID
if kb.SummaryModelID == "" {
logger.Warn(ctx, "Knowledge base summary model ID is empty, skipping summary generation")
@@ -1026,6 +1033,11 @@ func (s *knowledgeService) ProcessSummaryGeneration(ctx context.Context, t *asyn
knowledge.SummaryStatus = types.SummaryStatusCompleted
knowledge.UpdatedAt = time.Now()
summaryOut["summary_chars"] = len([]rune(summary))
// Preview the generated summary on the span output so the trace
// viewer can show "this is what the LLM produced" at a glance,
// without hopping to the knowledge-detail page. Capped to keep
// span rows compact.
summaryOut["summary_preview"] = previewText(summary, 240)
if err := s.repo.UpdateKnowledge(ctx, knowledge); err != nil {
logger.Errorf(ctx, "Failed to update knowledge description: %v", err)
summaryErr = err
@@ -1141,6 +1153,12 @@ func (s *knowledgeService) ProcessQuestionGeneration(ctx context.Context, t *asy
indexEntriesPrepared := 0
indexBatchAttempted := false
indexBatchSucceeded := false
// Sample question + model id surfaced on the span output so the
// trace viewer can answer "what did the LLM actually produce?" and
// "which model did it run on?" without joining back to the chunk
// store. Captured the first time we see a non-empty question batch.
var sampleQuestion string
var resolvedModelID string
// Postprocess subspan for the trace viewer. Opened lazily after we
// unmarshal the payload (so we have payload.Attempt) and closed in
// the defer below alongside the stats log so the span output mirrors
@@ -1190,6 +1208,16 @@ func (s *knowledgeService) ProcessQuestionGeneration(ctx context.Context, t *asy
"retry": retryCount,
"max_retry": maxRetry,
}
// Surface the resolved model id and a sample question on the
// span output. These help debugging "why is question generation
// slow" — both questions ("which model was hit?") and ("what
// did it produce?") are hard to answer from logs alone.
if resolvedModelID != "" {
out["model_id"] = resolvedModelID
}
if sampleQuestion != "" {
out["sample_question"] = sampleQuestion
}
// Treat any non-success exitStatus as a failed run; the
// existing stats-string already enumerates them. qErr stays
// optional for callers that want to surface a Go error.
@@ -1289,6 +1317,7 @@ func (s *knowledgeService) ProcessQuestionGeneration(ctx context.Context, t *asy
logger.Errorf(ctx, "Failed to get chat model: %v", err)
return fmt.Errorf("failed to get chat model: %w", err)
}
resolvedModelID = kb.SummaryModelID
// Initialize embedding model and retrieval engine
embeddingModel, err := s.modelService.GetEmbeddingModel(ctx, kb.EmbeddingModelID)
@@ -1368,6 +1397,9 @@ func (s *knowledgeService) ProcessQuestionGeneration(ctx context.Context, t *asy
}
llmCallSuccess++
generatedQuestionsTotal += len(questions)
if sampleQuestion == "" && len(questions) > 0 {
sampleQuestion = previewText(questions[0], 200)
}
// Update chunk metadata with unique IDs for each question
generatedQuestions := make([]types.GeneratedQuestion, len(questions))