fix(stage-ui,stage-tamagotchi): should not flash messages, stream correctly, and sync tool call renderer
This commit is contained in:
+59
-91
@@ -1,7 +1,7 @@
|
||||
<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 { Collapsible, ContainerError } from '@proj-airi/ui'
|
||||
import { ContainerError } from '@proj-airi/ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -125,99 +125,67 @@ function openGeneratedImagePreview() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Collapsible
|
||||
:class="[
|
||||
'bg-primary-100/40 dark:bg-primary-900/60 rounded-lg px-2 pb-2 pt-2',
|
||||
'flex flex-col gap-2 items-start',
|
||||
]"
|
||||
<ChatToolCallShell
|
||||
:tool-name="toolName"
|
||||
:state="state"
|
||||
>
|
||||
<template #trigger="{ visible, setVisible }">
|
||||
<button
|
||||
:class="[
|
||||
'w-full text-start',
|
||||
]"
|
||||
@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 #labelSuffix>
|
||||
<span v-if="state === 'error' && resultText" class="ml-2 text-xs text-red-500 op-80">
|
||||
(failed)
|
||||
</span>
|
||||
</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
|
||||
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">
|
||||
<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
|
||||
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>
|
||||
</Collapsible>
|
||||
</ChatToolCallShell>
|
||||
</template>
|
||||
|
||||
@@ -148,6 +148,33 @@ function createHarness() {
|
||||
* await runtime.ingest('hello', { model, chatProvider })
|
||||
*/
|
||||
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 () => {
|
||||
const harness = createHarness()
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import { createChatHooks } from './agent-hooks'
|
||||
import { useLlmmarkerParser } from './llm-marker-parser'
|
||||
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 {
|
||||
const content = msg.content
|
||||
@@ -616,7 +616,9 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps)
|
||||
}
|
||||
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>({
|
||||
@@ -786,8 +788,8 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps)
|
||||
reasoning: nextReasoning,
|
||||
}
|
||||
const crossesBoundary
|
||||
= Math.floor(nextReasoning.length / STREAMING_UI_FLUSH_CHUNK_SIZE)
|
||||
> Math.floor(reasoning.length / STREAMING_UI_FLUSH_CHUNK_SIZE)
|
||||
= Math.floor(nextReasoning.length / REASONING_UI_FLUSH_CHUNK_SIZE)
|
||||
> Math.floor(reasoning.length / REASONING_UI_FLUSH_CHUNK_SIZE)
|
||||
if (!reasoning || crossesBoundary)
|
||||
patchForegroundStream(sessionId, buildingMessage)
|
||||
break
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { ChatHistoryItem } from '@proj-airi/stage-ui/types/chat'
|
||||
import type { ChatProvider } from '@xsai-ext/providers/utils'
|
||||
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { isStageTamagotchi } from '@proj-airi/stage-shared'
|
||||
import { useThreeViewControl } from '@proj-airi/stage-ui-three'
|
||||
import { ChatHistory, HearingConfigDialog } from '@proj-airi/stage-ui/components'
|
||||
@@ -124,10 +125,9 @@ async function handleSend() {
|
||||
}
|
||||
catch (error) {
|
||||
messageInput.value = textToSend
|
||||
messages.value.pop()
|
||||
messages.value.push({
|
||||
chatSession.appendSessionMessage(chatSession.activeSessionId, {
|
||||
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 chatSession = useChatSessionStore()
|
||||
const { ingest, onAfterMessageComposed } = chatOrchestrator
|
||||
const { messages } = storeToRefs(chatSession)
|
||||
const { audioContext } = useAudioContext()
|
||||
const { t } = useI18n()
|
||||
const sendModeLabels = computed<Record<SendMode, string>>(() => ({
|
||||
@@ -83,13 +82,10 @@ async function handleSend() {
|
||||
catch (error) {
|
||||
// preserve any user input when failed to send the message
|
||||
messageInput.value = [textToSend, messageInput.value.trim()].filter(Boolean).join(' ')
|
||||
chatSession.setSessionMessages(chatSession.activeSessionId, [
|
||||
...messages.value.slice(0, -1),
|
||||
{
|
||||
role: 'error',
|
||||
content: errorMessageFrom(error) ?? 'Failed to send message',
|
||||
},
|
||||
])
|
||||
chatSession.appendSessionMessage(chatSession.activeSessionId, {
|
||||
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(),
|
||||
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', () => {
|
||||
let composable: ReturnType<typeof useThreeViewControl>
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"./libs/providers/providers/browser-web-speech-api": "./src/libs/providers/providers/browser-web-speech-api/index.ts",
|
||||
"./libs/*": "./src/libs/*.ts",
|
||||
"./libs": "./src/libs/index.ts",
|
||||
"./models/*": "./src/models/*.ts",
|
||||
"./services/*": "./src/services/*.ts",
|
||||
"./tools/mcp": "./src/tools/mcp.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">
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import { useMarkdown } from '../../composables/markdown'
|
||||
|
||||
@@ -14,28 +14,42 @@ const props = defineProps<Props>()
|
||||
|
||||
const processedContent = ref('')
|
||||
const { process, processSync } = useMarkdown()
|
||||
let processRequestId = 0
|
||||
|
||||
async function processContent() {
|
||||
if (!props.content) {
|
||||
async function processRichContent(content: string, requestId: number) {
|
||||
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 = ''
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
processedContent.value = DOMPurify.sanitize(await process(props.content))
|
||||
processedContent.value = DOMPurify.sanitize(processSync(content))
|
||||
}
|
||||
catch (error) {
|
||||
console.warn('Failed to process markdown with syntax highlighting, using fallback:', error)
|
||||
processedContent.value = DOMPurify.sanitize(processSync(props.content))
|
||||
console.warn('Failed to process markdown:', error)
|
||||
processedContent.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
if (/`{3,}/.test(content))
|
||||
void processRichContent(content, requestId)
|
||||
}
|
||||
|
||||
// Process content when it changes
|
||||
watch(() => props.content, processContent, { immediate: true })
|
||||
|
||||
onMounted(() => {
|
||||
processContent()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -93,13 +93,34 @@ function createHarness(messages: ChatHistoryItem[]) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @example
|
||||
* describe('ChatHistory retry actions', () => {
|
||||
* it('emits retry-message when the retry button is clicked for an error after a user message', async () => {})
|
||||
* })
|
||||
*/
|
||||
describe('chatHistory retry actions', () => {
|
||||
describe('chat history', () => {
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// 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
|
||||
* 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">
|
||||
import type { ChatAssistantMessage, ChatHistoryItem, ContextMessage } from '../../../../types/chat'
|
||||
import type { ChatHistoryItem, StreamingAssistantMessage } from '../../../../types/chat'
|
||||
import type { ChatToolCallRendererRegistry } from './tool-call-renderer'
|
||||
|
||||
import { computed, provide, ref } from 'vue'
|
||||
@@ -15,7 +15,7 @@ import { getChatHistoryItemKey } from '../utils'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
messages: ChatHistoryItem[]
|
||||
streamingMessage?: ChatAssistantMessage & { createdAt?: number }
|
||||
streamingMessage?: StreamingAssistantMessage
|
||||
sending?: boolean
|
||||
assistantLabel?: string
|
||||
userLabel?: string
|
||||
@@ -47,25 +47,20 @@ const labels = computed(() => ({
|
||||
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 streamingTs = computed(() => streaming.value?.createdAt)
|
||||
function shouldShowPlaceholder(message: ChatHistoryItem) {
|
||||
const ts = streamingTs.value
|
||||
if (ts == null)
|
||||
return false
|
||||
|
||||
return message.context?.createdAt === ts || message.createdAt === ts
|
||||
return !!streaming.value.id && message.id === streaming.value.id
|
||||
}
|
||||
const renderMessages = computed<ChatHistoryItem[]>(() => {
|
||||
if (!props.sending)
|
||||
return props.messages
|
||||
|
||||
const streamTs = streamingTs.value
|
||||
if (!streamTs)
|
||||
const streamId = streaming.value.id
|
||||
if (!streamId)
|
||||
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)
|
||||
return props.messages
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { Collapsible, ContainerError } from '@proj-airi/ui'
|
||||
import { ContainerError } from '@proj-airi/ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import ChatToolCallShell from './tool-call-shell.vue'
|
||||
|
||||
import { createToolResultError } from './tool-call-display'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -38,82 +40,42 @@ function emitToolCallRerun() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Collapsible
|
||||
:class="[
|
||||
'bg-primary-100/40 dark:bg-primary-900/60 rounded-lg px-1 pb-1 pt-1',
|
||||
'flex flex-col gap-2 items-start',
|
||||
]"
|
||||
<ChatToolCallShell
|
||||
:tool-name="toolName"
|
||||
:state="state"
|
||||
>
|
||||
<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
|
||||
:class="[
|
||||
'w-full',
|
||||
'inline-flex items-center gap-1',
|
||||
'mt-2 whitespace-pre-wrap break-words font-mono',
|
||||
]"
|
||||
>
|
||||
<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 }}
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="whitespace-pre-wrap break-words font-mono">
|
||||
{{ formattedArgs }}
|
||||
</div>
|
||||
</Collapsible>
|
||||
</ChatToolCallShell>
|
||||
</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(() => [
|
||||
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))
|
||||
</script>
|
||||
|
||||
@@ -5,5 +5,6 @@ export { default as ChatHistory } from './components/history.vue'
|
||||
export { default as ChatSessionsDrawer } from './components/sessions-drawer.vue'
|
||||
export { createToolResultError, normalizeToolResultText } from './components/tool-call-display'
|
||||
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 JournalPreviewModal } from './JournalPreviewModal.vue'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Span, SpanContext, SpanStatusCode } from '@opentelemetry/api'
|
||||
import type { ReadableSpan, SpanExporter } from '@opentelemetry/sdk-trace-base'
|
||||
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 { 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 BROADCAST_CHANNEL = 'io-tracer-channel' // TODO: Use simple BroadcastChannel for now
|
||||
|
||||
export interface SerializedSpan {
|
||||
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 {
|
||||
function serializeSpan(span: ReadableSpan): SerializedIOSpan {
|
||||
const ctx = span.spanContext()
|
||||
const parentCtx = span.parentSpanContext
|
||||
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 n = Number(nano)
|
||||
return [Math.floor(n / 1e9), n % 1e9]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Tool } from '@xsai/shared-chat'
|
||||
import type {} from 'pinia-plugin-synced'
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
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', () => ({
|
||||
activeTurnSpan: ioTracerMocks.activeTurnSpan,
|
||||
startSpan: ioTracerMocks.startSpanMock,
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ChatOrchestratorRuntimeState, ChatOrchestratorSendOptions, StreamE
|
||||
import type { WebSocketEventInputs } from '@proj-airi/server-sdk'
|
||||
import type { ChatProvider } from '@xsai-ext/providers/utils'
|
||||
import type { Message } from '@xsai/shared-chat'
|
||||
import type {} from 'pinia-plugin-synced'
|
||||
|
||||
import type { ChatHistoryItem, ChatToolReference } from '../types/chat'
|
||||
import type { ToolCallRerunPayload } from './tool-call-rerun'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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 { ChatWsClient, CloudChatMapper } from '../../libs/chat-sync'
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type {} from 'pinia-plugin-synced'
|
||||
|
||||
import type { StreamingAssistantMessage } from '../../types/chat'
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
@@ -9,8 +11,8 @@ export const useChatStreamStore = defineStore('chat-stream', () => {
|
||||
const chatSession = useChatSessionStore()
|
||||
const streamingMessage = ref<StreamingAssistantMessage>({ role: 'assistant', content: '', slices: [], tool_results: [], createdAt: Date.now() })
|
||||
|
||||
function beginStream() {
|
||||
streamingMessage.value = { role: 'assistant', content: '', slices: [], tool_results: [], createdAt: Date.now() }
|
||||
function beginStream(id: string) {
|
||||
streamingMessage.value = { role: 'assistant', content: '', slices: [], tool_results: [], createdAt: Date.now(), id }
|
||||
}
|
||||
|
||||
function appendStreamLiteral(literal: string) {
|
||||
|
||||
@@ -577,7 +577,7 @@ describe('context bridge contract', () => {
|
||||
streamSender.postMessage({ type: 'before-send', message: 'ping', sessionId: 'remote-session', context })
|
||||
await vi.waitFor(() => {
|
||||
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 })
|
||||
@@ -638,7 +638,7 @@ describe('context bridge contract', () => {
|
||||
|
||||
streamSender.postMessage({ type: 'before-send', message: 'ping', sessionId: 'remote-session', context })
|
||||
await vi.waitFor(() => {
|
||||
expect(beginStreamMock).toHaveBeenCalledTimes(1)
|
||||
expect(beginStreamMock).toHaveBeenCalledWith('turn-1')
|
||||
})
|
||||
|
||||
currentGeneration = 8
|
||||
|
||||
@@ -809,7 +809,7 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () =
|
||||
generation: chatSession.getSessionGenerationValue(chatSession.activeSessionId),
|
||||
}
|
||||
chatOrchestrator.sending = true
|
||||
chatStream.beginStream()
|
||||
chatStream.beginStream(event.context.turnId)
|
||||
break
|
||||
case 'after-send':
|
||||
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 { useMutation, useQuery } from '@pinia/colada'
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
TranscriptionProvider,
|
||||
TranscriptionProviderWithExtraOptions,
|
||||
} from '@xsai-ext/providers/utils'
|
||||
import type {} from 'pinia-plugin-synced'
|
||||
|
||||
import type { ProviderMetadata, ProviderValidationPlan } from '../../libs/providers'
|
||||
import type { ModelInfo, ProviderDefinition, ProviderInstance } from '../../libs/providers/types'
|
||||
|
||||
Reference in New Issue
Block a user