feat(trace-drawer): implement resizable trace drawer and enhance UI interactions

This commit introduces a resizable trace drawer in the `doc-content` component, allowing users to adjust the width for better visibility. Key changes include:

1. Added functionality to save and load the drawer width from local storage.
2. Implemented mouse events for resizing the drawer, enhancing user interaction.
3. Updated the UI to reflect the new drawer width dynamically.
4. Enhanced the trace entry button for improved accessibility and clarity.

These changes aim to improve user experience by providing a more flexible and user-friendly interface for trace inspection.
This commit is contained in:
wizardchen
2026-05-28 13:19:41 +08:00
committed by lyingbug
parent d12273255d
commit a0547729b2
5 changed files with 433 additions and 485 deletions

View File

@@ -93,6 +93,103 @@ function closeTimeline() {
timelineDrawerVisible.value = false;
}
const TRACE_DRAWER_WIDTH_KEY = 'weknora-trace-drawer-width';
const TRACE_DRAWER_DEFAULT_WIDTH = 820;
const TRACE_DRAWER_MIN_WIDTH = 560;
const timelineDrawerWidth = ref(TRACE_DRAWER_DEFAULT_WIDTH);
const timelineDrawerResizing = ref(false);
let traceResizeStartX = 0;
let traceResizeStartWidth = 0;
function traceDrawerMaxWidth() {
return Math.min(1400, Math.max(TRACE_DRAWER_MIN_WIDTH, Math.floor(window.innerWidth * 0.92)));
}
function clampTraceDrawerWidth(width: number) {
return Math.max(TRACE_DRAWER_MIN_WIDTH, Math.min(traceDrawerMaxWidth(), width));
}
function loadTraceDrawerWidth() {
try {
const raw = localStorage.getItem(TRACE_DRAWER_WIDTH_KEY);
const parsed = raw ? parseInt(raw, 10) : NaN;
if (!Number.isNaN(parsed)) {
timelineDrawerWidth.value = clampTraceDrawerWidth(parsed);
}
} catch {
/* ignore quota / private mode */
}
}
function onTraceDrawerResizeStart(e: MouseEvent) {
timelineDrawerResizing.value = true;
traceResizeStartX = e.clientX;
traceResizeStartWidth = timelineDrawerWidth.value;
document.addEventListener('mousemove', onTraceDrawerResizeMove);
document.addEventListener('mouseup', onTraceDrawerResizeEnd);
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
}
function onTraceDrawerResizeMove(e: MouseEvent) {
const delta = traceResizeStartX - e.clientX;
timelineDrawerWidth.value = clampTraceDrawerWidth(traceResizeStartWidth + delta);
}
function onTraceDrawerResizeEnd() {
document.removeEventListener('mousemove', onTraceDrawerResizeMove);
document.removeEventListener('mouseup', onTraceDrawerResizeEnd);
document.body.style.cursor = '';
document.body.style.userSelect = '';
timelineDrawerResizing.value = false;
try {
localStorage.setItem(TRACE_DRAWER_WIDTH_KEY, String(timelineDrawerWidth.value));
} catch {
/* ignore */
}
}
function onTraceDrawerWindowResize() {
timelineDrawerWidth.value = clampTraceDrawerWidth(timelineDrawerWidth.value);
}
function cleanupTraceDrawerResize() {
document.removeEventListener('mousemove', onTraceDrawerResizeMove);
document.removeEventListener('mouseup', onTraceDrawerResizeEnd);
document.body.style.cursor = '';
document.body.style.userSelect = '';
timelineDrawerResizing.value = false;
}
const traceEntryTheme = computed(() => {
const s = timelineSummary.value.status || '';
switch (s) {
case 'done':
case 'completed':
return 'success';
case 'failed':
return 'danger';
case 'running':
case 'processing':
case 'pending':
return 'warning';
default:
return 'default';
}
});
const traceEntryTitle = computed(() => {
let tip = t('knowledgeStages.viewTrace');
if (timelineSummary.value.totalMs > 0) {
tip += ` · ${formatTimelineDuration(timelineSummary.value.totalMs)}`;
} else if (timelineSummary.value.stageTotal > 0) {
tip += ` · ${timelineSummary.value.stageIndex}/${timelineSummary.value.stageTotal}`;
}
return tip;
});
// Exposed so the parent's three-dot menu can jump straight into the
// trace drawer for a card without forcing the user to click the
// detail drawer header link manually.
@@ -193,6 +290,8 @@ const mergeChunks = (chunks: any[]): string => {
};
onMounted(() => {
loadTraceDrawerWidth();
window.addEventListener('resize', onTraceDrawerWindowResize, { passive: true });
nextTick(() => {
const drawers = document.getElementsByClassName('t-drawer__body');
if (drawers && drawers.length > 0) {
@@ -223,6 +322,8 @@ watch(() => props.details?.chunkLoading, (val) => {
}
});
onUnmounted(() => {
window.removeEventListener('resize', onTraceDrawerWindowResize);
cleanupTraceDrawerResize();
if (doc) {
doc.removeEventListener('scroll', handleDetailsScroll);
}
@@ -817,27 +918,16 @@ const handleDetailsScroll = () => {
<template #header>
<div class="drawer-header">
<span class="header-title">{{ getDisplayTitle() }}</span>
<t-tag v-if="details.type" size="small" :theme="getTypeTheme()" variant="light">
<t-tag v-if="details.type" class="header-type-tag" size="small" :theme="getTypeTheme()" variant="light">
{{ getTypeLabel() }}
</t-tag>
<!-- Trace entry: compact inline link next to the file-type
tag. Replaces the previous big card-style trigger which
felt out of place above 文件名/摘要 keeps the doc body
focused on document content and treats trace inspection
as a secondary action discoverable from the header. -->
<button v-if="details.id && hasTimelineSpans" type="button" class="kp-trace-link"
:class="['kp-trace-link-' + (timelineSummary.status || 'unknown')]" :title="$t('knowledgeStages.viewTrace')"
@click="openTimeline">
<span class="kp-trace-link-icon">
<t-button v-if="details.id && hasTimelineSpans" class="trace-entry-btn" size="small" variant="outline"
:theme="traceEntryTheme" :title="traceEntryTitle" @click="openTimeline">
<template #icon>
<t-icon name="chart-bar" size="14px" />
</span>
<span class="kp-trace-link-dot" :class="['kp-trace-link-dot-' + (timelineSummary.status || 'unknown')]" />
<span class="kp-trace-link-text">Trace</span>
<span v-if="timelineSummary.totalMs > 0" class="kp-trace-link-meta">{{
formatTimelineDuration(timelineSummary.totalMs) }}</span>
<span v-else-if="timelineSummary.stageTotal > 0" class="kp-trace-link-meta">{{ timelineSummary.stageIndex
}}/{{ timelineSummary.stageTotal }}</span>
</button>
</template>
{{ $t('knowledgeStages.traceBtn') }}
</t-button>
</div>
</template>
@@ -851,27 +941,18 @@ const handleDetailsScroll = () => {
</div>
<!-- 二级抽屉:完整 Langfuse-style waterfall -->
<t-drawer :visible="timelineDrawerVisible" :zIndex="2100" size="820px" attach="body" :closeBtn="false"
:footer="false" :header="false" :showOverlay="true" :closeOnOverlayClick="true" placement="right"
class="kp-secondary-drawer" @close="closeTimeline">
<div class="kp-drawer-shell">
<div class="kp-drawer-titlebar">
<div class="kp-drawer-titlebar-left">
<span class="kp-drawer-titlebar-kind">trace</span>
<span class="kp-drawer-titlebar-title">{{ $t('knowledgeStages.title') }}</span>
<span v-if="details.title" class="kp-drawer-titlebar-sep">·</span>
<span v-if="details.title" class="kp-drawer-titlebar-doc" :title="details.title">{{ details.title
}}</span>
</div>
<button type="button" class="kp-drawer-titlebar-close" @click="closeTimeline"
:aria-label="$t('knowledgeStages.close')">
<t-icon name="close" size="16px" />
</button>
</div>
<div class="kp-drawer-body">
<KnowledgeProcessingTimeline v-if="details.id && timelineDrawerVisible" :knowledge-id="details.id"
:parse-status="details.parse_status" />
<t-drawer :visible="timelineDrawerVisible" :zIndex="2100" :size="`${timelineDrawerWidth}px`" attach="body"
:closeBtn="false" :footer="false" :header="false" :showOverlay="true" :closeOnOverlayClick="true"
placement="right" :class="['kp-secondary-drawer', { 'kp-secondary-drawer--resizing': timelineDrawerResizing }]"
@close="closeTimeline">
<div class="kp-drawer-shell" :class="{ 'kp-drawer-shell--resizing': timelineDrawerResizing }">
<div class="kp-drawer-resize-handle" role="separator" aria-orientation="vertical"
:aria-label="$t('knowledgeStages.resizeDrawer')" :title="$t('knowledgeStages.resizeDrawer')"
@mousedown.prevent="onTraceDrawerResizeStart">
<div class="kp-drawer-resize-line" />
</div>
<KnowledgeProcessingTimeline v-if="details.id && timelineDrawerVisible" :knowledge-id="details.id"
:parse-status="details.parse_status" :doc-title="details.title" show-close @close="closeTimeline" />
</div>
</t-drawer>
@@ -1116,15 +1197,22 @@ const handleDetailsScroll = () => {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
.header-title {
flex: 1;
min-width: 0;
font-size: 16px;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.header-type-tag,
.trace-entry-btn {
flex-shrink: 0;
}
}
// 信息面板通用样式
@@ -1148,183 +1236,6 @@ const handleDetailsScroll = () => {
border-radius: 6px;
}
/* ============== Trace entry (header inline link) ==============
Sits in the drawer titlebar next to the file-type tag. Status-aware
tinted pill: success-green when the trace finished cleanly,
amber-stripe when still in flight, error-red on failure. The
duration is shown in mono digits to the right; the trace icon
anchors the meaning so the button reads as "open the trace" even
without the word "查看". */
.kp-trace-link {
display: inline-flex;
align-items: center;
gap: 6px;
height: 26px;
padding: 0 10px 0 8px;
border: 1px solid var(--td-component-stroke);
border-radius: var(--td-radius-default);
background: var(--td-bg-color-container);
color: var(--td-text-color-secondary);
font-family: var(--app-font-family);
font-size: 12px;
line-height: 1;
cursor: pointer;
transition: border-color 150ms ease, color 150ms ease,
background 150ms ease, box-shadow 150ms ease;
}
.kp-trace-link:hover {
border-color: var(--td-text-color-secondary);
color: var(--td-text-color-primary);
background: var(--td-bg-color-container-hover);
}
.kp-trace-link-icon {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--td-text-color-placeholder);
margin-right: -2px;
transition: color 150ms ease;
}
.kp-trace-link:hover .kp-trace-link-icon {
color: var(--td-text-color-secondary);
}
.kp-trace-link-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--td-text-color-placeholder);
flex-shrink: 0;
}
.kp-trace-link-text {
font-weight: 500;
letter-spacing: 0.02em;
}
.kp-trace-link-meta {
font-family: var(--app-font-family-mono);
font-size: 11px;
color: var(--td-text-color-placeholder);
font-weight: 500;
padding-left: 2px;
border-left: 1px solid var(--td-component-stroke);
margin-left: 2px;
padding-left: 8px;
letter-spacing: 0;
}
/* Status tints — done / failed / running each get a soft palette
pull. Lets a glance at the pill tell you the trace's outcome
without needing to look at the colored dot. */
.kp-trace-link-done,
.kp-trace-link-completed {
border-color: var(--td-success-color-3);
background: var(--td-success-color-1);
color: var(--td-success-color-7);
}
.kp-trace-link-done .kp-trace-link-dot,
.kp-trace-link-completed .kp-trace-link-dot {
background: var(--td-success-color);
}
.kp-trace-link-done .kp-trace-link-icon,
.kp-trace-link-completed .kp-trace-link-icon {
color: var(--td-success-color-6);
}
.kp-trace-link-done .kp-trace-link-meta,
.kp-trace-link-completed .kp-trace-link-meta {
color: var(--td-success-color-7);
border-color: var(--td-success-color-3);
}
.kp-trace-link-done:hover,
.kp-trace-link-completed:hover {
border-color: var(--td-success-color-5);
background: var(--td-success-color-2);
color: var(--td-success-color-8);
}
.kp-trace-link-failed {
border-color: var(--td-error-color-3);
background: var(--td-error-color-1);
color: var(--td-error-color-7);
}
.kp-trace-link-failed .kp-trace-link-dot {
background: var(--td-error-color);
}
.kp-trace-link-failed .kp-trace-link-icon {
color: var(--td-error-color-6);
}
.kp-trace-link-failed .kp-trace-link-meta {
color: var(--td-error-color-7);
border-color: var(--td-error-color-3);
}
.kp-trace-link-failed:hover {
border-color: var(--td-error-color-5);
background: var(--td-error-color-2);
color: var(--td-error-color-8);
}
.kp-trace-link-running,
.kp-trace-link-processing,
.kp-trace-link-pending {
border-color: var(--td-warning-color-3);
background: var(--td-warning-color-1);
color: var(--td-warning-color-7);
}
.kp-trace-link-running .kp-trace-link-dot,
.kp-trace-link-processing .kp-trace-link-dot,
.kp-trace-link-pending .kp-trace-link-dot {
background: var(--td-warning-color);
animation: kpTriggerPulse 1.4s ease-in-out infinite;
}
.kp-trace-link-running .kp-trace-link-icon,
.kp-trace-link-processing .kp-trace-link-icon,
.kp-trace-link-pending .kp-trace-link-icon {
color: var(--td-warning-color-6);
}
.kp-trace-link-running .kp-trace-link-meta,
.kp-trace-link-processing .kp-trace-link-meta,
.kp-trace-link-pending .kp-trace-link-meta {
color: var(--td-warning-color-7);
border-color: var(--td-warning-color-3);
}
.kp-trace-link-running:hover,
.kp-trace-link-processing:hover,
.kp-trace-link-pending:hover {
border-color: var(--td-warning-color-5);
background: var(--td-warning-color-2);
color: var(--td-warning-color-8);
}
@keyframes kpTriggerPulse {
0%,
100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(0.6);
opacity: 0.5;
}
}
/* Hidden mount keeps fetcher live without showing UI */
.kp-trigger-shadow {
display: none;
@@ -1332,6 +1243,7 @@ const handleDetailsScroll = () => {
/* ============== Secondary drawer shell ============== */
.kp-drawer-shell {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
@@ -1343,90 +1255,40 @@ const handleDetailsScroll = () => {
min-width: 0;
}
.kp-drawer-titlebar {
flex: 0 0 auto;
.kp-drawer-resize-handle {
position: absolute;
top: 0;
left: -6px;
bottom: 0;
width: 12px;
cursor: col-resize;
z-index: 20;
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px 12px;
border-bottom: 1px solid var(--td-component-stroke);
background: var(--td-bg-color-container);
}
.kp-drawer-titlebar-left {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.kp-drawer-titlebar-kind {
font-family: var(--app-font-family-mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--td-text-color-placeholder);
background: var(--td-bg-color-secondarycontainer);
border-radius: var(--td-radius-default);
padding: 2px 6px;
line-height: 1.2;
}
.kp-drawer-titlebar-title {
font-size: 15px;
font-weight: 600;
color: var(--td-text-color-primary);
letter-spacing: -0.005em;
}
.kp-drawer-titlebar-sep {
font-size: 13px;
color: var(--td-text-color-placeholder);
flex-shrink: 0;
}
.kp-drawer-titlebar-doc {
font-size: 13px;
color: var(--td-text-color-secondary);
min-width: 0;
flex: 0 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kp-drawer-titlebar-close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
background: transparent;
cursor: pointer;
color: var(--td-text-color-placeholder);
border-radius: var(--td-radius-default);
transition: background 150ms ease, color 150ms ease;
&:hover .kp-drawer-resize-line,
.kp-drawer-shell--resizing & .kp-drawer-resize-line {
opacity: 1;
background: var(--td-brand-color);
}
}
.kp-drawer-titlebar-close:hover {
background: var(--td-bg-color-secondarycontainer);
color: var(--td-text-color-primary);
.kp-drawer-resize-line {
width: 2px;
height: 48px;
border-radius: 1px;
background: var(--td-component-border);
opacity: 0.55;
transition: opacity 0.15s ease, background 0.15s ease;
}
.kp-drawer-body {
flex: 1 1 auto;
min-height: 0;
min-width: 0;
overflow: hidden;
/* Plain block layout — flex was unnecessary and risked the timeline
becoming a flex item with auto-sized basis, leaking past the drawer
bounds when its internal min-widths exceeded the parent. */
display: block;
position: relative;
.kp-drawer-shell--resizing .kp-drawer-resize-line {
opacity: 1;
background: var(--td-brand-color);
}
.kp-drawer-body> :deep(.kp-timeline) {
.kp-drawer-shell> :deep(.kp-timeline) {
width: 100%;
height: 100%;
}
@@ -1865,4 +1727,8 @@ const handleDetailsScroll = () => {
.t-drawer.kp-secondary-drawer .t-drawer__content {
background: var(--td-bg-color-container);
}
.t-drawer.kp-secondary-drawer--resizing .t-drawer__content {
transition: none !important;
}
</style>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, reactive, onMounted, onBeforeUnmount, watch, computed } from 'vue'
import { ref, reactive, onMounted, onBeforeUnmount, watch, computed, nextTick } from 'vue'
import { MessagePlugin } from 'tdesign-vue-next'
import { useI18n } from 'vue-i18n'
import { getKnowledgeSpans, reparseKnowledge } from '@/api/knowledge-base/index'
@@ -65,17 +65,24 @@ const props = withDefaults(
// summary work doesn't keep a headless poller alive
// after the user closed the trace drawer.
gracePoll?: boolean
/** Document title shown as the drawer primary heading. */
docTitle?: string
/** Show a close control (secondary trace drawer). */
showClose?: boolean
}>(),
{
autoPoll: true,
compact: false,
gracePoll: true,
docTitle: '',
showClose: false,
},
)
const emit = defineEmits<{
(e: 'update:hasSpans', has: boolean): void
(e: 'update:summary', summary: { totalMs: number; status: string; stageIndex: number; stageTotal: number; stageLabel: string }): void
(e: 'close'): void
}>()
const { t } = useI18n()
@@ -93,6 +100,7 @@ const expandedJsonKeys = ref<Set<string>>(new Set())
const nowTick = ref(Date.now())
const detailTab = ref<'overview' | 'input' | 'output' | 'metadata' | 'raw'>('overview')
const lastFetchedAt = ref<number>(0)
const scrollRef = ref<HTMLElement | null>(null)
// Stages the user has explicitly toggled (in either direction). Once
// a stage is in this set, the auto-expand-when-children-appear rule
// stops applying to it — the user's last action wins forever.
@@ -785,9 +793,31 @@ function isPlaceholder(node: SpanNode): boolean {
return !node.span_id && !node.started_at
}
function isRowExpanded(key: string): boolean {
return expandedRows.value.has(key)
}
function treeToggleAriaLabel(row: FlatRow): string {
if (!row.hasChildren || row.isRoot) return ''
return isRowExpanded(row.key)
? t('knowledgeStages.collapseBranch')
: t('knowledgeStages.expandBranch')
}
function scrollRowIntoView(key: string) {
nextTick(() => {
const root = scrollRef.value
if (!root) return
const safeKey = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(key) : key.replace(/"/g, '\\"')
const el = root.querySelector(`.kp-row[data-span-key="${safeKey}"]`)
el?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
})
}
function toggleTree(row: FlatRow, ev?: MouseEvent) {
if (ev) ev.stopPropagation()
if (!row.hasChildren) return
const wasExpanded = expandedRows.value.has(row.key)
const next = new Set(expandedRows.value)
if (next.has(row.key)) next.delete(row.key)
else next.add(row.key)
@@ -800,15 +830,20 @@ function toggleTree(row: FlatRow, ev?: MouseEvent) {
const touched = new Set(userToggledRows.value)
touched.add(row.key)
userToggledRows.value = touched
// Expanding via chevron should also surface the detail panel for
// this stage/span — otherwise the name column only toggles the tree.
if (!wasExpanded && next.has(row.key)) {
selectRow(row)
}
}
function selectRow(row: FlatRow) {
if (selectedSpanId.value === row.key) {
selectedSpanId.value = null
} else {
selectedSpanId.value = row.key
detailTab.value = 'overview'
return
}
selectedSpanId.value = row.key
detailTab.value = 'overview'
scrollRowIntoView(row.key)
}
function closeDetail() {
@@ -1016,16 +1051,69 @@ function attemptGlyph(status: string): { ch: string; cls: string } {
}
}
const headerStatusGlyph = computed(() => {
const s = data.value?.trace?.status || data.value?.parse_status || ''
return attemptGlyph(s)
})
const headerStatusText = computed(() => {
const s = data.value?.trace?.status || data.value?.parse_status || ''
return s ? localizedStatus(s) : ''
})
const headerStatusTheme = computed(() => {
const s = data.value?.trace?.status || data.value?.parse_status || ''
switch (s) {
case 'done':
case 'completed':
return 'success'
case 'failed':
return 'danger'
case 'running':
case 'processing':
case 'pending':
return 'warning'
default:
return 'default'
}
})
const stagesStatDisplay = computed(() => {
const total = stages.value.length
const doneCount = stages.value.filter((s) => s.status === 'done').length
const inProgress = stages.value.some(
(s) => s.status === 'running' || s.status === 'failed' || s.status === 'pending',
)
if (inProgress) {
return {
label: t('knowledgeStages.head.stagesProgress'),
value: `${currentStageIndex.value}/${total}`,
}
}
return {
label: t('knowledgeStages.head.stagesDone'),
value: `${doneCount}/${total}`,
}
})
const headMetaParts = computed(() => {
if (!data.value) return []
const parts: string[] = [t('knowledgeStages.title')]
if (totalMs.value > 0) {
parts.push(t('knowledgeStages.total', { d: formatDuration(totalMs.value) }))
}
const st = stagesStatDisplay.value
parts.push(`${st.label} ${st.value}`)
if (attemptTabs.value.length === 0 && data.value.current_attempt) {
parts.push(t('knowledgeStages.attempt', { n: data.value.current_attempt }))
}
if (lastFetchedAt.value && isLive.value) {
let updated = formatRelativeTime(lastFetchedAt.value)
if (!lastFetchOk.value) {
updated += ` (${t('knowledgeStages.fetchFailedShort')})`
}
parts.push(`${t('knowledgeStages.head.updated')} ${updated}`)
}
return parts
})
const primaryHeadTitle = computed(() => props.docTitle || t('knowledgeStages.title'))
// Emit summary upstream so the doc-content drawer can show a one-line
// status pill without mounting a second copy of the tree.
watch(
@@ -1053,6 +1141,17 @@ function tabHasContent(tab: 'input' | 'output' | 'metadata'): boolean {
return hasContent((node as any)[tab])
}
const traceMetadata = computed(() => {
const m = data.value?.trace?.metadata
return hasContent(m) ? m : null
})
watch([selectedSpanId, detailTab], () => {
if (detailTab.value === 'metadata' && !tabHasContent('metadata')) {
detailTab.value = 'overview'
}
})
// Identity / lineage info for the Overview tab. Surfaces fields that
// were previously buried in the raw payload so the panel doesn't feel
// thin even when the span has no input/output/metadata.
@@ -1137,22 +1236,23 @@ const stageBreakdown = computed<StageRowSummary[]>(() => {
<div class="kp-shell">
<!-- ============== HEADER ============== -->
<div class="kp-head">
<div class="kp-head-row">
<div class="kp-head-id">
<span class="kp-head-status-dot"
:class="['kp-dot-' + (data?.trace?.status || data?.parse_status || 'unknown')]" />
<span class="kp-head-name">{{ t('knowledgeStages.root') }}</span>
<span v-if="isLive" class="kp-live-badge" :title="t('knowledgeStages.liveTooltip')">
<span class="kp-live-dot" />
<span class="kp-live-text">{{ t('knowledgeStages.live') }}</span>
</span>
</div>
<div class="kp-head-toolbar">
<h2 class="kp-head-doc-title" :title="primaryHeadTitle">{{ primaryHeadTitle }}</h2>
<t-tag v-if="data && headerStatusText" size="small" :theme="headerStatusTheme" variant="light"
class="kp-head-status-tag">
{{ headerStatusText }}
</t-tag>
<span v-if="isLive" class="kp-live-badge" :title="t('knowledgeStages.liveTooltip')">
<span class="kp-live-dot" />
<span class="kp-live-text">{{ t('knowledgeStages.live') }}</span>
</span>
<div class="kp-head-actions">
<button type="button" class="kp-icon-btn" :class="{
'kp-icon-btn-spin': refreshing,
'kp-icon-btn-autoflow': isLive && !refreshing,
}" :disabled="loading || refreshing"
:title="isLive ? t('knowledgeStages.autoRefreshOn') : t('knowledgeStages.refresh')"
:aria-label="isLive ? t('knowledgeStages.autoRefreshOn') : t('knowledgeStages.refresh')"
@click="onManualRefresh">
<t-icon name="refresh" size="14px" />
</button>
@@ -1161,49 +1261,19 @@ const stageBreakdown = computed<StageRowSummary[]>(() => {
<t-icon name="refresh" size="14px" />
<span style="margin-left: 4px">{{ t('knowledgeStages.retry') }}</span>
</t-button>
<button v-if="showClose" type="button" class="kp-icon-btn" :aria-label="t('knowledgeStages.close')"
:title="t('knowledgeStages.close')" @click="emit('close')">
<t-icon name="close" size="16px" />
</button>
</div>
</div>
<div v-if="data" class="kp-head-stats">
<div class="kp-stat">
<span class="kp-stat-label">{{ t('knowledgeStages.head.duration') }}</span>
<span class="kp-stat-val kp-mono">{{ totalMs > 0 ? formatDuration(totalMs) : '—' }}</span>
</div>
<div class="kp-stat">
<span class="kp-stat-label">{{ t('knowledgeStages.head.stages') }}</span>
<span class="kp-stat-val kp-mono">
<span class="kp-stat-num">{{stages.filter((s) => s.status === 'done').length}}</span>
<span class="kp-stat-slash">/</span>
<span>{{ stages.length }}</span>
</span>
</div>
<div class="kp-stat">
<span class="kp-stat-label">{{ t('knowledgeStages.head.status') }}</span>
<span class="kp-stat-val">
<span class="kp-meta-glyph" :class="headerStatusGlyph.cls">{{ headerStatusGlyph.ch }}</span>
{{ headerStatusText || '—' }}
</span>
</div>
<div v-if="data?.current_attempt" class="kp-stat">
<span class="kp-stat-label">{{ t('knowledgeStages.head.attempt') }}</span>
<span class="kp-stat-val kp-mono">#{{ data.current_attempt }}</span>
</div>
<!-- "Updated X ago" only meaningful while we're actively
polling. Terminal traces (completed/failed/cancelled)
are final data; showing the staleness counter would
just tick up forever and imply freshness concerns
where none exist. While polling, also surface fetch
failures so the user knows when the loop is running
but not landing data. -->
<div v-if="lastFetchedAt && isLive" class="kp-stat kp-stat-end">
<span class="kp-stat-label">{{ t('knowledgeStages.head.updated') }}</span>
<span class="kp-stat-val" :class="{ 'kp-stat-val-stale': !lastFetchOk }">{{
formatRelativeTime(lastFetchedAt) }}</span>
<span v-if="failedAttempts > 1" class="kp-stat-fail"
:title="t('knowledgeStages.fetchFailed', { n: failedAttempts })">⚠ {{
t('knowledgeStages.fetchFailedShort') }}</span>
</div>
</div>
<p v-if="headMetaParts.length > 0" class="kp-head-meta">
<template v-for="(part, idx) in headMetaParts" :key="idx">
<span v-if="idx > 0" class="kp-head-meta-sep" aria-hidden="true">·</span>
<span class="kp-head-meta-part">{{ part }}</span>
</template>
</p>
<div v-if="attemptTabs.length > 0" class="kp-attempts">
<button v-for="tab in attemptTabs" :key="tab.n" type="button" class="kp-attempt"
@@ -1225,6 +1295,8 @@ const stageBreakdown = computed<StageRowSummary[]>(() => {
</div>
<template v-else-if="data">
<!-- Ruler sits outside the scroll region so the time axis never
scrolls away or fights position:sticky inside overflow. -->
<div v-if="showRuler" class="kp-ruler">
<div class="kp-ruler-spacer-name" />
<div class="kp-ruler-spacer-meta" />
@@ -1238,17 +1310,23 @@ const stageBreakdown = computed<StageRowSummary[]>(() => {
</div>
</div>
<div ref="scrollRef" class="kp-scroll">
<div class="kp-rows">
<div v-for="row in flatRows" :key="row.key" class="kp-row" :class="{
<div v-for="row in flatRows" :key="row.key" class="kp-row" :data-span-key="row.key" :class="{
'kp-row-active': selectedSpanId === row.key,
'kp-row-root': row.isRoot,
'kp-row-stage': row.isStage,
}" @click="selectRow(row)">
'kp-row-span': !row.isRoot && !row.isStage,
'kp-row-expandable': row.hasChildren && !row.isRoot,
}" :title="row.hasChildren && !row.isRoot ? t('knowledgeStages.rowSelectHint') : undefined"
@click="selectRow(row)">
<div class="kp-cell-name">
<div class="kp-name-inner" :style="{ paddingLeft: row.depth * 16 + 'px' }">
<button v-if="row.hasChildren && !row.isRoot" type="button" class="kp-tree-toggle"
:class="{ 'kp-tree-toggle-open': expandedRows.has(row.key) }" :aria-label="row.key"
@click="toggleTree(row, $event)">▸</button>
:aria-expanded="isRowExpanded(row.key)" :aria-label="treeToggleAriaLabel(row)"
@click="toggleTree(row, $event)">
<t-icon :name="isRowExpanded(row.key) ? 'chevron-down' : 'chevron-right'" size="14px" />
</button>
<span v-else class="kp-tree-toggle-spacer" />
<span class="kp-status-dot"
:class="['kp-dot-' + row.node.status, { 'kp-dot-placeholder': isPlaceholder(row.node) }]" />
@@ -1307,9 +1385,8 @@ const stageBreakdown = computed<StageRowSummary[]>(() => {
</div>
</div>
</div>
</template>
<div v-if="data?.last_error && data?.parse_status === 'failed'" class="kp-last-error">
<div v-if="data?.last_error && data?.parse_status === 'failed'" class="kp-last-error">
<div class="kp-last-error-bar" />
<div class="kp-last-error-body">
<div class="kp-last-error-row">
@@ -1323,7 +1400,9 @@ const stageBreakdown = computed<StageRowSummary[]>(() => {
data.last_error.error_message }}
</div>
</div>
</div>
</div>
</div>
</template>
</div>
<!-- ============== DETAIL PANEL ============== -->
@@ -1358,9 +1437,9 @@ const stageBreakdown = computed<StageRowSummary[]>(() => {
<button type="button" class="kp-tab"
:class="{ 'kp-tab-active': detailTab === 'output', 'kp-tab-empty': !tabHasContent('output') }"
@click="detailTab = 'output'">{{ t('knowledgeStages.detail.output') }}</button>
<button type="button" class="kp-tab"
:class="{ 'kp-tab-active': detailTab === 'metadata', 'kp-tab-empty': !tabHasContent('metadata') }"
@click="detailTab = 'metadata'">{{ t('knowledgeStages.detail.metadata') }}</button>
<button v-if="tabHasContent('metadata')" type="button" class="kp-tab"
:class="{ 'kp-tab-active': detailTab === 'metadata' }" @click="detailTab = 'metadata'">{{
t('knowledgeStages.detail.metadata') }}</button>
<button type="button" class="kp-tab" :class="{ 'kp-tab-active': detailTab === 'raw' }"
@click="detailTab = 'raw'">{{ t('knowledgeStages.tab.raw') }}</button>
</div>
@@ -1423,6 +1502,17 @@ const stageBreakdown = computed<StageRowSummary[]>(() => {
</div>
</div>
<div v-if="traceMetadata" class="kp-section">
<div class="kp-section-title">{{ t('knowledgeStages.detail.traceMetadata') }}</div>
<p class="kp-section-desc">{{ t('knowledgeStages.detail.metadataHint') }}</p>
<div class="kp-kv">
<div v-for="entry in buildKvEntries(traceMetadata)" :key="entry.key" class="kp-kv-row kp-kv-row-multiline">
<span class="kp-kv-key kp-mono">{{ entry.key }}</span>
<span class="kp-kv-val kp-kv-scalar">{{ entry.display }}</span>
</div>
</div>
</div>
<!-- Stage breakdown (root only) -->
<div v-if="selectedRow.isRoot" class="kp-section">
<div class="kp-section-title">{{ t('knowledgeStages.detail.stageBreakdown') }}</div>
@@ -1464,7 +1554,8 @@ const stageBreakdown = computed<StageRowSummary[]>(() => {
<!-- Input / Output / Metadata tabs -->
<template v-else-if="detailTab === 'input' || detailTab === 'output' || detailTab === 'metadata'">
<div v-if="!tabHasContent(detailTab)" class="kp-detail-empty">
<span>{{ t('knowledgeStages.detail.empty') }}</span>
<span>{{ detailTab === 'metadata' ? t('knowledgeStages.detail.metadataEmpty') :
t('knowledgeStages.detail.empty') }}</span>
</div>
<template v-else>
<div class="kp-section">
@@ -1565,38 +1656,33 @@ const stageBreakdown = computed<StageRowSummary[]>(() => {
/* ============== HEADER ============== */
.kp-head {
flex: 0 0 auto;
padding: 16px 20px 12px;
padding: 14px 20px 10px;
border-bottom: 1px solid var(--td-component-stroke);
background: var(--td-bg-color-container);
}
.kp-head-row {
.kp-head-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.kp-head-id {
display: flex;
align-items: center;
gap: 10px;
gap: 8px;
min-width: 0;
}
.kp-head-status-dot {
width: 9px;
height: 9px;
border-radius: 50%;
flex-shrink: 0;
background: var(--td-text-color-placeholder);
.kp-head-doc-title {
flex: 1;
min-width: 0;
margin: 0;
font-size: 15px;
font-weight: 600;
line-height: 1.35;
color: var(--td-text-color-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kp-head-name {
font-size: 14px;
font-weight: 600;
color: var(--td-text-color-primary);
letter-spacing: -0.01em;
.kp-head-status-tag {
flex-shrink: 0;
}
/* LIVE badge — sits next to the title while polling, telegraphs the
@@ -1645,7 +1731,26 @@ const stageBreakdown = computed<StageRowSummary[]>(() => {
.kp-head-actions {
display: flex;
align-items: center;
gap: 6px;
gap: 4px;
flex-shrink: 0;
margin-left: auto;
}
.kp-head-meta {
margin: 8px 0 0;
font-size: 12px;
line-height: 1.5;
color: var(--td-text-color-secondary);
word-break: break-word;
}
.kp-head-meta-sep {
margin: 0 6px;
color: var(--td-text-color-placeholder);
}
.kp-head-meta-part {
display: inline;
}
.kp-icon-btn {
@@ -1690,76 +1795,6 @@ const stageBreakdown = computed<StageRowSummary[]>(() => {
}
}
/* Stats row — surfaces duration / stage count / status / attempt /
updated-time. Spaced like a TDesign description list. */
.kp-head-stats {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 18px;
margin-top: 12px;
font-size: 12px;
}
.kp-stat {
display: inline-flex;
align-items: baseline;
gap: 6px;
white-space: nowrap;
}
.kp-stat-end {
margin-left: auto;
}
.kp-stat-label {
color: var(--td-text-color-placeholder);
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.kp-stat-val {
color: var(--td-text-color-primary);
font-size: 12px;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 4px;
}
/* Stale value styling — "更新于" goes muted/strikethrough-ish when the
last fetch attempt failed, signaling that the timestamp may not
reflect fresh server state. */
.kp-stat-val-stale {
color: var(--td-text-color-placeholder);
font-style: italic;
}
/* Inline "fetch failed" hint that lives next to "更新于". Surfaces
silent polling failures (network errors, success=false responses)
so the user knows the loop is running but not landing data. */
.kp-stat-fail {
margin-left: 6px;
font-size: 11px;
color: var(--td-error-color);
background: var(--td-error-color-light);
padding: 1px 6px;
border-radius: var(--td-radius-default);
letter-spacing: 0.02em;
cursor: help;
}
.kp-stat-num {
color: var(--td-text-color-primary);
}
.kp-stat-slash {
color: var(--td-text-color-placeholder);
margin: 0 1px;
}
.kp-meta-glyph {
display: inline-flex;
align-items: center;
@@ -1791,7 +1826,7 @@ const stageBreakdown = computed<StageRowSummary[]>(() => {
.kp-attempts {
display: flex;
gap: 6px;
margin-top: 12px;
margin-top: 10px;
overflow-x: auto;
padding-bottom: 2px;
}
@@ -1842,34 +1877,42 @@ const stageBreakdown = computed<StageRowSummary[]>(() => {
.kp-body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding: 12px 0 16px;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--td-bg-color-container);
}
.kp-scroll {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overflow-x: auto;
padding-bottom: 16px;
}
.kp-state {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 56px 0;
flex: 1 1 auto;
padding: 56px 20px;
font-size: 13px;
color: var(--td-text-color-placeholder);
}
/* Ruler */
/* Ruler — pinned above .kp-scroll, not inside the scroller */
.kp-ruler {
flex: 0 0 auto;
display: grid;
grid-template-columns: minmax(220px, 42%) 64px 1fr;
height: 24px;
align-items: end;
padding: 0 20px 6px;
margin-bottom: 6px;
position: sticky;
top: 0;
padding: 12px 20px 6px;
background: var(--td-bg-color-container);
z-index: 2;
border-bottom: 1px dashed var(--td-component-stroke);
box-shadow: 0 4px 8px -6px rgba(0, 0, 0, 0.12);
}
.kp-ruler-spacer-name,
@@ -1961,6 +2004,19 @@ const stageBreakdown = computed<StageRowSummary[]>(() => {
font-weight: 600;
}
.kp-row-stage:not(.kp-row-active) {
background: color-mix(in srgb, var(--td-bg-color-secondarycontainer) 55%, transparent);
}
.kp-row-span:not(.kp-row-active):not(:hover) {
background: color-mix(in srgb, var(--td-bg-color-container) 92%, var(--td-bg-color-secondarycontainer));
}
.kp-row-expandable:hover .kp-tree-toggle {
color: var(--td-text-color-primary);
background: var(--td-bg-color-container-hover);
}
/* Name cell */
.kp-cell-name {
min-width: 0;
@@ -1974,8 +2030,6 @@ const stageBreakdown = computed<StageRowSummary[]>(() => {
}
.kp-tree-toggle {
width: 14px;
height: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
@@ -1984,13 +2038,12 @@ const stageBreakdown = computed<StageRowSummary[]>(() => {
padding: 0;
cursor: pointer;
color: var(--td-text-color-placeholder);
font-size: 14px;
line-height: 1;
width: 16px;
height: 16px;
transition: transform 150ms ease, color 120ms ease;
width: 22px;
height: 22px;
margin: -3px 0;
transition: color 120ms ease, background 150ms ease;
flex-shrink: 0;
border-radius: var(--td-radius-small);
border-radius: var(--td-radius-default);
}
.kp-tree-toggle:hover {
@@ -1998,15 +2051,12 @@ const stageBreakdown = computed<StageRowSummary[]>(() => {
background: var(--td-bg-color-container-hover);
}
.kp-tree-toggle-open {
transform: rotate(90deg);
}
.kp-tree-toggle-spacer {
width: 16px;
height: 16px;
width: 22px;
height: 22px;
display: inline-block;
flex-shrink: 0;
margin: -3px 0;
}
.kp-status-dot {
@@ -2146,14 +2196,9 @@ const stageBreakdown = computed<StageRowSummary[]>(() => {
opacity: 1;
}
/* ROOT row sits flush against the sticky ruler at the top of
.kp-body (which is `overflow-y: auto` — it clips anything that
tries to escape upward). The default `bottom: calc(100% + 8px)`
tooltip placement therefore renders as a half-cropped black bar
for the root row's solid + dashed bars. Flip it below the bar so
the tooltip lands inside the next row's bar lane (always empty in
that horizontal slice — the dashed wrap bar sits at top:9), which
neither clips nor obscures the next stage's label cell. */
/* ROOT row is the first row under the fixed ruler. The default
`bottom: calc(100% + 8px)` tooltip placement can clip against the
ruler band, so flip tooltips below the bar for the root row. */
.kp-row-root .kp-bar-tip {
bottom: auto;
top: calc(100% + 8px);
@@ -2628,6 +2673,13 @@ const stageBreakdown = computed<StageRowSummary[]>(() => {
color: var(--td-text-color-secondary);
}
.kp-section-desc {
margin: 4px 0 8px;
font-size: 12px;
line-height: 1.5;
color: var(--td-text-color-placeholder);
}
.kp-section-bar {
display: flex;
align-items: center;

View File

@@ -406,6 +406,11 @@ export default {
fetchFailed: 'Last {n} refreshes failed — data may be stale, click refresh to retry',
fetchFailedShort: 'fetch failed',
viewTrace: 'View trace',
traceBtn: 'Trace',
expandBranch: 'Expand children',
collapseBranch: 'Collapse children',
rowSelectHint: 'Click to view details; use the arrow to expand or collapse children',
resizeDrawer: 'Drag to resize panel width',
justNow: 'just now',
secondsAgo: '{n}s ago',
minutesAgo: '{n}m ago',
@@ -415,6 +420,8 @@ export default {
head: {
duration: 'Duration',
stages: 'Stages',
stagesDone: 'Stages completed',
stagesProgress: 'Current stage',
stage: 'Stage',
status: 'Status',
attempt: 'Attempt',
@@ -440,6 +447,9 @@ export default {
input: 'Input',
output: 'Output',
metadata: 'Metadata',
traceMetadata: 'Trace metadata',
metadataHint: 'Auxiliary fields for observability (e.g. Langfuse trace ID). Stage/subspan payloads live under Input and Output.',
metadataEmpty: 'This span has no metadata. Use Input/Output for stage payloads; trace-level fields appear in Overview when Langfuse is connected.',
error: 'Error',
empty: 'No data',
inProgress: 'In progress',

View File

@@ -412,6 +412,11 @@ export default {
fetchFailed: "최근 {n}회 새로고침 실패 — 데이터가 오래되었을 수 있습니다, 새로고침 버튼을 눌러 재시도하세요",
fetchFailedShort: "새로고침 실패",
viewTrace: "Trace 보기",
traceBtn: "Trace",
expandBranch: "하위 항목 펼치기",
collapseBranch: "하위 항목 접기",
rowSelectHint: "클릭하여 세부 정보 보기, 왼쪽 화살표로 하위 항목 펼치기/접기",
resizeDrawer: "패널 너비 조절",
justNow: "방금",
secondsAgo: "{n}초 전",
minutesAgo: "{n}분 전",
@@ -421,6 +426,8 @@ export default {
head: {
duration: "소요시간",
stages: "단계",
stagesDone: "완료된 단계",
stagesProgress: "현재 단계",
stage: "단계",
status: "상태",
attempt: "시도",
@@ -446,6 +453,9 @@ export default {
input: "입력",
output: "출력",
metadata: "메타데이터",
traceMetadata: "Trace 메타데이터",
metadataHint: "Langfuse trace ID 등 관측용 보조 필드입니다. 단계별 입출력은 입력/출력 탭에서 확인하세요.",
metadataEmpty: "이 span에는 메타데이터가 없습니다. 단계 입출력은 입력/출력 탭을, Langfuse trace 필드는 개요 탭을 확인하세요.",
error: "오류",
empty: "데이터 없음",
inProgress: "진행 중",

View File

@@ -409,6 +409,11 @@ export default {
fetchFailed: "最近 {n} 次刷新都失败了,数据可能已过期,点击刷新按钮重试",
fetchFailedShort: "刷新失败",
viewTrace: "查看 Trace",
traceBtn: "Trace",
expandBranch: "展开子项",
collapseBranch: "收起子项",
rowSelectHint: "点击查看详情;左侧箭头展开或收起子项",
resizeDrawer: "拖拽调整面板宽度",
justNow: "刚刚",
secondsAgo: "{n} 秒前",
minutesAgo: "{n} 分钟前",
@@ -418,6 +423,8 @@ export default {
head: {
duration: "总耗时",
stages: "阶段",
stagesDone: "已完成阶段",
stagesProgress: "当前阶段",
stage: "阶段",
status: "状态",
attempt: "尝试",
@@ -443,6 +450,9 @@ export default {
input: "输入",
output: "输出",
metadata: "元数据",
traceMetadata: "Trace 级元数据",
metadataHint: "记录与 Langfuse 等可观测性系统关联的辅助字段(如 trace ID。各阶段的业务入参/出参在「输入」「输出」标签中查看。",
metadataEmpty: "当前 span 没有元数据。阶段与子任务的入参/出参请查看「输入」「输出」;若已接入 Langfusetrace 级字段会显示在「概览」。",
error: "错误",
empty: "暂无数据",
inProgress: "进行中",