mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
feat(web): add Web WASM AI chat
This commit is contained in:
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)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user