fix(stage-pages,stage-shared,stage-ui): auto fit IO tracer

This commit is contained in:
Makito
2026-04-15 02:56:04 +09:00
parent 4f28b40b9f
commit bcd89786a0
5 changed files with 24 additions and 85 deletions
@@ -466,10 +466,10 @@ function autoFit() {
hasUserInteracted = false
}
watch(() => props.turns, () => {
if (visibleSpans.value.length > 0 && !hasUserInteracted)
watch(() => visibleSpans.value.length, (count) => {
if (count > 0 && !hasUserInteracted)
autoFit()
}, { deep: false })
})
defineExpose({ autoFit })
@@ -491,12 +491,7 @@ function spanDuration(span: IOSpan): string {
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}`
return `${subsystemLabel} / ${span.name}`
}
</script>
@@ -818,8 +813,8 @@ function spanLabel(span: IOSpan): string {
<div v-if="hoveredSpan.span.meta.text" :class="['text-neutral-400 mt-1 break-words']">
{{ hoveredSpan.span.meta.text.length > 80 ? `${hoveredSpan.span.meta.text.slice(0, 80)}` : hoveredSpan.span.meta.text }}
</div>
<div v-if="hoveredSpan.span.meta.reason" :class="['text-amber-300/80 mt-0.5']">
chunk: {{ hoveredSpan.span.meta.reason }}
<div v-if="hoveredSpan.span.meta.chunk_reason" :class="['text-amber-300/80 mt-0.5']">
chunk: {{ hoveredSpan.span.meta.chunk_reason }}
</div>
</div>
</Teleport>
@@ -10,7 +10,6 @@ export const IOSpanNames = {
InteractionTurn: 'Interaction turn',
SpeechRecognition: 'Speech recognition',
LLMInference: 'LLM inference',
TTSSegment: 'TTS segment',
TTSSynthesis: 'TTS synthesis',
AudioPlayback: 'Audio playback',
} as const
@@ -2,66 +2,29 @@ import type { Span } from '@opentelemetry/api'
import type { createSpeechPipeline } from '@proj-airi/pipelines-audio'
import { IOAttributes, IOSpanNames, IOSubsystems } from '@proj-airi/stage-shared'
import { onScopeDispose, watch } from 'vue'
import { onScopeDispose } from 'vue'
import { activeTurnSpan, startSpan } from './use-io-tracer'
export function useIOTraceBridge(pipeline: ReturnType<typeof createSpeechPipeline>) {
const cleanupFns: (() => void)[] = []
const segmentSpans = new Map<string, Span>()
const synthesisSpans = new Map<string, Span>()
const playbackSpans = new Map<string, Span>()
const segmentReasons = new Map<string, string>()
let currentParent: Span | undefined
let hadSegments = false
const stopWatch = watch(activeTurnSpan, (newSpan) => {
if (newSpan) {
currentParent = newSpan
hadSegments = false
}
}, { immediate: true })
function tryCloseTurn() {
if (hadSegments && segmentSpans.size === 0) {
activeTurnSpan.value?.end()
activeTurnSpan.value = undefined
}
}
function closeSegment(segmentId: string) {
const segSpan = segmentSpans.get(segmentId)
if (segSpan) {
segSpan.end()
segmentSpans.delete(segmentId)
}
}
cleanupFns.push(pipeline.on('onSegment', (segment) => {
segmentReasons.set(segment.segmentId, segment.reason)
}))
cleanupFns.push(pipeline.on('onTtsRequest', (request) => {
let ttsSegmentSpan = segmentSpans.get(request.segmentId)
if (!ttsSegmentSpan) {
hadSegments = true
ttsSegmentSpan = startSpan(IOSpanNames.TTSSegment, currentParent, {
[IOAttributes.Subsystem]: IOSubsystems.TTS,
[IOAttributes.TTSSegmentId]: request.segmentId,
[IOAttributes.TTSText]: request.text,
[IOAttributes.TTSChunkReason]: segmentReasons.get(request.segmentId) ?? '',
})
segmentReasons.delete(request.segmentId)
segmentSpans.set(request.segmentId, ttsSegmentSpan)
}
const ttsSynthesisSpan = startSpan(IOSpanNames.TTSSynthesis, ttsSegmentSpan, {
const ttsSynthesisSpan = startSpan(IOSpanNames.TTSSynthesis, activeTurnSpan.value, {
[IOAttributes.Subsystem]: IOSubsystems.TTS,
[IOAttributes.TTSSegmentId]: request.segmentId,
[IOAttributes.TTSText]: request.text,
[IOAttributes.TTSChunkReason]: segmentReasons.get(request.segmentId) ?? '',
})
segmentReasons.delete(request.segmentId)
synthesisSpans.set(request.segmentId, ttsSynthesisSpan)
}))
@@ -74,8 +37,7 @@ export function useIOTraceBridge(pipeline: ReturnType<typeof createSpeechPipelin
}))
cleanupFns.push(pipeline.on('onPlaybackStart', (event) => {
const segSpan = segmentSpans.get(event.item.segmentId)
const playbackSpan = startSpan(IOSpanNames.AudioPlayback, segSpan, {
const playbackSpan = startSpan(IOSpanNames.AudioPlayback, activeTurnSpan.value, {
[IOAttributes.Subsystem]: IOSubsystems.Playback,
[IOAttributes.TTSSegmentId]: event.item.segmentId,
[IOAttributes.TTSText]: event.item.text,
@@ -89,8 +51,6 @@ export function useIOTraceBridge(pipeline: ReturnType<typeof createSpeechPipelin
playbackSpan.end()
playbackSpans.delete(event.item.segmentId)
}
closeSegment(event.item.segmentId)
tryCloseTurn()
}))
cleanupFns.push(pipeline.on('onPlaybackInterrupt', (event) => {
@@ -101,34 +61,22 @@ export function useIOTraceBridge(pipeline: ReturnType<typeof createSpeechPipelin
playbackSpan.end()
playbackSpans.delete(event.item.segmentId)
}
closeSegment(event.item.segmentId)
tryCloseTurn()
}))
cleanupFns.push(pipeline.on('onPlaybackReject', (event) => {
closeSegment(event.item.segmentId)
tryCloseTurn()
}))
cleanupFns.push(pipeline.on('onIntentCancel', () => {
for (const [segmentId, span] of segmentSpans) {
span.setAttribute(IOAttributes.TTSCanceled, true)
span.end()
segmentSpans.delete(segmentId)
}
for (const [segmentId, span] of synthesisSpans) {
span.setAttribute(IOAttributes.TTSCanceled, true)
span.end()
synthesisSpans.delete(segmentId)
}
for (const [segmentId, span] of playbackSpans) {
span.setAttribute(IOAttributes.TTSCanceled, true)
span.end()
playbackSpans.delete(segmentId)
}
tryCloseTurn()
}))
onScopeDispose(() => {
stopWatch()
for (const cleanup of cleanupFns)
cleanup()
})
+4 -8
View File
@@ -449,23 +449,19 @@ export const useChatOrchestratorStore = defineStore('chat-orchestrator', () => {
toolCalls: sessionMessagesForSend.filter(msg => msg.role === 'tool') as ToolMessage[],
}, streamingMessageContext)
// TODO: Close turn span for zero-segment responses (tool-only, empty).
// Currently the bridge's tryCloseTurn() only fires after TTS playback,
// so turns without segments stay open until the next interaction.
if (isForegroundSession()) {
streamingMessage.value = { role: 'assistant', content: '', slices: [], tool_results: [] }
}
}
catch (error) {
if (!hadExistingTurn && activeTurnSpan.value) {
activeTurnSpan.value.end()
activeTurnSpan.value = undefined
}
console.error('Error sending message:', error)
throw error
}
finally {
if (!hadExistingTurn && activeTurnSpan.value) {
activeTurnSpan.value.end()
activeTurnSpan.value = undefined
}
sending.value = false
}
}
@@ -111,16 +111,17 @@ export const useIOTracerStore = defineStore('devtools:io-tracer', () => {
const subsystem = readable.attributes[IOAttributes.Subsystem] as IOSubsystem | undefined
if (!subsystem) {
if (readable.name === IOSpanNames.TTSSegment) {
const turn = getOrCreateTurn()
const text = readable.attributes[IOAttributes.TTSText]
if (typeof text === 'string' && !turn.outputText)
turn.outputText = text
}
notifyUpdate()
return
}
if (readable.name === IOSpanNames.TTSSynthesis) {
const turn = getOrCreateTurn()
const text = readable.attributes[IOAttributes.TTSText]
if (typeof text === 'string' && !turn.outputText)
turn.outputText = text
}
const turn = getOrCreateTurn()
const meta = attrsToMeta(readable.attributes)