feat(stage-ui): io tracer for CALL tokens

This commit is contained in:
Neko Ayaka
2026-05-18 01:23:14 +08:00
parent 77eeb64f51
commit f33d485b82
6 changed files with 155 additions and 14 deletions
@@ -1,6 +1,7 @@
<script setup lang="ts">
import type { IOSpan, IOSubsystem, IOTurn } from '@proj-airi/stage-shared'
import { IOSubsystems } from '@proj-airi/stage-shared'
import { useElementBounding, useElementSize, useEventListener } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
@@ -131,8 +132,8 @@ const layout = computed(() => {
const gapAnnotations: GapAnnotation[] = []
let y = 0
const subsystemOrder: IOSubsystem[] = ['tts', 'playback']
const ttsSubsystems = new Set<IOSubsystem>(['tts', 'playback'])
const subsystemOrder: IOSubsystem[] = [IOSubsystems.TTS, IOSubsystems.Playback]
const ttsSubsystems = new Set<IOSubsystem>([IOSubsystems.TTS, IOSubsystems.Playback])
let isFirstTurn = true
for (const turn of turns.value) {
@@ -146,7 +147,10 @@ const layout = computed(() => {
}
isFirstTurn = false
const llmSpans = turnSpans.filter(s => s.subsystem === 'llm').sort((a, b) => a.startTs - b.startTs)
const llmSpans = turnSpans.filter(s => s.subsystem === IOSubsystems.LLM).sort((a, b) => a.startTs - b.startTs)
const auxiliarySpans = turnSpans
.filter(s => s.subsystem !== IOSubsystems.LLM && !ttsSubsystems.has(s.subsystem))
.sort((a, b) => a.startTs - b.startTs)
const ttsSpanList = turnSpans.filter(s => ttsSubsystems.has(s.subsystem))
const segmentGroups = new Map<string, IOSpan[]>()
@@ -166,7 +170,12 @@ const layout = computed(() => {
.sort((a, b) => a[0].startTs - b[0].startTs)
for (const span of llmSpans) {
rows.push({ type: 'span', span, turn, subsystem: 'llm', y })
rows.push({ type: 'span', span, turn, subsystem: IOSubsystems.LLM, y })
y += ROW_HEIGHT
}
for (const span of auxiliarySpans) {
rows.push({ type: 'span', span, turn, subsystem: span.subsystem, y })
y += ROW_HEIGHT
}
@@ -512,6 +521,28 @@ function spanLabel(span: IOSpan): string {
const subsystemLabel = SUBSYSTEM_CONFIG_MAP.get(span.subsystem)?.label ?? ''
return `${subsystemLabel} / ${span.name}`
}
function formatMetaValue(value: unknown): string {
if (value == null)
return ''
if (typeof value === 'object')
return JSON.stringify(value)
return String(value)
}
function tooltipMetaEntries(span: IOSpan) {
const tooltipKeys = Array.isArray(span.meta.tooltipKeys)
? span.meta.tooltipKeys.filter((key): key is string => typeof key === 'string')
: []
return tooltipKeys
.map(key => [key, span.meta[key]] as const)
.filter(([, value]) => value !== undefined && value !== '')
.map(([key, value]) => ({
label: key,
value: formatMetaValue(value),
}))
}
</script>
<template>
@@ -831,6 +862,19 @@ function spanLabel(span: IOSpan): string {
<div v-if="hoveredSpan.span.meta.chunk_reason" :class="['text-amber-300/80 mt-0.5']">
chunk: {{ hoveredSpan.span.meta.chunk_reason }}
</div>
<div
v-if="tooltipMetaEntries(hoveredSpan.span).length > 0"
:class="['mt-1.5 pt-1.5 border-t border-neutral-700/80 flex flex-col gap-0.5']"
>
<div
v-for="entry in tooltipMetaEntries(hoveredSpan.span)"
:key="entry.label"
:class="['grid grid-cols-[auto_1fr] gap-x-2 text-neutral-300']"
>
<span :class="['text-neutral-500']">{{ entry.label }}</span>
<span :class="['font-mono text-neutral-100 truncate']">{{ entry.value }}</span>
</div>
</div>
</div>
</Teleport>
</div>
@@ -3,7 +3,7 @@ import type { IOSubsystem } from '@proj-airi/stage-shared'
import { Button } from '@proj-airi/ui'
import { SUBSYSTEM_CONFIG_MAP } from '../io-tracer-types'
import { SUBSYSTEM_CONFIG_MAP, SUBSYSTEM_CONFIGS } from '../io-tracer-types'
defineProps<{
isRecording: boolean
@@ -20,10 +20,10 @@ const emit = defineEmits<{
exportOtlp: []
}>()
const ttsSubsystems: { subsystem: IOSubsystem, label: string }[] = [
{ subsystem: 'tts', label: 'TTS' },
{ subsystem: 'playback', label: 'Play' },
]
const subsystemFilters = SUBSYSTEM_CONFIGS.map(config => ({
subsystem: config.subsystem,
label: config.label,
}))
</script>
<template>
@@ -31,8 +31,8 @@ const ttsSubsystems: { subsystem: IOSubsystem, label: string }[] = [
<Button
:class="[
'flex items-center gap-1.5',
isRecording ? 'text-red-500' : '',
]"
:variant="isRecording ? 'danger' : 'primary'"
@click="emit('toggleRecording')"
>
<div
@@ -60,11 +60,11 @@ const ttsSubsystems: { subsystem: IOSubsystem, label: string }[] = [
Fit
</Button>
<!-- TTS Subsystem Toggles -->
<!-- Subsystem Toggles -->
<div :class="['w-px h-4 bg-neutral-200 dark:bg-neutral-700 mx-1']" />
<span :class="['text-2.5 text-neutral-400']">TTS:</span>
<span :class="['text-2.5 text-neutral-400']">Subsystems:</span>
<button
v-for="item in ttsSubsystems"
v-for="item in subsystemFilters"
:key="item.subsystem"
:class="[
'text-2.5 px-1.5 py-0.5 rounded',
@@ -83,6 +83,22 @@ const metaEntries = computed(() => {
}))
})
const eventEntries = computed(() => {
const span = props.span
if (!span)
return []
return (span.events ?? []).map(event => ({
name: event.name,
relativeTime: fmtMs(event.timeTs - span.startTs),
meta: Object.entries(event.meta)
.map(([key, value]) => ({
key: key.includes('.') ? key.split('.').at(-1)! : key,
value: typeof value === 'object' ? JSON.stringify(value) : String(value),
})),
}))
})
function copyValue(value: string) {
navigator.clipboard.writeText(value)
}
@@ -197,6 +213,42 @@ function copyValue(value: string) {
</div>
</div>
<!-- Events -->
<div v-if="eventEntries.length > 0">
<div :class="['text-neutral-500 font-medium mb-1.5 uppercase tracking-wider text-2.5']">
Events
</div>
<div :class="['flex flex-col gap-1.5']">
<div
v-for="entry in eventEntries"
:key="`${entry.name}:${entry.relativeTime}`"
:class="[
'rounded border border-neutral-100 dark:border-neutral-800',
'bg-neutral-50 dark:bg-neutral-800/60',
'p-1.5',
]"
>
<div :class="['flex items-center justify-between gap-2']">
<span :class="['font-mono text-2.5 truncate']">{{ entry.name }}</span>
<span :class="['font-mono text-2.5 text-neutral-400 flex-shrink-0']">+{{ entry.relativeTime }}</span>
</div>
<div
v-if="entry.meta.length > 0"
:class="['mt-1 flex flex-col gap-0.5']"
>
<div
v-for="item in entry.meta"
:key="item.key"
:class="['grid grid-cols-[auto_1fr] gap-x-2']"
>
<span :class="['text-neutral-400 text-2.5']">{{ item.key }}</span>
<span :class="['font-mono text-2.5 truncate']">{{ item.value }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- Metadata -->
<div v-if="metaEntries.length > 0">
<div :class="['text-neutral-500 font-medium mb-1.5 uppercase tracking-wider text-2.5']">
@@ -13,6 +13,7 @@ export interface SubsystemConfig {
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.StreamingControl, label: 'Streaming Control', color: '#06b6d4', bgColor: '#06b6d418', icon: 'i-lucide:radio-tower' },
{ 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' },
]
@@ -1,6 +1,7 @@
export const IOSubsystems = {
ASR: 'asr',
LLM: 'llm',
StreamingControl: 'streaming-control',
TTS: 'tts',
Playback: 'playback',
} as const
@@ -10,6 +11,7 @@ export const IOSpanNames = {
InteractionTurn: 'Interaction turn',
SpeechRecognition: 'Speech recognition',
LLMInference: 'LLM inference',
StreamingControlDispatch: 'Streaming control dispatch',
TTSSynthesis: 'TTS synthesis',
AudioPlayback: 'Audio playback',
} as const
@@ -22,10 +24,22 @@ export const IOAttributes = {
// Non-standard
Subsystem: `${customPrefix}.subsystem`,
TooltipKeys: `${customPrefix}.tooltip.keys`,
LLM_TTFT: `${customPrefix}.llm.time_to_first_token`,
ASRText: `${customPrefix}.asr.text`,
ASRAbort: `${customPrefix}.asr.abort`,
LLMTextLength: `${customPrefix}.llm.text_length`,
StreamingControlCallName: `${customPrefix}.streaming_control.call_name`,
StreamingControlHandlerCount: `${customPrefix}.streaming_control.handler_count`,
StreamingControlMatched: `${customPrefix}.streaming_control.matched`,
StreamingControlParameter: `${customPrefix}.streaming_control.parameter`,
StreamingControlParsed: `${customPrefix}.streaming_control.parsed`,
StreamingControlParserName: `${customPrefix}.streaming_control.parser_name`,
StreamingControlReason: `${customPrefix}.streaming_control.reason`,
StreamingControlRawToken: `${customPrefix}.streaming_control.raw_token`,
StreamingControlTokenLength: `${customPrefix}.streaming_control.token_length`,
StreamingControlTokenType: `${customPrefix}.streaming_control.token_type`,
StreamingControlTurnId: `${customPrefix}.streaming_control.turn_id`,
TTSSegmentId: `${customPrefix}.tts.segment_id`,
TTSText: `${customPrefix}.tts.text`,
TTSChunkReason: `${customPrefix}.tts.chunk_reason`,
@@ -38,8 +52,26 @@ export const IOEvents = {
// Non-standard
LLMFirstToken: `${customPrefix}.llm.first_token`,
ASRSentenceEnd: `${customPrefix}.asr.sentence_end`,
StreamingControlHandlerEnd: `${customPrefix}.streaming_control.handler_end`,
StreamingControlHandlerError: `${customPrefix}.streaming_control.handler_error`,
StreamingControlHandlerStart: `${customPrefix}.streaming_control.handler_start`,
StreamingControlParsed: `${customPrefix}.streaming_control.parsed`,
StreamingControlRejected: `${customPrefix}.streaming_control.rejected`,
StreamingControlSignalHandlerError: `${customPrefix}.streaming_control.signal_handler_error`,
} as const
/**
* Event captured inside an IO tracing span.
*/
export interface IOSpanEvent {
/** OTel event name. */
name: string
/** Event timestamp in milliseconds. */
timeTs: number
/** Event attributes normalized for the devtools UI. */
meta: Record<string, unknown>
}
export interface IOSpan {
id: string
traceId: string
@@ -51,6 +83,8 @@ export interface IOSpan {
subsystem: IOSubsystem
name: string
meta: Record<string, any>
/** OTel events attached to the span. */
events?: IOSpanEvent[]
}
export interface IOTurn {
@@ -14,7 +14,11 @@ const MAX_TURNS = 50
function attrsToMeta(attrs: Attributes): Record<string, any> {
const meta: Record<string, any> = {}
for (const [key, value] of Object.entries(attrs)) {
const shortKey = key.includes('.') ? key.split('.').at(-1)! : key
const shortKey = key === IOAttributes.TooltipKeys
? 'tooltipKeys'
: key.includes('.')
? key.split('.').at(-1)!
: key
meta[shortKey] = value
}
return meta
@@ -124,6 +128,11 @@ export const useIOTracerStore = defineStore('devtools:io-tracer', () => {
const turn = getOrCreateTurn()
const meta = attrsToMeta(readable.attributes)
const events = readable.events.map(event => ({
name: event.name,
timeTs: hrTimeToMilliseconds(event.time),
meta: attrsToMeta(event.attributes ?? {}),
}))
for (const event of readable.events) {
const eventAttrs = event.attributes ?? {}
@@ -153,6 +162,7 @@ export const useIOTracerStore = defineStore('devtools:io-tracer', () => {
startTs: startMs,
endTs: endMs,
meta,
events,
}
turn.spans.push(ioSpan)