feat(web): chat

This commit is contained in:
Acbox
2026-02-10 19:33:00 +08:00
parent 169d9a35af
commit 6ac8874fa8
11 changed files with 857 additions and 206 deletions
+74 -74
View File
@@ -1,103 +1,103 @@
import { fetchApi } from '@/utils/request'
// ---- Types ----
export interface Bot {
id: string
name?: string
export interface StreamEvent {
type?:
| 'text_start' | 'text_delta' | 'text_end'
| 'reasoning_start' | 'reasoning_delta' | 'reasoning_end'
| 'tool_call_start' | 'tool_call_end'
| 'agent_start' | 'agent_end'
delta?: string
toolName?: string
input?: unknown
result?: unknown
[key: string]: unknown
}
export interface BotsResponse {
items: Bot[]
}
export type StreamEventHandler = (event: StreamEvent) => void
export interface CreateSessionResponse {
session_id: string
}
// ---- Plain async functions (used by chat store) ----
export async function fetchBots(): Promise<Bot[]> {
const res = await fetchApi<BotsResponse>('/bots')
return res.items
}
// ---- Session ----
export async function createSession(botId: string): Promise<string> {
const res = await fetchApi<CreateSessionResponse>(
`/bots/${botId}/web/sessions`,
{ method: 'POST' },
)
return res.session_id
}
export async function sendChatMessage(
botId: string,
sessionId: string,
text: string,
): Promise<void> {
await fetchApi(`/bots/${botId}/web/sessions/${sessionId}/messages`, {
const token = localStorage.getItem('token') ?? ''
const resp = await fetch(`/api/bots/${botId}/web/sessions`, {
method: 'POST',
body: { text },
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
})
if (!resp.ok) throw new Error(`Failed to create session: ${resp.status}`)
const data = await resp.json()
return data.session_id
}
/**
* 创建 SSE 流连接。返回 abort 函数。
* 调用方负责处理 reader 中的数据。
*/
export function createStreamConnection(
// ---- Streaming chat ----
export function streamChat(
botId: string,
sessionId: string,
onMessage: (text: string) => void,
query: string,
onEvent: StreamEventHandler,
onDone: () => void,
onError: (err: Error) => void,
): () => void {
const controller = new AbortController()
const token = localStorage.getItem('token') ?? ''
;(async () => {
const resp = await fetch(`/api/bots/${botId}/web/sessions/${sessionId}/stream`, {
method: 'GET',
headers: { Authorization: `Bearer ${token}` },
signal: controller.signal,
}).catch(() => null)
try {
const resp = await fetch(
`/api/bots/${botId}/chat/stream?session_id=${encodeURIComponent(sessionId)}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ query }),
signal: controller.signal,
},
)
if (!resp?.ok || !resp.body) return
if (!resp.ok || !resp.body) {
onError(new Error(`Chat request failed: ${resp.status}`))
return
}
const reader = resp.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
const reader = resp.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { value, done } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
while (true) {
const { value, done } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
let idx: number
while ((idx = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, idx).trim()
buffer = buffer.slice(idx + 1)
if (!line.startsWith('data:')) continue
const payload = line.slice(5).trim()
if (!payload || payload === '[DONE]') continue
let idx: number
while ((idx = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, idx).trim()
buffer = buffer.slice(idx + 1)
if (!line.startsWith('data:')) continue
const payload = line.slice(5).trim()
if (!payload || payload === '[DONE]') continue
const text = extractTextFromEvent(payload)
if (text) onMessage(text)
try {
const event = JSON.parse(payload) as StreamEvent
onEvent(event)
} catch {
// 非 JSON payload,尝试作为纯文本
onEvent({ type: 'text_delta', delta: payload })
}
}
}
onDone()
} catch (err) {
if ((err as Error).name !== 'AbortError') {
onError(err as Error)
}
}
})()
return () => controller.abort()
}
function extractTextFromEvent(payload: string): string | null {
try {
const event = JSON.parse(payload)
if (typeof event === 'string') return event
if (typeof event?.text === 'string') return event.text
if (typeof event?.content === 'string') return event.content
if (typeof event?.data === 'string') return event.data
if (typeof event?.data?.text === 'string') return event.data.text
return null
} catch {
return payload
}
}
+9 -2
View File
@@ -50,8 +50,15 @@
},
"chat": {
"greeting": "Hi! How can I help you today?",
"inputPlaceholder": "Type your question…",
"send": "Send"
"inputPlaceholder": "Type your question… (Enter to send)",
"send": "Send",
"selectBot": "Select a Bot to start chatting",
"selectBotHint": "Choose from the list on the left",
"thinking": "Thinking…",
"thinkingInProgress": "Thinking…",
"thinkingDone": "Thought process",
"toolRunning": "Running",
"toolDone": "Done"
},
"models": {
"title": "Models",
+9 -2
View File
@@ -50,8 +50,15 @@
},
"chat": {
"greeting": "你好!有什么我可以帮你的吗?",
"inputPlaceholder": "输入你的问题…",
"send": "发送"
"inputPlaceholder": "输入你的问题… (Enter 发送)",
"send": "发送",
"selectBot": "选择一个 Bot 开始对话",
"selectBotHint": "从左侧列表中选择",
"thinking": "正在思考…",
"thinkingInProgress": "正在思考…",
"thinkingDone": "思考过程",
"toolRunning": "执行中",
"toolDone": "已完成"
},
"models": {
"title": "模型",
+2
View File
@@ -24,6 +24,7 @@ import {
faCheck,
faEye,
faEyeSlash,
faChevronRight,
} from '@fortawesome/free-solid-svg-icons'
import {
faRectangleList,
@@ -43,6 +44,7 @@ library.add(
faCheck,
faEye,
faEyeSlash,
faChevronRight,
faRectangleList,
faTrashCan,
faComments,
@@ -0,0 +1,81 @@
<template>
<div class="w-56 shrink-0 border-r flex flex-col h-full">
<div class="p-3 border-b">
<h3 class="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Bots
</h3>
</div>
<ScrollArea class="flex-1">
<div class="p-1">
<!-- Loading -->
<div
v-if="isLoading"
class="flex justify-center py-4"
>
<FontAwesomeIcon
:icon="['fas', 'spinner']"
class="size-4 animate-spin text-muted-foreground"
/>
</div>
<!-- Bot list -->
<button
v-for="bot in bots"
:key="bot.id"
class="flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-sm transition-colors hover:bg-accent"
:class="{ 'bg-accent': currentBotId === bot.id }"
@click="handleSelect(bot)"
>
<Avatar class="size-8 shrink-0">
<AvatarImage
v-if="bot.avatar_url"
:src="bot.avatar_url"
:alt="bot.display_name"
/>
<AvatarFallback class="text-xs">
{{ (bot.display_name || bot.id).slice(0, 2).toUpperCase() }}
</AvatarFallback>
</Avatar>
<div class="flex-1 text-left min-w-0">
<div class="font-medium truncate">
{{ bot.display_name || bot.id }}
</div>
<div
v-if="bot.type"
class="text-xs text-muted-foreground truncate"
>
{{ bot.type }}
</div>
</div>
</button>
<!-- Empty -->
<div
v-if="!isLoading && bots.length === 0"
class="px-3 py-6 text-center text-sm text-muted-foreground"
>
{{ $t('bots.emptyTitle') }}
</div>
</div>
</ScrollArea>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Avatar, AvatarImage, AvatarFallback, ScrollArea } from '@memoh/ui'
import { useBotList, type BotInfo } from '@/composables/api/useBots'
import { useChatStore } from '@/store/chat-list'
import { storeToRefs } from 'pinia'
const chatStore = useChatStore()
const { currentBotId } = storeToRefs(chatStore)
const { data: botData, isLoading } = useBotList()
const bots = computed<BotInfo[]>(() => botData.value ?? [])
function handleSelect(bot: BotInfo) {
chatStore.selectBot(bot.id)
}
</script>
@@ -0,0 +1,153 @@
<template>
<div class="flex-1 flex flex-col h-full min-w-0">
<!-- No bot selected -->
<div
v-if="!currentBotId"
class="flex-1 flex items-center justify-center text-muted-foreground"
>
<div class="text-center">
<p class="text-lg">{{ $t('chat.selectBot') }}</p>
<p class="text-sm mt-1">{{ $t('chat.selectBotHint') }}</p>
</div>
</div>
<template v-else>
<!-- Messages -->
<div
ref="scrollContainer"
class="flex-1 overflow-y-auto"
>
<div class="max-w-3xl mx-auto px-4 py-6 space-y-6">
<!-- Empty state -->
<div
v-if="messages.length === 0"
class="flex items-center justify-center min-h-[300px]"
>
<p class="text-muted-foreground text-lg">
{{ $t('chat.greeting') }}
</p>
</div>
<!-- Message list -->
<MessageItem
v-for="msg in messages"
:key="msg.id"
:message="msg"
/>
</div>
</div>
<!-- Input -->
<div class="border-t p-4">
<div class="max-w-3xl mx-auto relative">
<Textarea
v-model="inputText"
class="pr-16 min-h-[60px] max-h-[200px] resize-none"
:placeholder="$t('chat.inputPlaceholder')"
:disabled="!currentBotId"
@keydown.enter.exact="handleKeydown"
/>
<div class="absolute right-2 bottom-2">
<Button
v-if="!streaming"
size="sm"
:disabled="!inputText.trim() || !currentBotId"
@click="handleSend"
>
<FontAwesomeIcon :icon="['fas', 'paper-plane']" class="size-3.5" />
</Button>
<Button
v-else
size="sm"
variant="destructive"
@click="chatStore.abort()"
>
<FontAwesomeIcon :icon="['fas', 'spinner']" class="size-3.5 animate-spin" />
</Button>
</div>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
import { Textarea, Button } from '@memoh/ui'
import { useChatStore } from '@/store/chat-list'
import { storeToRefs } from 'pinia'
import MessageItem from './message-item.vue'
const chatStore = useChatStore()
const { messages, streaming, currentBotId } = storeToRefs(chatStore)
const inputText = ref('')
const scrollContainer = ref<HTMLElement>()
// 自动滚动到底部
let userScrolledUp = false
function scrollToBottom(smooth = true) {
nextTick(() => {
const el = scrollContainer.value
if (!el) return
el.scrollTo({
top: el.scrollHeight,
behavior: smooth ? 'smooth' : 'instant',
})
})
}
// 监听用户滚动
function handleScroll() {
const el = scrollContainer.value
if (!el) return
const distanceFromBottom = el.scrollHeight - el.clientHeight - el.scrollTop
userScrolledUp = distanceFromBottom > 50
}
// 内容变化时自动滚动
watch(
() => {
// 深度监听消息变化(包括流式更新)
const last = messages.value[messages.value.length - 1]
return last?.blocks.reduce((acc, b) => {
if (b.type === 'text') return acc + b.content.length
if (b.type === 'thinking') return acc + b.content.length
return acc + 1
}, 0) ?? 0
},
() => {
if (!userScrolledUp) scrollToBottom()
},
)
// 新消息时滚动
watch(
() => messages.value.length,
() => {
userScrolledUp = false
scrollToBottom()
},
)
// 注册滚动事件
watch(scrollContainer, (el) => {
if (el) el.addEventListener('scroll', handleScroll, { passive: true })
}, { immediate: true })
function handleKeydown(e: KeyboardEvent) {
// IME 输入中的回车用于确认候选词,不发送消息
if (e.isComposing) return
e.preventDefault()
handleSend()
}
function handleSend() {
const text = inputText.value.trim()
if (!text || streaming.value) return
inputText.value = ''
chatStore.sendMessage(text)
}
</script>
@@ -0,0 +1,97 @@
<template>
<div
class="flex gap-3 items-start"
:class="message.role === 'user' ? 'justify-end' : ''"
>
<!-- Assistant avatar -->
<div
v-if="message.role === 'assistant'"
class="shrink-0 size-8 rounded-full bg-primary/10 flex items-center justify-center"
>
<FontAwesomeIcon
:icon="['fas', 'robot']"
class="size-4 text-primary"
/>
</div>
<!-- Content -->
<div
class="min-w-0"
:class="message.role === 'user' ? 'max-w-[80%]' : 'flex-1 max-w-full'"
>
<!-- User message -->
<div
v-if="message.role === 'user'"
class="rounded-2xl rounded-tr-sm bg-primary text-primary-foreground px-4 py-2.5 text-sm whitespace-pre-wrap"
>
{{ (message.blocks[0] as TextBlock)?.content }}
</div>
<!-- Assistant message blocks -->
<div
v-else
class="space-y-3"
>
<template
v-for="(block, i) in message.blocks"
:key="i"
>
<!-- Thinking block -->
<ThinkingBlock
v-if="block.type === 'thinking'"
:block="(block as ThinkingBlockType)"
:streaming="message.streaming && !block.done"
/>
<!-- Tool call block -->
<ToolCallBlock
v-else-if="block.type === 'tool_call'"
:block="(block as ToolCallBlockType)"
/>
<!-- Text block -->
<div
v-else-if="block.type === 'text' && block.content"
class="prose prose-sm dark:prose-invert max-w-none *:first:mt-0"
>
<MarkdownRender
:content="block.content"
custom-id="chat-msg"
/>
</div>
</template>
<!-- Streaming indicator -->
<div
v-if="message.streaming && message.blocks.length === 0"
class="flex items-center gap-2 text-sm text-muted-foreground h-8"
>
<FontAwesomeIcon
:icon="['fas', 'spinner']"
class="size-3.5 animate-spin"
/>
{{ $t('chat.thinking') }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import MarkdownRender, { enableKatex, enableMermaid } from 'markstream-vue'
enableKatex()
enableMermaid()
import ThinkingBlock from './thinking-block.vue'
import ToolCallBlock from './tool-call-block.vue'
import type {
ChatMessage,
TextBlock,
ThinkingBlock as ThinkingBlockType,
ToolCallBlock as ToolCallBlockType,
} from '@/store/chat-list'
defineProps<{
message: ChatMessage
}>()
</script>
@@ -0,0 +1,45 @@
<template>
<Collapsible v-model:open="isOpen">
<CollapsibleTrigger class="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer group">
<FontAwesomeIcon
:icon="['fas', 'chevron-right']"
class="size-3 transition-transform"
:class="{ 'rotate-90': isOpen }"
/>
<span class="flex items-center gap-1.5">
<template v-if="streaming">
<FontAwesomeIcon
:icon="['fas', 'spinner']"
class="size-3 animate-spin"
/>
{{ $t('chat.thinkingInProgress') }}
</template>
<template v-else>
💭 {{ $t('chat.thinkingDone') }}
</template>
</span>
</CollapsibleTrigger>
<CollapsibleContent>
<div class="mt-2 ml-5 pl-3 border-l-2 border-muted text-sm text-muted-foreground">
<div
class="whitespace-pre-wrap leading-relaxed"
v-text="block.content"
/>
</div>
</CollapsibleContent>
</Collapsible>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@memoh/ui'
import type { ThinkingBlock } from '@/store/chat-list'
defineProps<{
block: ThinkingBlock
streaming: boolean
}>()
// 流式中默认展开,完成后折叠
const isOpen = ref(true)
</script>
@@ -0,0 +1,81 @@
<template>
<div class="rounded-lg border bg-muted/30 text-sm overflow-hidden">
<!-- Header -->
<div class="flex items-center gap-2 px-3 py-2 bg-muted/50">
<FontAwesomeIcon
:icon="['fas', block.done ? 'check' : 'spinner']"
class="size-3"
:class="block.done ? 'text-green-600 dark:text-green-400' : 'animate-spin text-muted-foreground'"
/>
<span class="font-mono font-medium text-xs">
{{ block.toolName }}
</span>
<Badge
v-if="block.done"
variant="secondary"
class="text-[10px] ml-auto"
>
{{ $t('chat.toolDone') }}
</Badge>
<Badge
v-else
variant="outline"
class="text-[10px] ml-auto"
>
{{ $t('chat.toolRunning') }}
</Badge>
</div>
<!-- Input (collapsible) -->
<Collapsible v-if="block.input">
<CollapsibleTrigger class="flex items-center gap-1.5 px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground cursor-pointer w-full">
<FontAwesomeIcon
:icon="['fas', 'chevron-right']"
class="size-2.5 transition-transform"
:class="{ 'rotate-90': inputOpen }"
/>
Input
</CollapsibleTrigger>
<CollapsibleContent>
<pre class="px-3 pb-2 text-xs text-muted-foreground overflow-x-auto whitespace-pre-wrap break-all">{{ formatJson(block.input) }}</pre>
</CollapsibleContent>
</Collapsible>
<!-- Result (collapsible) -->
<Collapsible v-if="block.done && block.result != null">
<CollapsibleTrigger class="flex items-center gap-1.5 px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground cursor-pointer w-full border-t border-muted">
<FontAwesomeIcon
:icon="['fas', 'chevron-right']"
class="size-2.5 transition-transform"
:class="{ 'rotate-90': resultOpen }"
/>
Result
</CollapsibleTrigger>
<CollapsibleContent>
<pre class="px-3 pb-2 text-xs text-muted-foreground overflow-x-auto whitespace-pre-wrap break-all">{{ formatJson(block.result) }}</pre>
</CollapsibleContent>
</Collapsible>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Badge, Collapsible, CollapsibleTrigger, CollapsibleContent } from '@memoh/ui'
import type { ToolCallBlock } from '@/store/chat-list'
defineProps<{
block: ToolCallBlock
}>()
const inputOpen = ref(false)
const resultOpen = ref(false)
function formatJson(val: unknown): string {
if (typeof val === 'string') return val
try {
return JSON.stringify(val, null, 2)
} catch {
return String(val)
}
}
</script>
+8 -63
View File
@@ -1,69 +1,14 @@
<template>
<section class="h-[calc(100vh-calc(var(--spacing)*20))] max-w-187 gap-8 w-full *:w-full m-auto flex flex-col">
<!-- 聊天区域 -->
<section class="flex-1 h-0 [&:has(p)]:block! [&:has(p)+section_.logo-title]:hidden [&:has(p)+section]:mt-0! hidden">
<ScrollArea class="max-h-full h-full w-full rounded-md p-4 **:focus-visible:ring-0!">
<ChatList />
</ScrollArea>
</section>
<div class="flex h-[calc(100vh-calc(var(--spacing)*20))]">
<!-- Left: Bot sidebar -->
<BotSidebar />
<!-- 输入区域 -->
<section class="flex-none relative m-auto">
<section class="mb-20 logo-title">
<h4
class="scroll-m-20 text-3xl font-semibold tracking-tight text-center"
style="font-family: 'Source Han Serif CN', 'Noto Serif SC', 'STSong', 'SimSun', serif;"
>
<TextGenerateEffect :words="$t('chat.greeting')" />
</h4>
</section>
<Textarea
v-model="curInputSay"
class="pb-16 pt-4"
:placeholder="$t('chat.inputPlaceholder')"
/>
<section class="absolute bottom-0 h-14 px-2 inset-x-0 flex items-center">
<Button
variant="default"
class="ml-auto"
@click="send"
>
<template v-if="!loading">
{{ $t('chat.send') }}
<FontAwesomeIcon :icon="['fas', 'paper-plane']" />
</template>
<LoadingDots v-else />
</Button>
</section>
</section>
</section>
<!-- Right: Chat area -->
<ChatArea />
</div>
</template>
<script setup lang="ts">
import {
ScrollArea,
Textarea,
Button,
} from '@memoh/ui'
import ChatList from '@/components/chat-list/index.vue'
import LoadingDots from '@/components/loading-dots/index.vue'
import { provide, ref } from 'vue'
import { useChatList } from '@/store/chat-list'
import { storeToRefs } from 'pinia'
const chatSay = ref('')
const curInputSay = ref('')
const { loading } = storeToRefs(useChatList())
provide('chatSay', chatSay)
function send() {
if (!loading.value) {
chatSay.value = curInputSay.value
curInputSay.value = ''
}
}
import BotSidebar from './components/bot-sidebar.vue'
import ChatArea from './components/chat-area.vue'
</script>
+298 -65
View File
@@ -1,82 +1,315 @@
import { defineStore } from 'pinia'
import { reactive, ref } from 'vue'
import type { user, robot } from '@memoh/shared'
import {
fetchBots,
createSession,
sendChatMessage,
createStreamConnection,
} from '@/composables/api/useChat'
import { createSession, streamChat, type StreamEvent } from '@/composables/api/useChat'
export const useChatList = defineStore('chatList', () => {
const chatList = reactive<(user | robot)[]>([])
const loading = ref(false)
const botId = ref<string | null>(null)
// ---- Message model ----
export interface TextBlock {
type: 'text'
content: string
}
export interface ThinkingBlock {
type: 'thinking'
content: string
done: boolean
}
export interface ToolCallBlock {
type: 'tool_call'
toolName: string
input: unknown
result: unknown | null
done: boolean
}
export type ContentBlock = TextBlock | ThinkingBlock | ToolCallBlock
export interface ChatMessage {
id: string
role: 'user' | 'assistant'
blocks: ContentBlock[]
timestamp: Date
streaming: boolean
}
// ---- Storage helpers ----
const STORAGE_PREFIX = 'chat:'
interface PersistedChat {
sessionId: string | null
messages: Array<Omit<ChatMessage, 'timestamp'> & { timestamp: string }>
}
function saveChat(botId: string, sid: string | null, msgs: ChatMessage[]) {
const key = STORAGE_PREFIX + botId
const data: PersistedChat = {
sessionId: sid,
messages: msgs.map(m => ({
...m,
streaming: false,
timestamp: m.timestamp.toISOString(),
// 深拷贝 blocks,避免序列化 reactive proxy 问题
blocks: JSON.parse(JSON.stringify(m.blocks)),
})),
}
try {
localStorage.setItem(key, JSON.stringify(data))
} catch {
// localStorage 已满,静默忽略
}
}
function loadChat(botId: string): { sessionId: string | null; messages: ChatMessage[] } | null {
const key = STORAGE_PREFIX + botId
const raw = localStorage.getItem(key)
if (!raw) return null
try {
const data: PersistedChat = JSON.parse(raw)
return {
sessionId: data.sessionId ?? null,
messages: data.messages.map(m => ({
...m,
timestamp: new Date(m.timestamp),
streaming: false,
})),
}
} catch {
localStorage.removeItem(key)
return null
}
}
function removeChat(botId: string) {
localStorage.removeItem(STORAGE_PREFIX + botId)
}
// ---- Store ----
export const useChatStore = defineStore('chat', () => {
const messages = reactive<ChatMessage[]>([])
const streaming = ref(false)
const currentBotId = ref<string | null>(null)
const sessionId = ref<string | null>(null)
const abortStream = ref<(() => void) | null>(null)
let abortFn: (() => void) | null = null
const nextId = () => `${Date.now()}-${Math.floor(Math.random() * 1000)}`
const addUserMessage = (text: string) => {
chatList.push({
description: text,
time: new Date(),
action: 'user',
id: nextId(),
})
/** 持久化当前会话到 localStorage */
function persist() {
if (!currentBotId.value) return
saveChat(currentBotId.value, sessionId.value, messages as ChatMessage[])
}
const addRobotMessage = (text: string) => {
chatList.push({
description: text,
time: new Date(),
action: 'robot',
id: nextId(),
type: 'Memoh Agent',
state: 'complete',
})
}
const ensureSession = async () => {
if (botId.value && sessionId.value) return
const bots = await fetchBots()
if (!bots.length) throw new Error('No bots found')
botId.value = botId.value ?? bots[0]!.id
sessionId.value = await createSession(botId.value)
if (botId.value && sessionId.value) {
// 关闭旧流
abortStream.value?.()
abortStream.value = createStreamConnection(
botId.value,
sessionId.value,
addRobotMessage,
)
}
}
const sendMessage = async (text: string) => {
const trimmed = text.trim()
if (!trimmed) return
loading.value = true
try {
addUserMessage(trimmed)
await ensureSession()
if (!botId.value || !sessionId.value) {
throw new Error('Session not ready')
// 切换 Bot
function selectBot(botId: string) {
if (currentBotId.value === botId) return
abort()
// 保存当前会话
persist()
currentBotId.value = botId
// 尝试从 localStorage 恢复
const cached = loadChat(botId)
messages.length = 0
if (cached) {
sessionId.value = cached.sessionId
for (const msg of cached.messages) {
messages.push(msg)
}
await sendChatMessage(botId.value, sessionId.value, trimmed)
} finally {
loading.value = false
} else {
sessionId.value = null
}
}
// 确保 session 存在
async function ensureSession() {
if (!currentBotId.value) throw new Error('No bot selected')
if (!sessionId.value) {
sessionId.value = await createSession(currentBotId.value)
}
}
// 中止当前流
function abort() {
abortFn?.()
abortFn = null
// 标记所有正在流式的消息为完成
for (const msg of messages) {
if (msg.streaming) msg.streaming = false
}
streaming.value = false
}
// 发送消息
async function sendMessage(text: string) {
const trimmed = text.trim()
if (!trimmed || streaming.value || !currentBotId.value) return
// 添加用户消息
messages.push({
id: nextId(),
role: 'user',
blocks: [{ type: 'text', content: trimmed }],
timestamp: new Date(),
streaming: false,
})
streaming.value = true
try {
await ensureSession()
// 创建助手消息占位
messages.push({
id: nextId(),
role: 'assistant',
blocks: [],
timestamp: new Date(),
streaming: true,
})
// 从 reactive 数组中获取 proxy 引用,确保后续修改触发响应式
const assistantMsg = messages[messages.length - 1]!
// 当前活跃 block 的索引(通过索引访问 reactive proxy,避免引用原始对象)
let textBlockIdx = -1
let thinkingBlockIdx = -1
function pushBlock(block: ContentBlock): number {
assistantMsg.blocks.push(block)
return assistantMsg.blocks.length - 1
}
abortFn = streamChat(
currentBotId.value!,
sessionId.value!,
trimmed,
// onEvent
(event: StreamEvent) => {
const type = event.type
switch (type) {
case 'text_start':
textBlockIdx = pushBlock({ type: 'text', content: '' })
break
case 'text_delta':
if (typeof event.delta === 'string') {
if (textBlockIdx < 0 || assistantMsg.blocks[textBlockIdx]?.type !== 'text') {
textBlockIdx = pushBlock({ type: 'text', content: '' })
}
;(assistantMsg.blocks[textBlockIdx] as TextBlock).content += event.delta
}
break
case 'text_end':
textBlockIdx = -1
break
case 'reasoning_start':
thinkingBlockIdx = pushBlock({ type: 'thinking', content: '', done: false })
break
case 'reasoning_delta':
if (typeof event.delta === 'string') {
if (thinkingBlockIdx < 0 || assistantMsg.blocks[thinkingBlockIdx]?.type !== 'thinking') {
thinkingBlockIdx = pushBlock({ type: 'thinking', content: '', done: false })
}
;(assistantMsg.blocks[thinkingBlockIdx] as ThinkingBlock).content += event.delta
}
break
case 'reasoning_end':
if (thinkingBlockIdx >= 0 && assistantMsg.blocks[thinkingBlockIdx]?.type === 'thinking') {
;(assistantMsg.blocks[thinkingBlockIdx] as ThinkingBlock).done = true
}
thinkingBlockIdx = -1
break
case 'tool_call_start': {
pushBlock({
type: 'tool_call',
toolName: (event.toolName as string) ?? 'unknown',
input: event.input ?? null,
result: null,
done: false,
})
textBlockIdx = -1 // tool call 中断文本流
break
}
case 'tool_call_end': {
// 从头部找第一个未完成的同名 tool_call block
for (let i = 0; i < assistantMsg.blocks.length; i++) {
const b = assistantMsg.blocks[i]
if (b && b.type === 'tool_call' && b.toolName === event.toolName && !b.done) {
b.result = event.result ?? null
b.done = true
break
}
}
break
}
case 'agent_start':
case 'agent_end':
break
default: {
// 兜底:尝试提取文本
const text = extractFallbackText(event)
if (text) {
if (textBlockIdx < 0 || assistantMsg.blocks[textBlockIdx]?.type !== 'text') {
textBlockIdx = pushBlock({ type: 'text', content: '' })
}
;(assistantMsg.blocks[textBlockIdx] as TextBlock).content += text
}
break
}
}
},
// onDone
() => {
assistantMsg.streaming = false
streaming.value = false
abortFn = null
persist()
},
// onError
() => {
assistantMsg.streaming = false
streaming.value = false
abortFn = null
persist()
},
)
} catch {
streaming.value = false
}
}
function clearMessages() {
abort()
if (currentBotId.value) removeChat(currentBotId.value)
messages.length = 0
sessionId.value = null
}
return {
chatList,
loading,
messages,
streaming,
currentBotId,
selectBot,
sendMessage,
clearMessages,
abort,
}
})
function extractFallbackText(event: StreamEvent): string | null {
if (typeof event.delta === 'string') return event.delta
if (typeof event.text === 'string') return event.text
if (typeof event.content === 'string') return event.content
return null
}