主流程

This commit is contained in:
2026-06-10 17:53:09 +08:00
parent c6f239f424
commit b20435c027
66 changed files with 2779 additions and 1181 deletions
@@ -0,0 +1,169 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import type { ChatStreamEvent } from './useChatStream'
const chatApiMock = vi.hoisted(() => ({
getConversation: vi.fn(),
listMessages: vi.fn(),
sendMessage: vi.fn(),
cancelMessage: vi.fn(),
retryMessage: vi.fn(),
}))
vi.mock('@/api/chat', () => ({ chatApi: chatApiMock }))
// startChatStream 的 mock:记录每次调用,暴露 emit 让测试驱动事件。
interface StreamInstance {
url: string
since: number
emit: (ev: ChatStreamEvent) => void
stopped: boolean
}
const streamInstances = vi.hoisted(() => [] as StreamInstance[])
vi.mock('@/composables/useChatStream', () => ({
startChatStream: vi.fn(
(
url: string,
since: number,
handlers: { onEvent: (ev: ChatStreamEvent) => void; onError: (e: Error) => void },
) => {
const inst: StreamInstance = {
url,
since,
emit: (ev) => handlers.onEvent(ev),
stopped: false,
}
streamInstances.push(inst)
return {
stop: () => {
inst.stopped = true
},
}
},
),
}))
import { useConversationSession } from './useConversationSession'
function conv(id: string) {
return { id, user_id: 'u1', model_id: 'm1', system_prompt: '', created_at: '', updated_at: '' }
}
function msg(id: number, status = 'ok', role = 'assistant') {
return {
id,
conversation_id: 'c1',
role,
content: `msg-${id}`,
status,
created_at: '',
updated_at: '',
}
}
beforeEach(() => {
streamInstances.length = 0
vi.clearAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('useConversationSession', () => {
it('openConversation 将 DESC 消息排为 id 升序', async () => {
chatApiMock.getConversation.mockResolvedValue({
conversation: conv('c1'),
messages: [msg(3), msg(2), msg(1)],
})
const s = useConversationSession()
await s.openConversation('c1')
expect(s.messages.value.map((m) => m.id)).toEqual([1, 2, 3])
expect(s.isStreaming.value).toBe(false)
})
it('openConversation 发现 pending 消息时续接流', async () => {
chatApiMock.getConversation.mockResolvedValue({
conversation: conv('c1'),
messages: [msg(2, 'pending'), msg(1)],
})
const s = useConversationSession()
await s.openConversation('c1')
expect(s.streamingMessageId.value).toBe(2)
expect(streamInstances).toHaveLength(1)
expect(streamInstances[0].since).toBe(1)
})
it('sendMessage 追加两条占位消息并累积 text_delta 至 done', async () => {
chatApiMock.getConversation.mockResolvedValue({ conversation: conv('c1'), messages: [] })
chatApiMock.sendMessage.mockResolvedValue({
user_message_id: 10,
assistant_message_id: 11,
stream_url: '/stream',
})
const s = useConversationSession()
await s.openConversation('c1')
await s.sendMessage('hello')
expect(s.messages.value.map((m) => [m.id, m.role, m.status])).toEqual([
[10, 'user', 'ok'],
[11, 'assistant', 'pending'],
])
expect(s.isStreaming.value).toBe(true)
const stream = streamInstances[0]
stream.emit({ id: 1, event: 'text_delta', data: { text: 'foo' } })
stream.emit({ id: 2, event: 'text_delta', data: { text: 'bar' } })
stream.emit({ id: 3, event: 'done', data: {} })
const assistant = s.messages.value[1]
expect(assistant.content).toBe('foobar')
expect(assistant.status).toBe('ok')
expect(s.isStreaming.value).toBe(false)
})
it('error 事件标记消息失败并结束流式', async () => {
chatApiMock.getConversation.mockResolvedValue({ conversation: conv('c1'), messages: [] })
chatApiMock.sendMessage.mockResolvedValue({
user_message_id: 1,
assistant_message_id: 2,
stream_url: '/stream',
})
const s = useConversationSession()
await s.openConversation('c1')
await s.sendMessage('x')
streamInstances[0].emit({ id: 1, event: 'error', data: { message: 'boom' } })
expect(s.messages.value[1].status).toBe('error')
expect(s.messages.value[1].error_message).toBe('boom')
expect(s.isStreaming.value).toBe(false)
})
it('closeCurrent 终止流并清空状态', async () => {
chatApiMock.getConversation.mockResolvedValue({
conversation: conv('c1'),
messages: [msg(2, 'pending')],
})
const s = useConversationSession()
await s.openConversation('c1')
expect(streamInstances[0].stopped).toBe(false)
s.closeCurrent()
expect(streamInstances[0].stopped).toBe(true)
expect(s.currentConversation.value).toBeNull()
expect(s.messages.value).toEqual([])
})
it('双实例互不干扰', async () => {
chatApiMock.getConversation
.mockResolvedValueOnce({ conversation: conv('c1'), messages: [msg(1)] })
.mockResolvedValueOnce({ conversation: conv('c2'), messages: [msg(5), msg(4)] })
const a = useConversationSession()
const b = useConversationSession()
await a.openConversation('c1')
await b.openConversation('c2')
expect(a.messages.value.map((m) => m.id)).toEqual([1])
expect(b.messages.value.map((m) => m.id)).toEqual([4, 5])
b.closeCurrent()
expect(a.currentConversation.value?.id).toBe('c1')
})
})
@@ -0,0 +1,223 @@
import { computed, ref } from 'vue'
import { chatApi, type ChatMessage, type Conversation } from '@/api/chat'
import {
startChatStream,
type ChatStreamEvent,
type ChatStreamHandle,
} from '@/composables/useChatStream'
interface UsagePayload {
PromptTokens?: number
CompletionTokens?: number
ThinkingTokens?: number
}
interface TextDeltaPayload {
text?: string
}
interface ErrorPayload {
code?: string
message?: string
}
interface MetaPayload {
id?: number
role?: string
status?: ChatMessage['status']
}
// useConversationSession 承载"单个会话的消息流"全部状态与操作(打开/分页/发送/
// 取消/重试/SSE 流式接收)。每次调用返回一个独立实例,互不串扰——全局 /chat 页
// 经 chat store 持有一份,需求详情页内嵌面板各自持有一份。
// 调用方负责在组件卸载时调用 closeCurrent() 终止流。
export function useConversationSession() {
const currentConversation = ref<Conversation | null>(null)
const messages = ref<ChatMessage[]>([])
const streamingMessageId = ref<number | null>(null)
const streamHandle = ref<ChatStreamHandle | null>(null)
const isStreaming = computed(() => streamingMessageId.value !== null)
async function openConversation(id: string) {
detachStream()
const detail = await chatApi.getConversation(id)
currentConversation.value = detail.conversation
// 后端按 id DESC 返回(最新在前);视图按升序渲染(最新在底部),故排为升序。
messages.value = [...detail.messages].sort((a, b) => a.id - b.id)
const pending = detail.messages.find((m) => m.status === 'pending')
if (pending) {
streamingMessageId.value = pending.id
attachStream(`/api/v1/conversations/${id}/stream`, pending.id - 1)
}
}
async function loadOlderMessages() {
const conv = currentConversation.value
if (!conv) return
if (messages.value.length === 0) return
const beforeId = messages.value.reduce(
(min, m) => (m.id < min ? m.id : min),
messages.value[0].id,
)
const older = await chatApi.listMessages(conv.id, { before_id: beforeId })
if (older.length === 0) return
const existingIds = new Set(messages.value.map((m) => m.id))
const fresh = older.filter((m) => !existingIds.has(m.id))
if (fresh.length === 0) return
// 合并后统一按 id 升序,避免后端 DESC 批次与现有升序列表错位。
messages.value = [...fresh, ...messages.value].sort((a, b) => a.id - b.id)
}
async function sendMessage(content: string, attachmentIds: string[] = []) {
if (!currentConversation.value) return
if (streamingMessageId.value !== null) return
const conv = currentConversation.value
const resp = await chatApi.sendMessage(conv.id, {
content,
attachment_ids: attachmentIds,
})
const now = new Date().toISOString()
messages.value.push({
id: resp.user_message_id,
conversation_id: conv.id,
role: 'user',
content,
status: 'ok',
created_at: now,
updated_at: now,
})
messages.value.push({
id: resp.assistant_message_id,
conversation_id: conv.id,
role: 'assistant',
content: '',
status: 'pending',
created_at: now,
updated_at: now,
})
streamingMessageId.value = resp.assistant_message_id
attachStream(resp.stream_url, resp.user_message_id)
}
async function cancelMessage() {
const id = streamingMessageId.value
if (id == null) return
await chatApi.cancelMessage(id)
detachStream()
}
async function retryMessage(messageId: number) {
if (!currentConversation.value) return
const resp = await chatApi.retryMessage(messageId)
const idx = messages.value.findIndex((m) => m.id === messageId)
if (idx >= 0) {
const old = messages.value[idx]
messages.value[idx] = {
...old,
id: resp.assistant_message_id,
content: '',
thinking: null,
status: 'pending',
error_message: null,
}
}
streamingMessageId.value = resp.assistant_message_id
attachStream(resp.stream_url, resp.assistant_message_id - 1)
}
function closeCurrent() {
detachStream()
currentConversation.value = null
messages.value = []
streamingMessageId.value = null
}
function attachStream(streamUrl: string, sinceMessageID: number) {
detachStream()
streamHandle.value = startChatStream(streamUrl, sinceMessageID, {
onEvent: (ev) => applyStreamEvent(ev),
onClose: () => {
streamHandle.value = null
},
onError: (err) => {
console.error('chat stream error', err)
const id = streamingMessageId.value
if (id != null) {
const msg = messages.value.find((m) => m.id === id)
if (msg && msg.status === 'pending') {
msg.status = 'error'
msg.error_message = err.message
}
}
streamingMessageId.value = null
streamHandle.value = null
},
})
}
function detachStream() {
streamHandle.value?.stop()
streamHandle.value = null
}
function applyStreamEvent(ev: ChatStreamEvent) {
const targetId = streamingMessageId.value
if (targetId == null) return
const msg = messages.value.find((m) => m.id === targetId)
if (!msg) return
switch (ev.event) {
case 'message_meta': {
const d = ev.data as MetaPayload
if (d.status) msg.status = d.status
break
}
case 'text_delta': {
const d = ev.data as TextDeltaPayload
if (d.text) msg.content += d.text
break
}
case 'thinking_delta': {
const d = ev.data as TextDeltaPayload
if (d.text) msg.thinking = (msg.thinking ?? '') + d.text
break
}
case 'usage': {
const u = ev.data as UsagePayload
msg.prompt_tokens = u.PromptTokens ?? msg.prompt_tokens
msg.completion_tokens = u.CompletionTokens ?? msg.completion_tokens
msg.thinking_tokens = u.ThinkingTokens ?? msg.thinking_tokens
break
}
case 'done':
// 后端事件顺序:EventUsage → EventDone(参见 internal/chat/streamer.go)。
// tokens 已在 'usage' 分支写入;某些 provider 不发 usage 时此处不会兜底。
msg.status = 'ok'
streamingMessageId.value = null
break
case 'error': {
const d = ev.data as ErrorPayload
msg.status = 'error'
msg.error_message = d.message ?? d.code ?? 'stream error'
streamingMessageId.value = null
break
}
}
}
return {
currentConversation,
messages,
streamingMessageId,
isStreaming,
openConversation,
loadOlderMessages,
sendMessage,
cancelMessage,
retryMessage,
closeCurrent,
}
}
export type ConversationSession = ReturnType<typeof useConversationSession>