From b0b48840ec06ccfe4e7af6b7e0df8262d1bcbbd5 Mon Sep 17 00:00:00 2001 From: Makito Date: Sun, 12 Apr 2026 18:47:30 +0900 Subject: [PATCH] feat(stage-tamagotchi,stage-pages): initialize IO trace viewer --- .../pages/settings/system/developer.vue | 9 + .../io-tracer/components/io-tracer-chart.vue | 827 ++++++++++++++++++ .../components/io-tracer-controls.vue | 99 +++ .../io-tracer/components/io-tracer-detail.vue | 313 +++++++ .../components/io-tracer-metrics.vue | 151 ++++ .../components/io-tracer-turn-list.vue | 136 +++ .../src/pages/devtools/io-tracer/index.vue | 81 ++ .../devtools/io-tracer/io-tracer-types.ts | 38 + 8 files changed, 1654 insertions(+) create mode 100644 packages/stage-pages/src/pages/devtools/io-tracer/components/io-tracer-chart.vue create mode 100644 packages/stage-pages/src/pages/devtools/io-tracer/components/io-tracer-controls.vue create mode 100644 packages/stage-pages/src/pages/devtools/io-tracer/components/io-tracer-detail.vue create mode 100644 packages/stage-pages/src/pages/devtools/io-tracer/components/io-tracer-metrics.vue create mode 100644 packages/stage-pages/src/pages/devtools/io-tracer/components/io-tracer-turn-list.vue create mode 100644 packages/stage-pages/src/pages/devtools/io-tracer/index.vue create mode 100644 packages/stage-pages/src/pages/devtools/io-tracer/io-tracer-types.ts diff --git a/apps/stage-tamagotchi/src/renderer/pages/settings/system/developer.vue b/apps/stage-tamagotchi/src/renderer/pages/settings/system/developer.vue index 302fb9c67..e9248fe21 100644 --- a/apps/stage-tamagotchi/src/renderer/pages/settings/system/developer.vue +++ b/apps/stage-tamagotchi/src/renderer/pages/settings/system/developer.vue @@ -117,6 +117,15 @@ const openDevtoolsWindow = useElectronEventaInvoke(electronOpenDevtoolsWindow) > {{ t('tamagotchi.settings.devtools.pages.markdown-stress.title') }} + + IO Tracer + +import type { IOSpan, IOSubsystem, IOTurn } from '@proj-airi/stage-shared' + +import { useElementSize } from '@vueuse/core' +import { computed, ref, watch } from 'vue' + +import { + GAP_WARN_THRESHOLD_MS, + LABEL_COL_WIDTH, + MINIMAP_HEIGHT, + ROW_HEIGHT, + ROW_PADDING, + SUBSYSTEM_CONFIG_MAP, + TIME_AXIS_HEIGHT, +} from '../io-tracer-types' + +const props = defineProps<{ + turns: IOTurn[] + selectedSpanId: string | null + timeOrigin: number + hiddenSubsystems: Set +}>() + +const emit = defineEmits<{ + selectSpan: [spanId: string | null] +}>() + +const containerRef = ref() +const scrollAreaRef = ref() +const { width: containerWidth } = useElementSize(containerRef) +const hoveredSpan = ref<{ span: IOSpan, turn: IOTurn, x: number, y: number } | null>(null) + +const turns = computed(() => { + return props.turns.toSorted((a, b) => a.startTs - b.startTs) +}) + +const visibleSpans = computed(() => { + const result: { span: IOSpan, turn: IOTurn }[] = [] + for (const turn of turns.value) { + for (const span of turn.spans) { + if (!props.hiddenSubsystems.has(span.subsystem)) + result.push({ span, turn }) + } + } + return result +}) + +const viewStart = ref(0) +const viewEnd = ref(1000) + +const globalRange = computed(() => { + if (visibleSpans.value.length === 0) + return { min: props.timeOrigin, max: props.timeOrigin + 1000 } + let min = Infinity + let max = -Infinity + for (const { span } of visibleSpans.value) { + min = Math.min(min, span.startTs) + max = Math.max(max, span.endTs ?? performance.now()) + } + if (min === Infinity) + return { min: props.timeOrigin, max: props.timeOrigin + 1000 } + const pad = (max - min) * 0.05 || 50 + return { min: min - pad, max: max + pad } +}) + +const chartWidth = computed(() => Math.max(1, containerWidth.value - LABEL_COL_WIDTH)) + +const minViewDuration = computed(() => globalRange.value.max - globalRange.value.min) +const maxViewDuration = computed(() => Math.max(minViewDuration.value, 10)) +const minZoomDuration = 1 + +function clampViewport(start: number, end: number): { start: number, end: number } { + let dur = end - start + + if (dur > maxViewDuration.value) + dur = maxViewDuration.value + if (dur < minZoomDuration) + dur = minZoomDuration + + const range = globalRange.value + + if (start < range.min) { + start = range.min + end = start + dur + } + if (end > range.max) { + end = range.max + start = end - dur + } + if (start < range.min) + start = range.min + + return { start, end } +} + +function setViewport(start: number, end: number) { + const clamped = clampViewport(start, end) + viewStart.value = clamped.start + viewEnd.value = clamped.end +} + +const hoveredTurnId = ref(null) + +interface TurnSeparator { + type: 'turn-separator' + y: number +} + +interface SpanRow { + type: 'span' + span: IOSpan + turn: IOTurn + subsystem: IOSubsystem + y: number +} + +interface GapAnnotation { + startTs: number + endTs: number + durationMs: number + y: number +} + +type LayoutRow = TurnSeparator | SpanRow + +const layout = computed(() => { + const rows: LayoutRow[] = [] + const gapAnnotations: GapAnnotation[] = [] + let y = 0 + + const subsystemOrder: IOSubsystem[] = ['tts', 'playback'] + const ttsSubsystems = new Set(['tts', 'playback']) + let isFirstTurn = true + + for (const turn of turns.value) { + const turnSpans = turn.spans.filter(s => !props.hiddenSubsystems.has(s.subsystem)) + if (turnSpans.length === 0) + continue + + if (!isFirstTurn) { + rows.push({ type: 'turn-separator', y }) + y += 1 + } + isFirstTurn = false + + const llmSpans = turnSpans.filter(s => s.subsystem === 'llm').sort((a, b) => a.startTs - b.startTs) + const ttsSpanList = turnSpans.filter(s => ttsSubsystems.has(s.subsystem)) + + const segmentGroups = new Map() + for (const span of ttsSpanList) { + const segId = span.ttsCorrelationId ?? span.id + let group = segmentGroups.get(segId) + if (!group) { + group = [] + segmentGroups.set(segId, group) + } + group.push(span) + } + for (const group of segmentGroups.values()) + group.sort((a, b) => subsystemOrder.indexOf(a.subsystem) - subsystemOrder.indexOf(b.subsystem)) + + const sortedSegments = [...segmentGroups.values()] + .sort((a, b) => a[0].startTs - b[0].startTs) + + for (const span of llmSpans) { + rows.push({ type: 'span', span, turn, subsystem: 'llm', y }) + y += ROW_HEIGHT + } + + for (const group of sortedSegments) { + for (const span of group) { + rows.push({ type: 'span', span, turn, subsystem: span.subsystem, y }) + y += ROW_HEIGHT + } + } + } + + return { rows, totalHeight: y, gapAnnotations } +}) + +function timeToX(ts: number): number { + const duration = viewEnd.value - viewStart.value + if (duration <= 0) + return 0 + return ((ts - viewStart.value) / duration) * chartWidth.value +} + +function xToTime(x: number): number { + const duration = viewEnd.value - viewStart.value + return viewStart.value + (x / chartWidth.value) * duration +} + +function spanBarX(span: IOSpan): number { + return timeToX(span.startTs) +} + +function spanBarWidth(span: IOSpan): number { + const end = span.endTs ?? performance.now() + const x1 = timeToX(span.startTs) + const x2 = timeToX(end) + return Math.max(x2 - x1, 3) +} + +function isClippedLeft(span: IOSpan): boolean { + return timeToX(span.startTs) < 0 && spanBarX(span) + spanBarWidth(span) > 0 +} + +function isClippedRight(span: IOSpan): boolean { + const end = span.endTs ?? performance.now() + return timeToX(end) > chartWidth.value && timeToX(span.startTs) < chartWidth.value +} + +interface EdgeIndicator { + subsystem: IOSubsystem + side: 'left' | 'right' + y: number + spanId: string +} + +const edgeIndicators = computed(() => { + const indicators: EdgeIndicator[] = [] + const vStart = viewStart.value + const vEnd = viewEnd.value + + for (const row of layout.value.rows) { + if (row.type !== 'span') + continue + const span = row.span + const spanEnd = span.endTs ?? performance.now() + + if (spanEnd < vStart) { + indicators.push({ subsystem: span.subsystem, side: 'left', y: row.y, spanId: span.id }) + } + else if (span.startTs > vEnd) { + indicators.push({ subsystem: span.subsystem, side: 'right', y: row.y, spanId: span.id }) + } + } + return indicators +}) + +const ticks = computed(() => { + const width = chartWidth.value + if (width <= 0) + return [] + const vStart = viewStart.value + const vEnd = viewEnd.value + const duration = vEnd - vStart + if (duration <= 0) + return [] + + const targetCount = Math.max(4, Math.floor(width / 120)) + let interval = duration / targetCount + const mag = 10 ** Math.floor(Math.log10(interval)) + const norm = interval / mag + if (norm < 1.5) + interval = mag + else if (norm < 3.5) + interval = 2 * mag + else if (norm < 7.5) + interval = 5 * mag + else interval = 10 * mag + + const result: { x: number, label: string }[] = [] + const start = Math.ceil(vStart / interval) * interval + for (let ts = start; ts <= vEnd; ts += interval) { + result.push({ x: timeToX(ts), label: fmtMs(ts - props.timeOrigin) }) + } + return result +}) + +function minimapSpanX(span: IOSpan): number { + const range = globalRange.value + const dur = range.max - range.min + if (dur <= 0) + return 0 + return ((span.startTs - range.min) / dur) * chartWidth.value +} + +function minimapSpanW(span: IOSpan): number { + const range = globalRange.value + const dur = range.max - range.min + if (dur <= 0) + return 0 + const end = span.endTs ?? performance.now() + return Math.max(((end - span.startTs) / dur) * chartWidth.value, 1) +} + +const minimapViewportX = computed(() => { + const range = globalRange.value + const dur = range.max - range.min + if (dur <= 0) + return 0 + return ((viewStart.value - range.min) / dur) * chartWidth.value +}) + +const minimapViewportW = computed(() => { + const range = globalRange.value + const dur = range.max - range.min + if (dur <= 0) + return chartWidth.value + return ((viewEnd.value - viewStart.value) / dur) * chartWidth.value +}) + +const isDragging = ref(false) +let dragStartX = 0 +let dragStartY = 0 +let dragStartScrollTop = 0 +let dragViewStart = 0 +let dragViewEnd = 0 + +function onChartMouseDown(e: MouseEvent) { + hasUserInteracted = true + isDragging.value = true + dragStartX = e.clientX + dragStartY = e.clientY + dragStartScrollTop = scrollAreaRef.value?.scrollTop ?? 0 + dragViewStart = viewStart.value + dragViewEnd = viewEnd.value + e.preventDefault() +} + +function onChartMouseMove(e: MouseEvent) { + if (!isDragging.value) + return + const dx = e.clientX - dragStartX + const timeDelta = -(dx / chartWidth.value) * (dragViewEnd - dragViewStart) + setViewport(dragViewStart + timeDelta, dragViewEnd + timeDelta) + + const dy = e.clientY - dragStartY + if (scrollAreaRef.value) + scrollAreaRef.value.scrollTop = dragStartScrollTop - dy +} + +function onChartMouseUp() { + isDragging.value = false +} + +function onChartWheel(e: WheelEvent) { + e.preventDefault() + hasUserInteracted = true + const rect = containerRef.value?.getBoundingClientRect() + if (!rect) + return + + const absDx = Math.abs(e.deltaX) + const absDy = Math.abs(e.deltaY) + + if (absDx > absDy && absDx > 1) { + const viewDur = viewEnd.value - viewStart.value + const timeDelta = (e.deltaX / chartWidth.value) * viewDur + setViewport(viewStart.value + timeDelta, viewEnd.value + timeDelta) + return + } + + if (absDy > 1) { + const factor = e.deltaY > 0 ? 1.15 : 1 / 1.15 + const mouseX = e.clientX - rect.left - LABEL_COL_WIDTH + const pivot = xToTime(mouseX) + setViewport( + pivot - (pivot - viewStart.value) * factor, + pivot + (viewEnd.value - pivot) * factor, + ) + } +} + +type MinimapDragMode = 'left-handle' | 'right-handle' | 'area-select' | null +let minimapDragMode: MinimapDragMode = null +let minimapDragStartX = 0 +let minimapDragStartViewStart = 0 +let minimapDragStartViewEnd = 0 + +const HANDLE_HIT_WIDTH = 8 + +function minimapHitTest(offsetX: number): 'left-handle' | 'right-handle' | 'area-select' { + const leftEdge = minimapViewportX.value + const rightEdge = minimapViewportX.value + minimapViewportW.value + if (Math.abs(offsetX - leftEdge) <= HANDLE_HIT_WIDTH) + return 'left-handle' + if (Math.abs(offsetX - rightEdge) <= HANDLE_HIT_WIDTH) + return 'right-handle' + return 'area-select' +} + +function onMinimapMouseDown(e: MouseEvent) { + minimapDragMode = minimapHitTest(e.offsetX) + minimapDragStartX = e.offsetX + minimapDragStartViewStart = viewStart.value + minimapDragStartViewEnd = viewEnd.value + + if (minimapDragMode === 'area-select') { + const range = globalRange.value + const dur = range.max - range.min + const t = range.min + (e.offsetX / chartWidth.value) * dur + viewStart.value = t + viewEnd.value = t + } + e.preventDefault() +} + +function onMinimapMouseMove(e: MouseEvent) { + if (!minimapDragMode) + return + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect() + const x = Math.max(0, Math.min(e.clientX - rect.left, chartWidth.value)) + const range = globalRange.value + const dur = range.max - range.min + const t = range.min + (x / chartWidth.value) * dur + + if (minimapDragMode === 'left-handle') { + setViewport(Math.min(t, minimapDragStartViewEnd - minZoomDuration), minimapDragStartViewEnd) + } + else if (minimapDragMode === 'right-handle') { + setViewport(minimapDragStartViewStart, Math.max(t, minimapDragStartViewStart + minZoomDuration)) + } + else { + const t1 = range.min + (minimapDragStartX / chartWidth.value) * dur + setViewport(Math.min(t1, t), Math.max(t1, t)) + } +} + +function onMinimapMouseUp() { + if (minimapDragMode) { + if (minimapDragMode === 'area-select' && viewEnd.value - viewStart.value < 1) + autoFit() + minimapDragMode = null + } +} + +const minimapCursor = ref('crosshair') +function onMinimapHover(e: MouseEvent) { + if (minimapDragMode) + return + const hit = minimapHitTest(e.offsetX) + minimapCursor.value = hit === 'left-handle' || hit === 'right-handle' ? 'ew-resize' : 'crosshair' +} + +const tooltipStyle = computed(() => { + if (!hoveredSpan.value) + return {} + const { x, y } = hoveredSpan.value + const maxX = (typeof globalThis.window !== 'undefined' ? globalThis.window.innerWidth : 1920) - 300 + const maxY = (typeof globalThis.window !== 'undefined' ? globalThis.window.innerHeight : 1080) - 120 + return { + left: `${Math.min(x + 12, maxX)}px`, + top: `${Math.min(y - 8, maxY)}px`, + } +}) + +function onSpanHover(span: IOSpan, turn: IOTurn, e: MouseEvent) { + hoveredSpan.value = { span, turn, x: e.clientX, y: e.clientY } +} + +function onSpanLeave() { + hoveredSpan.value = null +} + +function onSpanClick(span: IOSpan) { + emit('selectSpan', span.id === props.selectedSpanId ? null : span.id) +} + +let hasUserInteracted = false + +function autoFit() { + const { min, max } = globalRange.value + setViewport(min, max) + hasUserInteracted = false +} + +watch(() => props.turns, () => { + if (visibleSpans.value.length > 0 && !hasUserInteracted) + autoFit() +}, { deep: false }) + +defineExpose({ autoFit }) + +function fmtMs(ms: number): string { + if (ms < 0.01) + return '0ms' + if (ms < 1) + return `${(ms * 1000).toFixed(0)}µs` + if (ms < 1000) + return `${ms.toFixed(ms < 10 ? 1 : 0)}ms` + return `${(ms / 1000).toFixed(2)}s` +} + +function spanDuration(span: IOSpan): string { + if (!span.endTs) + return 'live' + return fmtMs(span.endTs - span.startTs) +} + +function spanLabel(span: IOSpan): string { + const subsystemLabel = SUBSYSTEM_CONFIG_MAP.get(span.subsystem)?.label ?? '' + const text = span.meta.text + if (text && typeof text === 'string') { + const short = text.length > 30 ? `${text.slice(0, 30)}…` : text + return `${subsystemLabel} · ${short}` + } + return `${subsystemLabel} · ${span.name}` +} + + + diff --git a/packages/stage-pages/src/pages/devtools/io-tracer/components/io-tracer-controls.vue b/packages/stage-pages/src/pages/devtools/io-tracer/components/io-tracer-controls.vue new file mode 100644 index 000000000..87437138e --- /dev/null +++ b/packages/stage-pages/src/pages/devtools/io-tracer/components/io-tracer-controls.vue @@ -0,0 +1,99 @@ + + + diff --git a/packages/stage-pages/src/pages/devtools/io-tracer/components/io-tracer-detail.vue b/packages/stage-pages/src/pages/devtools/io-tracer/components/io-tracer-detail.vue new file mode 100644 index 000000000..174caa628 --- /dev/null +++ b/packages/stage-pages/src/pages/devtools/io-tracer/components/io-tracer-detail.vue @@ -0,0 +1,313 @@ + + + diff --git a/packages/stage-pages/src/pages/devtools/io-tracer/components/io-tracer-metrics.vue b/packages/stage-pages/src/pages/devtools/io-tracer/components/io-tracer-metrics.vue new file mode 100644 index 000000000..d0422242d --- /dev/null +++ b/packages/stage-pages/src/pages/devtools/io-tracer/components/io-tracer-metrics.vue @@ -0,0 +1,151 @@ + + + diff --git a/packages/stage-pages/src/pages/devtools/io-tracer/components/io-tracer-turn-list.vue b/packages/stage-pages/src/pages/devtools/io-tracer/components/io-tracer-turn-list.vue new file mode 100644 index 000000000..cd9f0bc7b --- /dev/null +++ b/packages/stage-pages/src/pages/devtools/io-tracer/components/io-tracer-turn-list.vue @@ -0,0 +1,136 @@ + + + diff --git a/packages/stage-pages/src/pages/devtools/io-tracer/index.vue b/packages/stage-pages/src/pages/devtools/io-tracer/index.vue new file mode 100644 index 000000000..909db046f --- /dev/null +++ b/packages/stage-pages/src/pages/devtools/io-tracer/index.vue @@ -0,0 +1,81 @@ + + + + + +meta: + layout: settings + titleKey: IO Tracer + subtitleKey: Devtools + diff --git a/packages/stage-pages/src/pages/devtools/io-tracer/io-tracer-types.ts b/packages/stage-pages/src/pages/devtools/io-tracer/io-tracer-types.ts new file mode 100644 index 000000000..683fdc575 --- /dev/null +++ b/packages/stage-pages/src/pages/devtools/io-tracer/io-tracer-types.ts @@ -0,0 +1,38 @@ +import type { IOSubsystem } from '@proj-airi/stage-shared' + +import { IOSubsystems } from '@proj-airi/stage-shared' + +export interface SubsystemConfig { + subsystem: IOSubsystem + label: string + color: string + bgColor: string + icon: string +} + +export const SUBSYSTEM_CONFIGS: SubsystemConfig[] = [ + { subsystem: IOSubsystems.ASR, label: 'ASR', color: '#3b82f6', bgColor: '#3b82f618', icon: 'i-lucide:mic' }, + { subsystem: IOSubsystems.LLM, label: 'LLM', color: '#a855f7', bgColor: '#a855f718', icon: 'i-lucide:brain' }, + { subsystem: IOSubsystems.TTS, label: 'TTS', color: '#22c55e', bgColor: '#22c55e18', icon: 'i-lucide:audio-lines' }, + { subsystem: IOSubsystems.Playback, label: 'Playback', color: '#f87171', bgColor: '#f8717118', icon: 'i-lucide:play' }, +] + +export const SUBSYSTEM_CONFIG_MAP = new Map(SUBSYSTEM_CONFIGS.map(c => [c.subsystem, c])) + +/** Height of one span row in pixels */ +export const ROW_HEIGHT = 28 +/** Height of subsystem group header */ +export const SUBSYSTEM_HEADER_HEIGHT = 24 +/** Height of a collapsible turn header */ +export const TURN_HEADER_HEIGHT = 36 +/** Vertical padding inside each row for the span bar */ +export const ROW_PADDING = 4 +/** Width of the left label column */ +export const LABEL_COL_WIDTH = 140 +/** Height of the time axis ruler */ +export const TIME_AXIS_HEIGHT = 28 +/** Height of the minimap */ +export const MINIMAP_HEIGHT = 32 + +/** Gap detection threshold: gaps longer than this (ms) are highlighted */ +export const GAP_WARN_THRESHOLD_MS = 100