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">
|
<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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user