feat(web): add Web WASM AI chat

This commit is contained in:
digua committed 2026-07-27 00:09:33 +08:00
1 parent 78bc9d392e
commit 7dcf5d9070
65 files changed
+4455 -27

No files matched your search

@@ -0,0 +1,551 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { RuntimeContentBlock, RuntimeMessage } from '@openchatlab/ai-runtime'
import {
WebAIChatRuntime,
type AgentStreamEvent,
type RuntimeConversation,
type SaveWebModelConfigInput,
type WebAIConnectionTestResult,
type WebModelConfig,
} from '@openchatlab/web-ai-runtime'
import ChatMessage from '@/components/AIChat/chat/ChatMessage.vue'
import { useBrowserRuntimeRpc } from '@/services/browser-runtime/service'
import { reportRuntimeLog } from '@/services/log-report'
import { useToast } from '@/composables/useToast'
import WebAIModelSetupModal from './WebAIModelSetupModal.vue'
import { toWebAIChatMessage } from './message-mapper'
const props = defineProps<{
sessionId: string
sessionName: string
}>()
const { t, locale } = useI18n()
const toast = useToast()
const runtime = new WebAIChatRuntime(useBrowserRuntimeRpc())
const setupModal = ref<InstanceType<typeof WebAIModelSetupModal> | null>(null)
const setupOpen = ref(false)
const testing = ref(false)
const saving = ref(false)
const config = ref<WebModelConfig | null>(null)
const conversations = ref<RuntimeConversation[]>([])
const conversationId = ref<string | null>(null)
const messages = ref<RuntimeMessage[]>([])
const streamingMessage = ref<RuntimeMessage | null>(null)
const prompt = ref('')
const generating = ref(false)
const loading = ref(true)
const errorText = ref('')
const processText = ref('')
const listOpen = ref(false)
const messagesContainer = ref<HTMLElement | null>(null)
const displayMessages = computed(() => {
const result = messages.value.map((message) => toWebAIChatMessage(message))
if (streamingMessage.value) result.push(toWebAIChatMessage(streamingMessage.value, true))
return result
})
const presetQuestions = computed(() => [
t('webAI.presets.overview'),
t('webAI.presets.topics'),
t('webAI.presets.activity'),
])
onMounted(async () => {
try {
config.value = await runtime.getConfig()
await loadConversations()
if (!config.value) setupOpen.value = true
} catch (error) {
errorText.value = error instanceof Error ? error.message : String(error)
} finally {
loading.value = false
}
})
watch(
() => props.sessionId,
async () => {
if (generating.value) return
conversationId.value = null
messages.value = []
await loadConversations()
}
)
watch(displayMessages, () => void scrollToBottom(), { deep: true })
async function loadConversations(preferredId?: string) {
conversations.value = await runtime.conversations.listConversations(props.sessionId)
const nextId = preferredId ?? conversationId.value ?? conversations.value[0]?.id ?? null
if (nextId && conversations.value.some((item) => item.id === nextId)) {
await selectConversation(nextId)
} else {
conversationId.value = null
messages.value = []
}
}
async function selectConversation(id: string) {
if (generating.value || id === conversationId.value) return
conversationId.value = id
messages.value = await runtime.conversations.getMessages(id)
errorText.value = ''
listOpen.value = false
}
function newConversation() {
if (generating.value) return
conversationId.value = null
messages.value = []
errorText.value = ''
listOpen.value = false
}
async function deleteConversation(id: string) {
if (generating.value || !window.confirm(t('webAI.conversations.deleteConfirm'))) return
await runtime.conversations.deleteConversation(id)
if (conversationId.value === id) conversationId.value = null
await loadConversations()
}
async function renameConversation(conversation: RuntimeConversation) {
if (generating.value) return
const title = window.prompt(t('webAI.conversations.renamePrompt'), conversation.title ?? props.sessionName)?.trim()
if (!title) return
await runtime.conversations.renameConversation(conversation.id, title)
await loadConversations(conversation.id)
}
async function ensureConversation(question: string): Promise<string> {
if (conversationId.value) return conversationId.value
const title = question.replace(/\s+/g, ' ').slice(0, 36)
const conversation = await runtime.conversations.createConversation(props.sessionId, title)
conversationId.value = conversation.id
conversations.value = [conversation, ...conversations.value]
return conversation.id
}
async function send(content = prompt.value) {
const question = content.trim()
if (!question || generating.value) return
if (!config.value) {
setupOpen.value = true
return
}
prompt.value = ''
errorText.value = ''
processText.value = ''
generating.value = true
reportRuntimeLog({
level: 'info',
scope: 'web-ai',
message: 'AI generation started',
data: { sessionId: props.sessionId, conversationId: conversationId.value },
})
try {
const id = await ensureConversation(question)
messages.value.push({
id: `optimistic-${Date.now()}`,
conversationId: id,
role: 'user',
content: question,
createdAt: Date.now(),
})
await runtime.run({
sessionId: props.sessionId,
conversationId: id,
locale: locale.value,
userMessage: question,
onEvent: handleEvent,
})
await refreshCurrentConversation()
await loadConversationListOnly()
reportRuntimeLog({
level: 'info',
scope: 'web-ai',
message: 'AI generation completed',
data: { sessionId: props.sessionId, conversationId: id },
})
} catch (error) {
const aborted = isAbortError(error)
if (!aborted) {
errorText.value = getFriendlyError(error)
}
reportRuntimeLog({
level: aborted ? 'info' : 'error',
scope: 'web-ai',
message: aborted ? 'AI generation aborted' : 'AI generation failed',
data: { code: getErrorCode(error), aborted },
})
await refreshCurrentConversation()
} finally {
generating.value = false
streamingMessage.value = null
processText.value = ''
}
}
function stop() {
if (conversationId.value && runtime.stop(conversationId.value)) {
reportRuntimeLog({
level: 'info',
scope: 'web-ai',
message: 'AI generation stopped',
data: { sessionId: props.sessionId, conversationId: conversationId.value },
})
}
}
async function regenerate() {
if (!conversationId.value || generating.value || !config.value) return
const previousMessages = messages.value
const latestAssistantIndex = messages.value.findLastIndex((message) => message.role === 'assistant')
if (latestAssistantIndex >= 0) {
messages.value = messages.value.filter((_, index) => index !== latestAssistantIndex)
}
generating.value = true
errorText.value = ''
try {
await runtime.regenerateLast({
sessionId: props.sessionId,
conversationId: conversationId.value,
locale: locale.value,
onEvent: handleEvent,
})
} catch (error) {
messages.value = previousMessages
if (!isAbortError(error)) errorText.value = getFriendlyError(error)
} finally {
generating.value = false
streamingMessage.value = null
processText.value = ''
await refreshCurrentConversation()
}
}
function handleEvent(event: AgentStreamEvent) {
if (event.type === 'start') {
streamingMessage.value = {
id: event.messageId,
conversationId: conversationId.value!,
role: 'assistant',
content: '',
createdAt: Date.now(),
blocks: [],
}
return
}
const message = streamingMessage.value
if (!message) return
if (event.type === 'text-delta') {
message.content += event.delta
appendTextBlock(message, 'text', event.delta)
} else if (event.type === 'reasoning-delta') {
appendTextBlock(message, 'reasoning', event.delta)
} else if (event.type === 'tool-start') {
message.blocks?.push({ type: 'tool', callId: event.callId, name: event.name, input: event.input })
processText.value = t('webAI.usingTool', { name: event.name })
} else if (event.type === 'tool-result') {
const block = message.blocks?.find((item) => item.type === 'tool' && item.callId === event.callId) as
| Extract<RuntimeContentBlock, { type: 'tool' }>
| undefined
if (block) {
block.result = event.result
block.isError = event.isError
}
} else if (event.type === 'chart') {
message.blocks?.push({ type: 'chart', payload: event.payload })
} else if (event.type === 'evidence') {
message.blocks?.push({ type: 'evidence', payload: event.payload })
} else if (event.type === 'compression-start') {
processText.value = t('webAI.compressing')
} else if (event.type === 'compression-done') {
processText.value = ''
}
}
function appendTextBlock(message: RuntimeMessage, type: 'text' | 'reasoning', delta: string) {
const last = message.blocks?.at(-1)
if (last?.type === type) last.text += delta
else message.blocks?.push({ type, text: delta })
}
async function refreshCurrentConversation() {
if (conversationId.value) messages.value = await runtime.conversations.getMessages(conversationId.value)
}
async function loadConversationListOnly() {
conversations.value = await runtime.conversations.listConversations(props.sessionId)
}
async function testConnection(input: SaveWebModelConfigInput) {
testing.value = true
const result = await runtime.testConnection(input)
setupModal.value?.setTestResult(result)
testing.value = false
}
async function saveConfig(input: SaveWebModelConfigInput) {
saving.value = true
const result: WebAIConnectionTestResult = await runtime.testConnection(input)
setupModal.value?.setTestResult(result)
if (result.ok) {
config.value = await runtime.saveConfig(input)
reportRuntimeLog({
level: 'info',
scope: 'web-ai',
message: 'Browser model configuration saved',
data: { provider: input.provider, model: input.model },
})
setupOpen.value = false
toast.success(t('webAI.config.saved'))
}
saving.value = false
}
function getFriendlyError(error: unknown): string {
if (error && typeof error === 'object' && 'data' in error) {
const code = (error as { data?: { code?: string } }).data?.code
if (code && ['AUTH', 'RATE_LIMIT', 'MODEL_NOT_FOUND', 'TIMEOUT', 'NETWORK_OR_CORS'].includes(code)) {
return t(`webAI.errors.${code}`)
}
}
return error instanceof Error ? error.message : String(error)
}
function getErrorCode(error: unknown): string {
if (error && typeof error === 'object' && 'data' in error) {
return String((error as { data?: { code?: unknown } }).data?.code ?? 'UNKNOWN')
}
return isAbortError(error) ? 'ABORTED' : 'UNKNOWN'
}
function isAbortError(error: unknown): boolean {
if (error instanceof DOMException && error.name === 'AbortError') return true
if (!error || typeof error !== 'object') return false
const candidate = error as { code?: unknown; data?: { code?: unknown } }
return candidate.code === 'ABORTED' || candidate.data?.code === 'ABORTED'
}
async function scrollToBottom() {
await nextTick()
messagesContainer.value?.scrollTo({ top: messagesContainer.value.scrollHeight, behavior: 'smooth' })
}
function handleInputKeydown(event: KeyboardEvent) {
if (event.key !== 'Enter' || event.shiftKey || event.isComposing) return
event.preventDefault()
void send()
}
</script>
<template>
<div class="relative flex h-full min-h-0 overflow-hidden bg-white/40 dark:bg-page-dark">
<button
v-if="listOpen"
type="button"
class="absolute inset-0 z-20 bg-black/20 md:hidden"
:aria-label="t('common.close')"
@click="listOpen = false"
/>
<aside
class="absolute inset-y-0 left-0 z-30 flex w-64 shrink-0 flex-col border-r border-gray-200 bg-white transition-transform md:static md:translate-x-0 dark:border-gray-800 dark:bg-page-dark"
:class="listOpen ? 'translate-x-0' : '-translate-x-full'"
>
<div class="flex items-center justify-between px-3 py-3">
<span class="text-xs font-semibold text-gray-500 dark:text-gray-400">{{ t('webAI.conversations.title') }}</span>
<UButton
icon="i-heroicons-plus"
size="xs"
color="neutral"
variant="ghost"
:aria-label="t('webAI.conversations.new')"
@click="newConversation"
/>
</div>
<div class="min-h-0 flex-1 space-y-1 overflow-y-auto px-2 pb-3">
<p v-if="conversations.length === 0" class="px-2 py-6 text-center text-xs text-gray-400">
{{ t('webAI.conversations.empty') }}
</p>
<div
v-for="conversation in conversations"
:key="conversation.id"
class="group flex items-center rounded-lg transition-colors"
:class="
conversation.id === conversationId
? 'bg-primary-50 text-primary-700 dark:bg-primary-900/20 dark:text-primary-300'
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800/60'
"
>
<button
type="button"
class="min-w-0 flex-1 truncate px-2.5 py-2 text-left text-xs"
@click="selectConversation(conversation.id)"
>
{{ conversation.title || t('webAI.conversations.untitled') }}
</button>
<div class="mr-1 flex opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
<UButton
icon="i-heroicons-pencil"
size="xs"
color="neutral"
variant="ghost"
:aria-label="t('webAI.conversations.rename')"
@click="renameConversation(conversation)"
/>
<UButton
icon="i-heroicons-trash"
size="xs"
color="neutral"
variant="ghost"
:aria-label="t('webAI.conversations.delete')"
@click="deleteConversation(conversation.id)"
/>
</div>
</div>
</div>
</aside>
<section class="flex min-w-0 flex-1 flex-col">
<div class="flex h-11 shrink-0 items-center gap-2 border-b border-gray-100 px-3 dark:border-gray-800/70">
<UButton
icon="i-heroicons-bars-3"
size="sm"
color="neutral"
variant="ghost"
class="md:hidden"
:aria-label="t('webAI.conversations.title')"
@click="listOpen = true"
/>
<div class="flex min-w-0 items-center gap-2">
<UIcon name="i-heroicons-sparkles" class="h-4 w-4 shrink-0 text-primary-500" />
<span class="truncate text-sm font-medium text-gray-700 dark:text-gray-200">
{{ t('webAI.defaultAssistant') }}
</span>
</div>
<span v-if="processText" class="ml-auto truncate text-xs text-gray-400">{{ processText }}</span>
<UButton
icon="i-heroicons-cog-6-tooth"
size="sm"
color="neutral"
variant="ghost"
class="ml-auto"
:aria-label="t('webAI.config.title')"
@click="setupOpen = true"
/>
</div>
<div ref="messagesContainer" class="min-h-0 flex-1 overflow-x-hidden overflow-y-auto px-3 py-5 sm:px-5">
<div v-if="loading" class="flex h-full items-center justify-center">
<UIcon name="i-heroicons-arrow-path" class="h-5 w-5 animate-spin text-primary-500" />
</div>
<div
v-else-if="displayMessages.length === 0"
class="mx-auto flex min-h-full max-w-2xl flex-col items-center justify-center px-3 pb-24 text-center"
>
<div class="flex h-11 w-11 items-center justify-center rounded-2xl bg-primary-50 dark:bg-primary-900/20">
<UIcon name="i-heroicons-sparkles" class="h-5 w-5 text-primary-500" />
</div>
<h2 class="mt-4 text-lg font-semibold text-gray-900 dark:text-white">{{ t('webAI.emptyTitle') }}</h2>
<p class="mt-1 max-w-md text-sm leading-6 text-gray-500 dark:text-gray-400">
{{ t('webAI.emptyDescription') }}
</p>
<div class="mt-5 flex max-w-xl flex-wrap justify-center gap-2">
<button
v-for="question in presetQuestions"
:key="question"
type="button"
class="rounded-full border border-gray-200 bg-white px-3 py-1.5 text-xs text-gray-600 transition-colors hover:border-primary-200 hover:bg-primary-50 hover:text-primary-700 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:hover:border-primary-800 dark:hover:bg-primary-900/20"
@click="send(question)"
>
{{ question }}
</button>
</div>
</div>
<div v-else class="mx-auto max-w-3xl space-y-6 px-1 sm:px-4">
<ChatMessage
v-for="message in displayMessages"
:key="message.id"
:message-id="message.id"
:role="message.role"
:content="message.content"
:timestamp="message.timestamp"
:content-blocks="message.contentBlocks"
:is-streaming="message.isStreaming"
/>
</div>
</div>
<div class="shrink-0 px-3 pb-3 sm:px-5">
<div class="mx-auto max-w-3xl">
<div
v-if="errorText"
class="mb-2 flex items-start gap-2 rounded-lg bg-red-50 px-3 py-2 text-xs text-red-700 dark:bg-red-900/20 dark:text-red-300"
>
<UIcon name="i-heroicons-exclamation-circle" class="mt-0.5 h-4 w-4 shrink-0" />
<span class="min-w-0 flex-1">{{ errorText }}</span>
<button type="button" @click="errorText = ''"><UIcon name="i-heroicons-x-mark" class="h-4 w-4" /></button>
</div>
<div
class="rounded-2xl border border-gray-200 bg-white p-2 shadow-sm focus-within:border-primary-300 dark:border-gray-700 dark:bg-gray-900 dark:focus-within:border-primary-700"
>
<textarea
v-model="prompt"
rows="2"
class="max-h-40 min-h-12 w-full resize-none bg-transparent px-2 py-1.5 text-sm text-gray-900 outline-none placeholder:text-gray-400 dark:text-white"
:placeholder="t('webAI.inputPlaceholder')"
:disabled="generating"
@keydown="handleInputKeydown"
/>
<div class="flex items-center justify-between gap-2 px-1">
<button
v-if="messages.some((message) => message.role === 'assistant') && !generating"
type="button"
class="rounded-md px-2 py-1 text-xs text-gray-500 transition-colors hover:bg-gray-100 dark:hover:bg-gray-800"
@click="regenerate"
>
{{ t('webAI.regenerate') }}
</button>
<span v-else />
<UButton
v-if="generating"
icon="i-heroicons-stop-solid"
size="sm"
color="neutral"
variant="soft"
@click="stop"
>
{{ t('webAI.stop') }}
</UButton>
<UButton
v-else
icon="i-heroicons-arrow-up"
size="sm"
color="primary"
:disabled="!prompt.trim()"
@click="send()"
>
{{ t('webAI.send') }}
</UButton>
</div>
</div>
<p class="mt-1.5 text-center text-[11px] text-gray-400">{{ t('webAI.footer') }}</p>
</div>
</div>
</section>
<WebAIModelSetupModal
ref="setupModal"
v-model:open="setupOpen"
:config="config"
:testing="testing"
:saving="saving"
@test="testConnection"
@save="saveConfig"
/>
</div>
</template>
@@ -0,0 +1,164 @@
<script setup lang="ts">
import { reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type {
SaveWebModelConfigInput,
WebAIConnectionTestResult,
WebModelConfig,
WebAIProvider,
} from '@openchatlab/web-ai-runtime'
const props = defineProps<{
open: boolean
config: WebModelConfig | null
testing?: boolean
saving?: boolean
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
test: [input: SaveWebModelConfigInput]
save: [input: SaveWebModelConfigInput]
}>()
const { t } = useI18n()
const result = ref<WebAIConnectionTestResult | null>(null)
const form = reactive<SaveWebModelConfigInput>({
provider: 'deepseek',
baseURL: 'https://api.deepseek.com',
model: 'deepseek-v4-flash',
apiKey: '',
})
const providerOptions = [
{ label: 'DeepSeek', value: 'deepseek' },
{ label: t('webAI.config.openAICompatible'), value: 'openai-compatible' },
]
watch(
() => props.open,
(open) => {
if (!open) return
result.value = null
form.provider = props.config?.provider ?? 'deepseek'
form.baseURL = props.config?.baseURL ?? 'https://api.deepseek.com'
form.model = props.config?.model ?? 'deepseek-v4-flash'
form.contextWindow = props.config?.contextWindow
form.apiKey = ''
},
{ immediate: true }
)
watch(
() => form.provider,
(provider) => {
if (provider === 'deepseek' && (!form.baseURL || form.baseURL.includes('example'))) {
form.baseURL = 'https://api.deepseek.com'
form.model = 'deepseek-v4-flash'
}
}
)
function getInput(): SaveWebModelConfigInput | null {
if (!form.apiKey.trim() || !form.model.trim() || !form.baseURL?.trim()) {
result.value = {
ok: false,
error: { code: 'NOT_CONFIGURED', message: t('webAI.config.required'), retryable: false },
}
return null
}
return {
provider: form.provider as WebAIProvider,
baseURL: form.baseURL.trim(),
model: form.model.trim(),
contextWindow: form.contextWindow,
apiKey: form.apiKey.trim(),
}
}
function testConnection() {
const input = getInput()
if (input) emit('test', input)
}
function save() {
const input = getInput()
if (input) emit('save', input)
}
defineExpose({
setTestResult(value: WebAIConnectionTestResult) {
result.value = value
},
})
</script>
<template>
<UModal :open="open" :title="t('webAI.config.title')" @update:open="emit('update:open', $event)">
<template #content>
<div class="w-full max-w-lg bg-white p-5 dark:bg-page-dark">
<div class="flex items-start justify-between gap-4">
<div>
<h2 class="text-base font-semibold text-gray-900 dark:text-white">{{ t('webAI.config.title') }}</h2>
<p class="mt-1 text-xs leading-5 text-gray-500 dark:text-gray-400">{{ t('webAI.config.description') }}</p>
</div>
<UButton
icon="i-heroicons-x-mark"
color="neutral"
variant="ghost"
size="sm"
:aria-label="t('common.close')"
@click="emit('update:open', false)"
/>
</div>
<div class="mt-5 space-y-4">
<UFormField :label="t('webAI.config.provider')">
<USelect v-model="form.provider" class="w-full" :items="providerOptions" value-key="value" />
</UFormField>
<UFormField :label="t('webAI.config.baseURL')">
<UInput v-model="form.baseURL" class="w-full" autocomplete="url" />
</UFormField>
<UFormField :label="t('webAI.config.model')">
<UInput v-model="form.model" class="w-full" autocomplete="off" />
</UFormField>
<UFormField :label="t('webAI.config.apiKey')">
<UInput
v-model="form.apiKey"
class="w-full"
type="password"
autocomplete="off"
:placeholder="config ? t('webAI.config.apiKeyReplace') : t('webAI.config.apiKeyPlaceholder')"
/>
</UFormField>
<div
class="rounded-lg bg-gray-50 px-3 py-2.5 text-xs leading-5 text-gray-500 dark:bg-gray-800/50 dark:text-gray-400"
>
<p>{{ t('webAI.config.keyStorageWarning') }}</p>
<p class="mt-1">{{ t('webAI.config.corsWarning') }}</p>
</div>
<div
v-if="result"
class="rounded-lg px-3 py-2 text-xs"
:class="
result.ok
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-900/20 dark:text-emerald-300'
: 'bg-red-50 text-red-700 dark:bg-red-900/20 dark:text-red-300'
"
>
{{ result.ok ? t('webAI.config.testSuccess', { ms: result.latencyMs }) : result.error?.message }}
</div>
</div>
<div class="mt-5 flex justify-end gap-2">
<UButton color="neutral" variant="soft" :loading="testing" @click="testConnection">
{{ t('webAI.config.test') }}
</UButton>
<UButton color="primary" :loading="saving" @click="save">{{ t('common.save') }}</UButton>
</div>
</div>
</template>
</UModal>
</template>
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { toWebAIContentBlocks } from './message-mapper'
describe('toWebAIContentBlocks', () => {
it('maps shared runtime blocks to the existing ChatLab message renderer', () => {
const blocks = toWebAIContentBlocks([
{ type: 'reasoning', text: 'checking' },
{
type: 'tool',
callId: 'call-1',
name: 'get_chat_overview',
input: {},
result: { content: '{"count":12}' },
},
{ type: 'chart', payload: { spec: { type: 'bar', title: 'Messages' }, rowCount: 1, data: {} } },
{ type: 'text', text: 'done' },
])
assert.deepEqual(
blocks.map((block) => block.type),
['think', 'tool', 'chart', 'text']
)
assert.equal(blocks[1]?.type === 'tool' ? blocks[1].tool.status : null, 'done')
})
})
@@ -0,0 +1,50 @@
import type { RuntimeContentBlock, RuntimeMessage } from '@openchatlab/ai-runtime'
import type { ChartPayload, ChatEvidencePayload } from '@openchatlab/core'
import type { ChatMessage, ContentBlock } from '@/stores/aiChat'
export function toWebAIContentBlocks(blocks: RuntimeContentBlock[] | undefined): ContentBlock[] {
return (blocks ?? []).flatMap((block): ContentBlock[] => {
if (block.type === 'text') return [{ type: 'text', text: block.text }]
if (block.type === 'reasoning') return [{ type: 'think', tag: 'reasoning', text: block.text }]
if (block.type === 'tool') {
return [
{
type: 'tool',
tool: {
name: block.name,
displayName: block.name,
status: block.result ? (block.isError ? 'error' : 'done') : 'running',
params: isRecord(block.input) ? block.input : undefined,
toolCallId: block.callId,
result: block.result?.content,
displayResult: block.result?.content,
isError: block.isError,
},
},
]
}
if (block.type === 'chart') {
const payloads = Array.isArray(block.payload) ? block.payload : [block.payload]
return payloads.filter(isRecord).map((chart) => ({ type: 'chart', chart: chart as unknown as ChartPayload }))
}
if (block.type === 'evidence' && isRecord(block.payload)) {
return [{ type: 'evidence', evidence: block.payload as unknown as ChatEvidencePayload }]
}
return []
})
}
export function toWebAIChatMessage(message: RuntimeMessage, isStreaming = false): ChatMessage {
return {
id: message.id,
role: message.role,
content: message.content,
timestamp: message.createdAt,
contentBlocks: toWebAIContentBlocks(message.blocks),
isStreaming,
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
+16 -4
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, watch } from 'vue'
import { computed, defineAsyncComponent, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
@@ -9,13 +9,18 @@ import { useSessionAnalysisPageBase } from '@/composables'
import { useSessionStore } from '@/stores/session'
import SessionInsights from '../components/session/insights/SessionInsights.vue'
const WebAIChat = defineAsyncComponent(() => import('../components/ai/WebAIChat.vue'))
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const sessionStore = useSessionStore()
const { currentSessionId, isInitialized, sessions } = storeToRefs(sessionStore)
const tabs = [{ id: 'insights', labelKey: 'analysis.tabs.insights', icon: 'i-heroicons-presentation-chart-bar' }]
const tabs = [
{ id: 'insights', labelKey: 'analysis.tabs.insights', icon: 'i-heroicons-presentation-chart-bar' },
{ id: 'ai-chat', labelKey: 'analysis.tabs.aiChat', icon: 'i-heroicons-sparkles' },
]
const {
activeTab,
@@ -100,8 +105,8 @@ watch(
@update:full-range="fullTimeRange = $event"
/>
<div class="relative flex-1 overflow-y-auto">
<LoadingState v-if="isLoading" variant="overlay" :text="t('common.loading')" />
<div class="relative min-h-0 flex-1" :class="activeTab === 'ai-chat' ? 'overflow-hidden' : 'overflow-y-auto'">
<LoadingState v-if="isLoading && activeTab !== 'ai-chat'" variant="overlay" :text="t('common.loading')" />
<SessionInsights
v-if="activeTab === 'insights'"
@@ -117,6 +122,13 @@ watch(
:filtered-member-count="filteredMemberCount"
:time-filter="timeFilter"
/>
<WebAIChat
v-else-if="activeTab === 'ai-chat' && currentSessionId"
:key="'ai-chat-' + currentSessionId"
:session-id="currentSessionId"
:session-name="session.name"
/>
</div>
</template>