fix(stage-ui,stage-tamagotchi): should not flash messages, stream correctly, and sync tool call renderer

This commit is contained in:
Neko Ayaka
2026-08-10 14:32:42 +08:00
parent 66d7ef207e
commit 07d5baf410
27 changed files with 419 additions and 237 deletions
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { createToolResultError, MarkdownRenderer, normalizeToolResultText } from '@proj-airi/stage-ui/components' import { ChatToolCallShell, createToolResultError, MarkdownRenderer, normalizeToolResultText } from '@proj-airi/stage-ui/components'
import { useJournalPreviewStore } from '@proj-airi/stage-ui/stores/journal-preview' import { useJournalPreviewStore } from '@proj-airi/stage-ui/stores/journal-preview'
import { Collapsible, ContainerError } from '@proj-airi/ui' import { ContainerError } from '@proj-airi/ui'
import { computed } from 'vue' import { computed } from 'vue'
const props = defineProps<{ const props = defineProps<{
@@ -125,99 +125,67 @@ function openGeneratedImagePreview() {
</script> </script>
<template> <template>
<Collapsible <ChatToolCallShell
:class="[ :tool-name="toolName"
'bg-primary-100/40 dark:bg-primary-900/60 rounded-lg px-2 pb-2 pt-2', :state="state"
'flex flex-col gap-2 items-start',
]"
> >
<template #trigger="{ visible, setVisible }"> <template #labelSuffix>
<button <span v-if="state === 'error' && resultText" class="ml-2 text-xs text-red-500 op-80">
:class="[ (failed)
'w-full text-start', </span>
]"
@click="setVisible(!visible)"
>
<div
v-if="state === 'executing'"
i-eos-icons:loading class="mr-1 inline-block translate-y-0.5 op-50"
/>
<div
v-else-if="state === 'error'"
i-ph:warning-circle-duotone class="mr-1 inline-block translate-y-0.5 text-red-500"
/>
<div
v-else-if="state === 'done'"
i-ph:check-circle-duotone class="mr-1 inline-block translate-y-0.5 text-emerald-500"
/>
<div
v-else
i-solar:sledgehammer-bold-duotone class="mr-1 inline-block translate-y-1 op-50"
/>
<code>{{ toolName }}</code>
<span v-if="state === 'error' && resultText" class="ml-2 text-xs text-red-500 op-80">
(failed)
</span>
</button>
</template> </template>
<div
:class="[
'rounded-md p-2 w-full',
'bg-neutral-100/80 text-sm text-neutral-800 dark:bg-neutral-900/80 dark:text-neutral-200',
]"
>
<template v-if="resultError">
<ContainerError
:error="resultError"
:include-stack="false"
:show-feedback-button="false"
height-preset="auto"
/>
<div
:class="[
'mt-2 whitespace-pre-wrap break-words font-mono',
]"
>
{{ formattedArgs }}
</div>
</template>
<template v-else-if="isTextJournalCreate">
<div class="mb-2 flex items-center gap-2">
<div class="i-solar:notebook-bookmark-bold-duotone text-base text-emerald-500" />
<div class="rounded-full bg-emerald-500/12 px-2.5 py-1 text-xs text-emerald-700 dark:text-emerald-300">
Saved to long-term memory
</div>
</div>
<MarkdownRenderer :content="textJournalMarkdown" />
</template>
<template v-else-if="isImageJournalCreate">
<div class="mb-2 flex items-center gap-2">
<div :class="[imageStatusIconClass, 'text-base']" />
<div
:class="[
'rounded-full px-2.5 py-1 text-xs',
imageStatusBadgeClass,
]"
>
{{ imageStatusLabel }}
</div>
</div>
<MarkdownRenderer :content="imageJournalMarkdown" />
<div <template v-if="resultError">
v-if="imageJournalResult?.imageUrl" <ContainerError
class="mt-4 overflow-hidden border border-primary-500/20 rounded-xl shadow-lg" :error="resultError"
> :include-stack="false"
<img :show-feedback-button="false"
:src="imageJournalResult.imageUrl" height-preset="auto"
class="w-full cursor-pointer object-contain transition-all active:scale-[0.98] hover:ring-2 hover:ring-primary-500/50" />
@click="openGeneratedImagePreview" <div
> :class="[
</div> 'mt-2 whitespace-pre-wrap break-words font-mono',
</template> ]"
<div v-else class="whitespace-pre-wrap break-words font-mono"> >
{{ formattedArgs }} {{ formattedArgs }}
</div> </div>
</template>
<template v-else-if="isTextJournalCreate">
<div class="mb-2 flex items-center gap-2">
<div class="i-solar:notebook-bookmark-bold-duotone text-base text-emerald-500" />
<div class="rounded-full bg-emerald-500/12 px-2.5 py-1 text-xs text-emerald-700 dark:text-emerald-300">
Saved to long-term memory
</div>
</div>
<MarkdownRenderer :content="textJournalMarkdown" />
</template>
<template v-else-if="isImageJournalCreate">
<div class="mb-2 flex items-center gap-2">
<div :class="[imageStatusIconClass, 'text-base']" />
<div
:class="[
'rounded-full px-2.5 py-1 text-xs',
imageStatusBadgeClass,
]"
>
{{ imageStatusLabel }}
</div>
</div>
<MarkdownRenderer :content="imageJournalMarkdown" />
<div
v-if="imageJournalResult?.imageUrl"
class="mt-4 overflow-hidden border border-primary-500/20 rounded-xl shadow-lg"
>
<img
:src="imageJournalResult.imageUrl"
class="w-full cursor-pointer object-contain transition-all active:scale-[0.98] hover:ring-2 hover:ring-primary-500/50"
@click="openGeneratedImagePreview"
>
</div>
</template>
<div v-else class="whitespace-pre-wrap break-words font-mono">
{{ formattedArgs }}
</div> </div>
</Collapsible> </ChatToolCallShell>
</template> </template>
@@ -148,6 +148,33 @@ function createHarness() {
* await runtime.ingest('hello', { model, chatProvider }) * await runtime.ingest('hello', { model, chatProvider })
*/ */
describe('createChatOrchestratorRuntime', () => { describe('createChatOrchestratorRuntime', () => {
// ROOT CAUSE:
//
// The marker parser buffered 24 literal characters plus its marker-safety tail.
// Providers that emitted small, slow deltas therefore showed no visible text for several seconds.
//
// We fixed this by keeping only the marker-safety tail before the first foreground update.
it('updates the foreground stream before a slow response reaches 24 characters', async () => {
const harness = createHarness()
let patchesBeforeFinish = 0
harness.stream.mockImplementationOnce(async (_model, _chatProvider, _messages, options) => {
for (const text of '1234567890')
await options?.onStreamEvent?.({ type: 'text-delta', text })
patchesBeforeFinish = harness.foregroundPatches.length
await options?.onStreamEvent?.({ type: 'finish', finishReason: 'stop' })
})
await harness.runtime.ingest('show a slow response', {
model: 'gpt-test',
chatProvider: provider,
})
expect(patchesBeforeFinish).toBeGreaterThan(1)
expect(harness.foregroundPatches.some(message => message.content === '1234')).toBe(true)
})
it('stores tool names with the user message and omits them from provider messages', async () => { it('stores tool names with the user message and omits them from provider messages', async () => {
const harness = createHarness() const harness = createHarness()
@@ -14,7 +14,7 @@ import { createChatHooks } from './agent-hooks'
import { useLlmmarkerParser } from './llm-marker-parser' import { useLlmmarkerParser } from './llm-marker-parser'
import { categorizeResponse, createStreamingCategorizer } from './response-categoriser' import { categorizeResponse, createStreamingCategorizer } from './response-categoriser'
const STREAMING_UI_FLUSH_CHUNK_SIZE = 24 const REASONING_UI_FLUSH_CHUNK_SIZE = 24
function prependTextToContent<T extends { content?: unknown }>(msg: T, text: string): T { function prependTextToContent<T extends { content?: unknown }>(msg: T, text: string): T {
const content = msg.content const content = msg.content
@@ -616,7 +616,9 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps)
} }
patchForegroundStream(sessionId, buildingMessage) patchForegroundStream(sessionId, buildingMessage)
}, },
minLiteralEmitLength: STREAMING_UI_FLUSH_CHUNK_SIZE, // The parser keeps its own marker-safety tail. Emit each safe literal
// chunk so slow providers update the chat before they reach 24 characters.
minLiteralEmitLength: 1,
}) })
const toolCallQueue = createQueue<ChatSlices>({ const toolCallQueue = createQueue<ChatSlices>({
@@ -786,8 +788,8 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps)
reasoning: nextReasoning, reasoning: nextReasoning,
} }
const crossesBoundary const crossesBoundary
= Math.floor(nextReasoning.length / STREAMING_UI_FLUSH_CHUNK_SIZE) = Math.floor(nextReasoning.length / REASONING_UI_FLUSH_CHUNK_SIZE)
> Math.floor(reasoning.length / STREAMING_UI_FLUSH_CHUNK_SIZE) > Math.floor(reasoning.length / REASONING_UI_FLUSH_CHUNK_SIZE)
if (!reasoning || crossesBoundary) if (!reasoning || crossesBoundary)
patchForegroundStream(sessionId, buildingMessage) patchForegroundStream(sessionId, buildingMessage)
break break
@@ -2,6 +2,7 @@
import type { ChatHistoryItem } from '@proj-airi/stage-ui/types/chat' import type { ChatHistoryItem } from '@proj-airi/stage-ui/types/chat'
import type { ChatProvider } from '@xsai-ext/providers/utils' import type { ChatProvider } from '@xsai-ext/providers/utils'
import { errorMessageFrom } from '@moeru/std'
import { isStageTamagotchi } from '@proj-airi/stage-shared' import { isStageTamagotchi } from '@proj-airi/stage-shared'
import { useThreeViewControl } from '@proj-airi/stage-ui-three' import { useThreeViewControl } from '@proj-airi/stage-ui-three'
import { ChatHistory, HearingConfigDialog } from '@proj-airi/stage-ui/components' import { ChatHistory, HearingConfigDialog } from '@proj-airi/stage-ui/components'
@@ -124,10 +125,9 @@ async function handleSend() {
} }
catch (error) { catch (error) {
messageInput.value = textToSend messageInput.value = textToSend
messages.value.pop() chatSession.appendSessionMessage(chatSession.activeSessionId, {
messages.value.push({
role: 'error', role: 'error',
content: (error as Error).message, content: errorMessageFrom(error) ?? 'Failed to send message',
}) })
} }
} }
@@ -45,7 +45,6 @@ const { enabled, stream } = storeToRefs(useSettingsAudioDevice())
const chatOrchestrator = useChatStore() const chatOrchestrator = useChatStore()
const chatSession = useChatSessionStore() const chatSession = useChatSessionStore()
const { ingest, onAfterMessageComposed } = chatOrchestrator const { ingest, onAfterMessageComposed } = chatOrchestrator
const { messages } = storeToRefs(chatSession)
const { audioContext } = useAudioContext() const { audioContext } = useAudioContext()
const { t } = useI18n() const { t } = useI18n()
const sendModeLabels = computed<Record<SendMode, string>>(() => ({ const sendModeLabels = computed<Record<SendMode, string>>(() => ({
@@ -83,13 +82,10 @@ async function handleSend() {
catch (error) { catch (error) {
// preserve any user input when failed to send the message // preserve any user input when failed to send the message
messageInput.value = [textToSend, messageInput.value.trim()].filter(Boolean).join(' ') messageInput.value = [textToSend, messageInput.value.trim()].filter(Boolean).join(' ')
chatSession.setSessionMessages(chatSession.activeSessionId, [ chatSession.appendSessionMessage(chatSession.activeSessionId, {
...messages.value.slice(0, -1), role: 'error',
{ content: errorMessageFrom(error) ?? 'Failed to send message',
role: 'error', })
content: errorMessageFrom(error) ?? 'Failed to send message',
},
])
} }
} }
@@ -0,0 +1,17 @@
/**
* A tracing span that can cross the browser `BroadcastChannel` boundary.
* Nanosecond timestamps use strings because structured clone cannot preserve the source integer format.
*/
export interface SerializedIOSpan {
traceId: string
spanId: string
parentSpanId: string
name: string
kind: number
startTimeNano: string
endTimeNano: string
attributes: Record<string, unknown>
events: Array<{ name: string, timeNano: string, attributes: Record<string, unknown> }>
status: { code: number, message: string }
ended: boolean
}
@@ -9,7 +9,6 @@ vi.mock('@vueuse/core', async () => {
post: vi.fn(), post: vi.fn(),
data: vue.ref(null), data: vue.ref(null),
})), })),
useLocalStorage: vi.fn((_key, initialValue) => vue.ref(initialValue)),
} }
}) })
@@ -14,13 +14,6 @@ vi.mock('./camera', async () => {
} }
}) })
vi.mock('@vueuse/core', async () => {
const vue = await import('vue')
return {
useLocalStorage: vi.fn((_key, initialValue) => vue.ref(initialValue)),
}
})
describe('useThreeViewControl', () => { describe('useThreeViewControl', () => {
let composable: ReturnType<typeof useThreeViewControl> let composable: ReturnType<typeof useThreeViewControl>
+1
View File
@@ -37,6 +37,7 @@
"./libs/providers/providers/browser-web-speech-api": "./src/libs/providers/providers/browser-web-speech-api/index.ts", "./libs/providers/providers/browser-web-speech-api": "./src/libs/providers/providers/browser-web-speech-api/index.ts",
"./libs/*": "./src/libs/*.ts", "./libs/*": "./src/libs/*.ts",
"./libs": "./src/libs/index.ts", "./libs": "./src/libs/index.ts",
"./models/*": "./src/models/*.ts",
"./services/*": "./src/services/*.ts", "./services/*": "./src/services/*.ts",
"./tools/mcp": "./src/tools/mcp.ts", "./tools/mcp": "./src/tools/mcp.ts",
"./stores/character": "./src/stores/character/index.ts", "./stores/character": "./src/stores/character/index.ts",
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest'
import { render } from 'vitest-browser-vue'
import { defineComponent, onMounted, ref, useTemplateRef } from 'vue'
import MarkdownRenderer from './markdown-renderer.vue'
const markdown = `### Roof Leak in Server Room
*Anime style virtual AI girl waking up in a server room and noticing water from the ceiling.*
> **Sharing**: Sending a quick sketch to our chat history...`
const MarkdownMountProbe = defineComponent({
components: {
MarkdownRenderer,
},
emits: {
mountedHtml: (html: string) => typeof html === 'string',
},
setup(_, { emit }) {
const root = useTemplateRef<HTMLElement>('root')
onMounted(() => {
emit('mountedHtml', root.value?.innerHTML ?? '')
})
return {
markdown,
root,
}
},
template: `
<div ref="root">
<MarkdownRenderer :content="markdown" />
</div>
`,
})
const MarkdownHarness = defineComponent({
components: {
MarkdownMountProbe,
},
setup() {
const mountedHtml = ref('')
return {
mountedHtml,
}
},
template: `
<MarkdownMountProbe @mounted-html="mountedHtml = $event" />
<output aria-label="initial-markdown-html">{{ mountedHtml }}</output>
`,
})
describe('markdown renderer initial content', () => {
it('renders basic Markdown before mounted layout code reads the element', async () => {
// ROOT CAUSE:
//
// MarkdownRenderer started with empty HTML and filled it after an awaited promise.
// TransitionVertical measured that empty element and kept the stale height for 250 ms.
// The content jumped to its real height when the animation released its fixed height.
//
// We fixed this by rendering basic Markdown synchronously before optional rich processing.
const screen = await render(MarkdownHarness)
await expect.element(screen.getByLabelText('initial-markdown-html')).toHaveTextContent('<h3>Roof Leak in Server Room</h3>')
})
})
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import DOMPurify from 'dompurify' import DOMPurify from 'dompurify'
import { onMounted, ref, watch } from 'vue' import { ref, watch } from 'vue'
import { useMarkdown } from '../../composables/markdown' import { useMarkdown } from '../../composables/markdown'
@@ -14,28 +14,42 @@ const props = defineProps<Props>()
const processedContent = ref('') const processedContent = ref('')
const { process, processSync } = useMarkdown() const { process, processSync } = useMarkdown()
let processRequestId = 0
async function processContent() { async function processRichContent(content: string, requestId: number) {
if (!props.content) { try {
const result = DOMPurify.sanitize(await process(content))
if (requestId === processRequestId)
processedContent.value = result
}
catch (error) {
console.warn('Failed to process markdown with syntax highlighting, using fallback:', error)
}
}
function processContent() {
const content = props.content
const requestId = ++processRequestId
if (!content) {
processedContent.value = '' processedContent.value = ''
return return
} }
try { try {
processedContent.value = DOMPurify.sanitize(await process(props.content)) processedContent.value = DOMPurify.sanitize(processSync(content))
} }
catch (error) { catch (error) {
console.warn('Failed to process markdown with syntax highlighting, using fallback:', error) console.warn('Failed to process markdown:', error)
processedContent.value = DOMPurify.sanitize(processSync(props.content)) processedContent.value = ''
return
} }
if (/`{3,}/.test(content))
void processRichContent(content, requestId)
} }
// Process content when it changes
watch(() => props.content, processContent, { immediate: true }) watch(() => props.content, processContent, { immediate: true })
onMounted(() => {
processContent()
})
</script> </script>
<template> <template>
@@ -93,13 +93,34 @@ function createHarness(messages: ChatHistoryItem[]) {
}) })
} }
/** describe('chat history', () => {
* @example // ROOT CAUSE:
* describe('ChatHistory retry actions', () => { //
* it('emits retry-message when the retry button is clicked for an error after a user message', async () => {}) // Cross-window synchronization can publish `sending` before it publishes the new stream.
* }) // The initial stream object has a timestamp but no message id, which rendered a short-lived bubble.
*/ //
describe('chatHistory retry actions', () => { // We fixed this by rendering only a stream that has the stable id assigned to the assistant turn.
it('does not render the initial empty stream while a synchronized send starts', async () => {
await render(ChatHistory, {
props: {
messages: [],
sending: true,
streamingMessage: {
role: 'assistant',
content: '',
slices: [],
tool_results: [],
createdAt: 1710000000000,
},
},
global: {
plugins: [createTestI18n()],
},
})
expect(document.querySelectorAll('[data-chat-message-role="assistant"]')).toHaveLength(0)
})
/** /**
* @example * @example
* it('emits retry-message when the retry button is clicked for an error after a user message', async () => { * it('emits retry-message when the retry button is clicked for an error after a user message', async () => {
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ChatAssistantMessage, ChatHistoryItem, ContextMessage } from '../../../../types/chat' import type { ChatHistoryItem, StreamingAssistantMessage } from '../../../../types/chat'
import type { ChatToolCallRendererRegistry } from './tool-call-renderer' import type { ChatToolCallRendererRegistry } from './tool-call-renderer'
import { computed, provide, ref } from 'vue' import { computed, provide, ref } from 'vue'
@@ -15,7 +15,7 @@ import { getChatHistoryItemKey } from '../utils'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
messages: ChatHistoryItem[] messages: ChatHistoryItem[]
streamingMessage?: ChatAssistantMessage & { createdAt?: number } streamingMessage?: StreamingAssistantMessage
sending?: boolean sending?: boolean
assistantLabel?: string assistantLabel?: string
userLabel?: string userLabel?: string
@@ -47,25 +47,20 @@ const labels = computed(() => ({
retry: props.retryLabel ?? t('stage.chat.actions.retry'), retry: props.retryLabel ?? t('stage.chat.actions.retry'),
})) }))
const streaming = computed<ChatAssistantMessage & { context?: ContextMessage } & { createdAt?: number }>(() => props.streamingMessage ?? { role: 'assistant', content: '', slices: [], tool_results: [], createdAt: Date.now() }) const streaming = computed<StreamingAssistantMessage>(() => props.streamingMessage ?? { role: 'assistant', content: '', slices: [], tool_results: [] })
const showStreamingPlaceholder = computed(() => (streaming.value.slices?.length ?? 0) === 0 && !streaming.value.content) const showStreamingPlaceholder = computed(() => (streaming.value.slices?.length ?? 0) === 0 && !streaming.value.content)
const streamingTs = computed(() => streaming.value?.createdAt)
function shouldShowPlaceholder(message: ChatHistoryItem) { function shouldShowPlaceholder(message: ChatHistoryItem) {
const ts = streamingTs.value return !!streaming.value.id && message.id === streaming.value.id
if (ts == null)
return false
return message.context?.createdAt === ts || message.createdAt === ts
} }
const renderMessages = computed<ChatHistoryItem[]>(() => { const renderMessages = computed<ChatHistoryItem[]>(() => {
if (!props.sending) if (!props.sending)
return props.messages return props.messages
const streamTs = streamingTs.value const streamId = streaming.value.id
if (!streamTs) if (!streamId)
return props.messages return props.messages
const hasStreamAlready = streamTs && props.messages.some(msg => msg?.role === 'assistant' && msg?.createdAt === streamTs) const hasStreamAlready = props.messages.some(message => message.role === 'assistant' && message.id === streamId)
if (hasStreamAlready) if (hasStreamAlready)
return props.messages return props.messages
@@ -1,7 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { Collapsible, ContainerError } from '@proj-airi/ui' import { ContainerError } from '@proj-airi/ui'
import { computed } from 'vue' import { computed } from 'vue'
import ChatToolCallShell from './tool-call-shell.vue'
import { createToolResultError } from './tool-call-display' import { createToolResultError } from './tool-call-display'
const props = defineProps<{ const props = defineProps<{
@@ -38,82 +40,42 @@ function emitToolCallRerun() {
</script> </script>
<template> <template>
<Collapsible <ChatToolCallShell
:class="[ :tool-name="toolName"
'bg-primary-100/40 dark:bg-primary-900/60 rounded-lg px-1 pb-1 pt-1', :state="state"
'flex flex-col gap-2 items-start',
]"
> >
<template #trigger="{ visible, setVisible }"> <template #actions>
<button
aria-label="Re-run tool call"
:class="[
'h-6 w-6 shrink-0 rounded-md',
'inline-flex items-center justify-center',
'text-primary-700/70 hover:bg-primary-200/70 hover:text-primary-800',
'dark:text-primary-100/70 dark:hover:bg-primary-800/70 dark:hover:text-primary-50',
]"
@click.stop="emitToolCallRerun"
>
<div class="i-solar:refresh-bold text-sm" />
</button>
</template>
<template v-if="resultError">
<ContainerError
:error="resultError"
:include-stack="false"
:show-feedback-button="false"
height-preset="auto"
/>
<div <div
:class="[ :class="[
'w-full', 'mt-2 whitespace-pre-wrap break-words font-mono',
'inline-flex items-center gap-1',
]" ]"
> >
<button
:class="[
'min-w-0 flex-1 text-start',
'inline-flex items-center',
]"
@click="setVisible(!visible)"
>
<div
v-if="state === 'executing'"
i-eos-icons:loading class="mr-1 inline-block op-50"
/>
<div
v-else-if="state === 'error'"
i-solar:danger-circle-bold-duotone class="mr-1 inline-block text-red-500"
/>
<div
v-else-if="state === 'done'"
i-solar:check-circle-bold-duotone class="mr-1 inline-block text-emerald-500"
/>
<div
v-else
i-solar:sledgehammer-bold-duotone class="mr-1 inline-block translate-y-1 op-50"
/>
<code class="truncate text-xs">{{ toolName }}</code>
</button>
<button
aria-label="Re-run tool call"
:class="[
'h-6 w-6 shrink-0 rounded-md',
'inline-flex items-center justify-center',
'text-primary-700/70 hover:bg-primary-200/70 hover:text-primary-800',
'dark:text-primary-100/70 dark:hover:bg-primary-800/70 dark:hover:text-primary-50',
]"
@click.stop="emitToolCallRerun"
>
<div class="i-solar:refresh-bold text-sm" />
</button>
</div>
</template>
<div
:class="[
'rounded-md p-2 w-full',
'bg-neutral-100/80 text-sm text-neutral-800 dark:bg-neutral-900/80 dark:text-neutral-200',
]"
>
<template v-if="resultError">
<ContainerError
:error="resultError"
:include-stack="false"
:show-feedback-button="false"
height-preset="auto"
/>
<div
:class="[
'mt-2 whitespace-pre-wrap break-words font-mono',
]"
>
{{ formattedArgs }}
</div>
</template>
<div v-else class="whitespace-pre-wrap break-words font-mono">
{{ formattedArgs }} {{ formattedArgs }}
</div> </div>
</template>
<div v-else class="whitespace-pre-wrap break-words font-mono">
{{ formattedArgs }}
</div> </div>
</Collapsible> </ChatToolCallShell>
</template> </template>
@@ -0,0 +1,72 @@
<script setup lang="ts">
import { Collapsible } from '@proj-airi/ui'
/** Properties for the shared collapsible frame around a chat tool call. */
interface Props {
toolName: string
state?: 'executing' | 'done' | 'error'
}
defineProps<Props>()
defineSlots<{
actions: (props: Record<string, never>) => unknown
default: (props: Record<string, never>) => unknown
labelSuffix: (props: Record<string, never>) => unknown
}>()
</script>
<template>
<Collapsible
:class="[
'rounded-lg bg-primary-100/40 px-1 pb-1 pt-1 dark:bg-primary-900/60',
'flex flex-col items-start',
]"
>
<template #trigger="{ visible, setVisible }">
<div
:class="[
'min-h-6 w-full',
'inline-flex items-center gap-1',
]"
>
<button
:class="[
'min-w-0 flex-1 text-start',
'inline-flex items-center',
]"
@click="setVisible(!visible)"
>
<div
v-if="state === 'executing'"
class="i-eos-icons:loading mr-1 inline-block op-50"
/>
<div
v-else-if="state === 'error'"
class="i-solar:danger-circle-bold-duotone mr-1 inline-block text-red-500"
/>
<div
v-else-if="state === 'done'"
class="i-solar:check-circle-bold-duotone mr-1 inline-block text-emerald-500"
/>
<div
v-else
class="i-solar:sledgehammer-bold-duotone mr-1 inline-block translate-y-1 op-50"
/>
<code class="truncate text-xs">{{ toolName }}</code>
<slot name="labelSuffix" />
</button>
<slot name="actions" />
</div>
</template>
<div
:class="[
'mt-2 w-full rounded-md p-2',
'bg-neutral-100/80 text-sm text-neutral-800 dark:bg-neutral-900/80 dark:text-neutral-200',
]"
>
<slot />
</div>
</Collapsible>
</template>
@@ -43,7 +43,7 @@ const containerClasses = computed(() => [
]) ])
const boxClasses = computed(() => [ const boxClasses = computed(() => [
props.variant === 'mobile' ? 'px-2 py-2 text-sm bg-neutral-100/90 dark:bg-neutral-800/90' : 'px-3 py-3 bg-neutral-100/80 dark:bg-neutral-800/80', props.variant === 'mobile' ? 'px-2 pt-2 pb-1 text-sm bg-neutral-100/90 dark:bg-neutral-800/90' : 'px-3 pt-3 pb-2 bg-neutral-100/80 dark:bg-neutral-800/80',
]) ])
const copyText = computed(() => getChatHistoryItemCopyText(props.message as ChatHistoryItem)) const copyText = computed(() => getChatHistoryItemCopyText(props.message as ChatHistoryItem))
</script> </script>
@@ -5,5 +5,6 @@ export { default as ChatHistory } from './components/history.vue'
export { default as ChatSessionsDrawer } from './components/sessions-drawer.vue' export { default as ChatSessionsDrawer } from './components/sessions-drawer.vue'
export { createToolResultError, normalizeToolResultText } from './components/tool-call-display' export { createToolResultError, normalizeToolResultText } from './components/tool-call-display'
export type { ChatToolCallRendererProps, ChatToolCallRendererRegistry } from './components/tool-call-renderer' export type { ChatToolCallRendererProps, ChatToolCallRendererRegistry } from './components/tool-call-renderer'
export { default as ChatToolCallShell } from './components/tool-call-shell.vue'
export { default as ChatUserItem } from './components/user-item.vue' export { default as ChatUserItem } from './components/user-item.vue'
export { default as JournalPreviewModal } from './JournalPreviewModal.vue' export { default as JournalPreviewModal } from './JournalPreviewModal.vue'
@@ -1,6 +1,7 @@
import type { Span, SpanContext, SpanStatusCode } from '@opentelemetry/api' import type { Span, SpanContext, SpanStatusCode } from '@opentelemetry/api'
import type { ReadableSpan, SpanExporter } from '@opentelemetry/sdk-trace-base' import type { ReadableSpan, SpanExporter } from '@opentelemetry/sdk-trace-base'
import type { TimedEvent } from '@opentelemetry/sdk-trace-base/build/esm/TimedEvent' import type { TimedEvent } from '@opentelemetry/sdk-trace-base/build/esm/TimedEvent'
import type { SerializedIOSpan } from '@proj-airi/stage-shared/types/io-trace'
import { context, trace } from '@opentelemetry/api' import { context, trace } from '@opentelemetry/api'
import { hrTimeToNanoseconds } from '@opentelemetry/core' import { hrTimeToNanoseconds } from '@opentelemetry/core'
@@ -12,21 +13,7 @@ export type { ReadableSpan } from '@opentelemetry/sdk-trace-base'
const TRACER_NAME = 'ai.moeru.airi.io-tracer' const TRACER_NAME = 'ai.moeru.airi.io-tracer'
const BROADCAST_CHANNEL = 'io-tracer-channel' // TODO: Use simple BroadcastChannel for now const BROADCAST_CHANNEL = 'io-tracer-channel' // TODO: Use simple BroadcastChannel for now
export interface SerializedSpan { function serializeSpan(span: ReadableSpan): SerializedIOSpan {
traceId: string
spanId: string
parentSpanId: string
name: string
kind: number
startTimeNano: string
endTimeNano: string
attributes: Record<string, unknown>
events: { name: string, timeNano: string, attributes: Record<string, unknown> }[]
status: { code: number, message: string }
ended: boolean
}
function serializeSpan(span: ReadableSpan): SerializedSpan {
const ctx = span.spanContext() const ctx = span.spanContext()
const parentCtx = span.parentSpanContext const parentCtx = span.parentSpanContext
return { return {
@@ -48,7 +35,7 @@ function serializeSpan(span: ReadableSpan): SerializedSpan {
} }
} }
export function deserializeSpan(s: SerializedSpan): ReadableSpan { export function deserializeSpan(s: SerializedIOSpan): ReadableSpan {
const nanoToHr = (nano: string): [number, number] => { const nanoToHr = (nano: string): [number, number] => {
const n = Number(nano) const n = Number(nano)
return [Math.floor(n / 1e9), n % 1e9] return [Math.floor(n / 1e9), n % 1e9]
@@ -1,4 +1,5 @@
import type { Tool } from '@xsai/shared-chat' import type { Tool } from '@xsai/shared-chat'
import type {} from 'pinia-plugin-synced'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
@@ -112,6 +112,56 @@ vi.mock('../composables', () => ({
}), }),
})) }))
vi.mock('../libs/analytics', () => ({
getAnalytics: () => ({
emit: (event: { name: string }, properties: unknown) => {
switch (event.name) {
case '$ai_generation':
chatAnalyticsMocks.trackAiGeneration(properties)
break
case 'assistant_response_rendered':
chatAnalyticsMocks.trackAssistantResponseRendered(properties)
break
case 'chat_activation_failed':
chatAnalyticsMocks.trackChatActivationFailed(properties)
break
case 'chat_activation_started':
chatAnalyticsMocks.trackChatActivationStarted(properties)
break
case 'chat_activation_succeeded':
chatAnalyticsMocks.trackChatActivationSucceeded(properties)
break
case 'llm_first_token':
chatAnalyticsMocks.trackLlmFirstToken(properties)
break
case 'llm_request_started':
chatAnalyticsMocks.trackLlmRequestStarted(properties)
break
case 'message_round':
chatAnalyticsMocks.trackMessageRound(properties)
break
case 'message_round_failed':
chatAnalyticsMocks.trackMessageRoundFailed(properties)
break
case 'message_send_started':
chatAnalyticsMocks.trackMessageSendStarted(properties)
break
case 'message_sent':
chatAnalyticsMocks.trackMessageSent(properties)
break
case 'second_turn_started':
chatAnalyticsMocks.trackSecondTurnStarted(properties)
break
default:
return false
}
return true
},
recordFirstMessage: trackFirstMessageMock,
}),
}))
vi.mock('../composables/use-io-tracer', () => ({ vi.mock('../composables/use-io-tracer', () => ({
activeTurnSpan: ioTracerMocks.activeTurnSpan, activeTurnSpan: ioTracerMocks.activeTurnSpan,
startSpan: ioTracerMocks.startSpanMock, startSpan: ioTracerMocks.startSpanMock,
+1
View File
@@ -2,6 +2,7 @@ import type { ChatOrchestratorRuntimeState, ChatOrchestratorSendOptions, StreamE
import type { WebSocketEventInputs } from '@proj-airi/server-sdk' import type { WebSocketEventInputs } from '@proj-airi/server-sdk'
import type { ChatProvider } from '@xsai-ext/providers/utils' import type { ChatProvider } from '@xsai-ext/providers/utils'
import type { Message } from '@xsai/shared-chat' import type { Message } from '@xsai/shared-chat'
import type {} from 'pinia-plugin-synced'
import type { ChatHistoryItem, ChatToolReference } from '../types/chat' import type { ChatHistoryItem, ChatToolReference } from '../types/chat'
import type { ToolCallRerunPayload } from './tool-call-rerun' import type { ToolCallRerunPayload } from './tool-call-rerun'
@@ -1,4 +1,5 @@
import type { MessageRole, NewMessagesPayload } from '@proj-airi/server-sdk-shared' import type { MessageRole, NewMessagesPayload } from '@proj-airi/server-sdk-shared'
import type {} from 'pinia-plugin-synced'
import type { ChatSendOutboxEntry } from '../../database/repos/chat-sessions.repo' import type { ChatSendOutboxEntry } from '../../database/repos/chat-sessions.repo'
import type { ChatWsClient, CloudChatMapper } from '../../libs/chat-sync' import type { ChatWsClient, CloudChatMapper } from '../../libs/chat-sync'
@@ -1,3 +1,5 @@
import type {} from 'pinia-plugin-synced'
import type { StreamingAssistantMessage } from '../../types/chat' import type { StreamingAssistantMessage } from '../../types/chat'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
@@ -9,8 +11,8 @@ export const useChatStreamStore = defineStore('chat-stream', () => {
const chatSession = useChatSessionStore() const chatSession = useChatSessionStore()
const streamingMessage = ref<StreamingAssistantMessage>({ role: 'assistant', content: '', slices: [], tool_results: [], createdAt: Date.now() }) const streamingMessage = ref<StreamingAssistantMessage>({ role: 'assistant', content: '', slices: [], tool_results: [], createdAt: Date.now() })
function beginStream() { function beginStream(id: string) {
streamingMessage.value = { role: 'assistant', content: '', slices: [], tool_results: [], createdAt: Date.now() } streamingMessage.value = { role: 'assistant', content: '', slices: [], tool_results: [], createdAt: Date.now(), id }
} }
function appendStreamLiteral(literal: string) { function appendStreamLiteral(literal: string) {
@@ -577,7 +577,7 @@ describe('context bridge contract', () => {
streamSender.postMessage({ type: 'before-send', message: 'ping', sessionId: 'remote-session', context }) streamSender.postMessage({ type: 'before-send', message: 'ping', sessionId: 'remote-session', context })
await vi.waitFor(() => { await vi.waitFor(() => {
expect(chatOrchestratorMock.sending).toBe(true) expect(chatOrchestratorMock.sending).toBe(true)
expect(beginStreamMock).toHaveBeenCalledTimes(1) expect(beginStreamMock).toHaveBeenCalledWith('turn-1')
}) })
streamSender.postMessage({ type: 'token-literal', literal: 'hello', sessionId: 'remote-session', context }) streamSender.postMessage({ type: 'token-literal', literal: 'hello', sessionId: 'remote-session', context })
@@ -638,7 +638,7 @@ describe('context bridge contract', () => {
streamSender.postMessage({ type: 'before-send', message: 'ping', sessionId: 'remote-session', context }) streamSender.postMessage({ type: 'before-send', message: 'ping', sessionId: 'remote-session', context })
await vi.waitFor(() => { await vi.waitFor(() => {
expect(beginStreamMock).toHaveBeenCalledTimes(1) expect(beginStreamMock).toHaveBeenCalledWith('turn-1')
}) })
currentGeneration = 8 currentGeneration = 8
@@ -809,7 +809,7 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () =
generation: chatSession.getSessionGenerationValue(chatSession.activeSessionId), generation: chatSession.getSessionGenerationValue(chatSession.activeSessionId),
} }
chatOrchestrator.sending = true chatOrchestrator.sending = true
chatStream.beginStream() chatStream.beginStream(event.context.turnId)
break break
case 'after-send': case 'after-send':
await chatOrchestrator.emitAfterSendHooks(event.message, event.context) await chatOrchestrator.emitAfterSendHooks(event.message, event.context)
@@ -1,3 +1,5 @@
import type {} from 'pinia-plugin-synced'
import type { InferenceServiceProvider, ProviderValidationStatus } from '../../libs/providers/types' import type { InferenceServiceProvider, ProviderValidationStatus } from '../../libs/providers/types'
import { useMutation, useQuery } from '@pinia/colada' import { useMutation, useQuery } from '@pinia/colada'
@@ -8,6 +8,7 @@ import type {
TranscriptionProvider, TranscriptionProvider,
TranscriptionProviderWithExtraOptions, TranscriptionProviderWithExtraOptions,
} from '@xsai-ext/providers/utils' } from '@xsai-ext/providers/utils'
import type {} from 'pinia-plugin-synced'
import type { ProviderMetadata, ProviderValidationPlan } from '../../libs/providers' import type { ProviderMetadata, ProviderValidationPlan } from '../../libs/providers'
import type { ModelInfo, ProviderDefinition, ProviderInstance } from '../../libs/providers/types' import type { ModelInfo, ProviderDefinition, ProviderInstance } from '../../libs/providers/types'