mirror of
https://github.com/moeru-ai/airi.git
synced 2026-08-14 00:48:06 +00:00
feat(stage-ui): io tracer for CALL tokens
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user