mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
feat(web): chat
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -50,8 +50,15 @@
|
||||
},
|
||||
"chat": {
|
||||
"greeting": "你好!有什么我可以帮你的吗?",
|
||||
"inputPlaceholder": "输入你的问题…",
|
||||
"send": "发送"
|
||||
"inputPlaceholder": "输入你的问题… (Enter 发送)",
|
||||
"send": "发送",
|
||||
"selectBot": "选择一个 Bot 开始对话",
|
||||
"selectBotHint": "从左侧列表中选择",
|
||||
"thinking": "正在思考…",
|
||||
"thinkingInProgress": "正在思考…",
|
||||
"thinkingDone": "思考过程",
|
||||
"toolRunning": "执行中",
|
||||
"toolDone": "已完成"
|
||||
},
|
||||
"models": {
|
||||
"title": "模型",
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user