From b8d29d8ea7e2e9d595bdccd080cf282a2d5a827f Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Mon, 4 May 2026 20:58:48 +0800 Subject: [PATCH] =?UTF-8?q?fix(web):=20code=20review=20pass=20=E2=80=94=20?= =?UTF-8?q?scroll=20watch,=20SSE=20same-origin,=20store=20concurrency,=20m?= =?UTF-8?q?odal=20reset,=20admin=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/api/chat.ts | 12 +- web/src/api/client.ts | 12 ++ web/src/api/llmAdmin.ts | 12 +- web/src/composables/useChatStream.ts | 106 +++++++++--------- web/src/stores/chat.ts | 20 +++- web/src/views/admin/LLMEndpointsView.vue | 13 ++- web/src/views/admin/LLMModelsView.vue | 13 +++ web/src/views/chat/ChatDetailView.vue | 17 ++- .../views/chat/ConversationCreateModal.vue | 19 ++++ .../chat/components/AttachmentUploader.vue | 5 + .../prompt-templates/PromptTemplatesView.vue | 1 + 11 files changed, 145 insertions(+), 85 deletions(-) diff --git a/web/src/api/chat.ts b/web/src/api/chat.ts index af9d108..a51a4f5 100644 --- a/web/src/api/chat.ts +++ b/web/src/api/chat.ts @@ -1,4 +1,4 @@ -import { request } from './client' +import { buildQuery, request } from './client' export type MessageRole = 'user' | 'assistant' export type MessageStatus = 'pending' | 'ok' | 'error' | 'cancelled' @@ -95,16 +95,6 @@ export interface UsageDailyItem { const enc = (v: string) => encodeURIComponent(v) -function buildQuery(params: object): string { - const q = new URLSearchParams() - for (const [k, v] of Object.entries(params)) { - if (v === undefined || v === null || v === '') continue - q.set(k, String(v)) - } - const s = q.toString() - return s ? '?' + s : '' -} - export const chatApi = { // ===== Conversations ===== listConversations: (params: ListConversationsParams = {}) => diff --git a/web/src/api/client.ts b/web/src/api/client.ts index bceee3f..3e3b2dd 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -75,3 +75,15 @@ export async function request(path: string, opts: RequestOptions = {}): Promi clearTimeout(timer) } } + +// buildQuery 把任意对象拼成 "?k=v&..." 形式;undefined / null / '' 跳过; +// 数组与对象用 String() 退化(业务侧目前没有数组 query 需求)。 +export function buildQuery(params: object): string { + const q = new URLSearchParams() + for (const [k, v] of Object.entries(params)) { + if (v === undefined || v === null || v === '') continue + q.set(k, String(v)) + } + const s = q.toString() + return s ? '?' + s : '' +} diff --git a/web/src/api/llmAdmin.ts b/web/src/api/llmAdmin.ts index 56ffe15..1851851 100644 --- a/web/src/api/llmAdmin.ts +++ b/web/src/api/llmAdmin.ts @@ -1,4 +1,4 @@ -import { request } from './client' +import { buildQuery, request } from './client' export type LLMProvider = 'anthropic' | 'openai' | 'gemini' @@ -79,16 +79,6 @@ export interface AdminUsageItem { const enc = (v: string) => encodeURIComponent(v) -function buildQuery(params: object): string { - const q = new URLSearchParams() - for (const [k, v] of Object.entries(params)) { - if (v === undefined || v === null || v === '') continue - q.set(k, String(v)) - } - const s = q.toString() - return s ? '?' + s : '' -} - export const llmAdminApi = { // ===== Endpoints ===== listEndpoints: () => diff --git a/web/src/composables/useChatStream.ts b/web/src/composables/useChatStream.ts index cf579ba..6215bfc 100644 --- a/web/src/composables/useChatStream.ts +++ b/web/src/composables/useChatStream.ts @@ -1,4 +1,3 @@ -import { ref } from 'vue' import { useAuthStore } from '@/stores/auth' export type ChatStreamEventType = @@ -26,65 +25,62 @@ export interface ChatStreamHandle { } /** - * useChatStream 通过 fetch + ReadableStream 自实现 SSE 解析, + * startChatStream 通过 fetch + ReadableStream 自实现 SSE 解析, * 以支持 Authorization Bearer 头(EventSource 不支持自定义 header)。 - * start() 立即返回 stop 句柄,读流在后台 fire-and-forget。 + * 同步返回 stop 句柄,读流在后台 fire-and-forget。 + * + * 仅接受同源 streamUrl —— 防止上游配置错误把 Bearer token 发到外域。 */ -export function useChatStream() { +export function startChatStream( + streamUrl: string, + since: number, + h: ChatStreamHandlers, +): ChatStreamHandle { const auth = useAuthStore() - const reading = ref(false) - - function start( - streamUrl: string, - since: number, - h: ChatStreamHandlers, - ): ChatStreamHandle { - const url = new URL(streamUrl, window.location.origin) - url.searchParams.set('since', String(since)) - const ctrl = new AbortController() - reading.value = true - - void (async () => { - try { - const headers: Record = { Accept: 'text/event-stream' } - if (auth.token) headers['Authorization'] = `Bearer ${auth.token}` - const resp = await fetch(url.toString(), { - headers, - signal: ctrl.signal, - }) - if (!resp.ok || !resp.body) { - throw new Error(`SSE HTTP ${resp.status}`) - } - const reader = resp.body.getReader() - const decoder = new TextDecoder() - let buf = '' - for (;;) { - const { value, done } = await reader.read() - if (done) break - buf += decoder.decode(value, { stream: true }) - let idx = buf.indexOf('\n\n') - while (idx >= 0) { - const raw = buf.slice(0, idx) - buf = buf.slice(idx + 2) - const ev = parseSSE(raw) - if (ev) h.onEvent(ev) - idx = buf.indexOf('\n\n') - } - } - h.onClose?.() - } catch (err) { - const e = err as { name?: string } - if (e?.name === 'AbortError') return - h.onError?.(err instanceof Error ? err : new Error(String(err))) - } finally { - reading.value = false - } - })() - - return { stop: () => ctrl.abort() } + const url = new URL(streamUrl, window.location.origin) + if (url.origin !== window.location.origin) { + throw new Error('refusing cross-origin SSE') } + url.searchParams.set('since', String(since)) - return { start, reading } + const ctrl = new AbortController() + + void (async () => { + try { + const headers: Record = { Accept: 'text/event-stream' } + if (auth.token) headers['Authorization'] = `Bearer ${auth.token}` + const resp = await fetch(url.toString(), { + headers, + signal: ctrl.signal, + }) + if (!resp.ok || !resp.body) { + throw new Error(`SSE HTTP ${resp.status}`) + } + const reader = resp.body.getReader() + const decoder = new TextDecoder() + let buf = '' + for (;;) { + const { value, done } = await reader.read() + if (done) break + buf += decoder.decode(value, { stream: true }) + let idx = buf.indexOf('\n\n') + while (idx >= 0) { + const raw = buf.slice(0, idx) + buf = buf.slice(idx + 2) + const ev = parseSSE(raw) + if (ev) h.onEvent(ev) + idx = buf.indexOf('\n\n') + } + } + h.onClose?.() + } catch (err) { + const e = err as { name?: string } + if (e?.name === 'AbortError') return + h.onError?.(err instanceof Error ? err : new Error(String(err))) + } + })() + + return { stop: () => ctrl.abort() } } function parseSSE(raw: string): ChatStreamEvent | null { diff --git a/web/src/stores/chat.ts b/web/src/stores/chat.ts index 4311873..eb469b1 100644 --- a/web/src/stores/chat.ts +++ b/web/src/stores/chat.ts @@ -8,7 +8,7 @@ import { type ListConversationsParams, } from '@/api/chat' import { - useChatStream, + startChatStream, type ChatStreamEvent, type ChatStreamHandle, } from '@/composables/useChatStream' @@ -61,6 +61,7 @@ export const useChatStore = defineStore('chat', () => { 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, @@ -119,16 +120,20 @@ export const useChatStore = defineStore('chat', () => { await chatApi.deleteConversation(id) conversations.value = conversations.value.filter((c) => c.id !== id) if (currentConversation.value?.id === id) { - currentConversation.value = null - messages.value = [] - detachStream() + closeCurrent() } } + function closeCurrent() { + detachStream() + currentConversation.value = null + messages.value = [] + streamingMessageId.value = null + } + function attachStream(streamUrl: string, sinceMessageID: number) { detachStream() - const stream = useChatStream() - streamHandle.value = stream.start(streamUrl, sinceMessageID, { + streamHandle.value = startChatStream(streamUrl, sinceMessageID, { onEvent: (ev) => applyStreamEvent(ev), onClose: () => { streamHandle.value = null @@ -183,6 +188,8 @@ export const useChatStore = defineStore('chat', () => { break } case 'done': + // 后端事件顺序:EventUsage → EventDone(参见 internal/chat/streamer.go)。 + // tokens 已在 'usage' 分支写入;某些 provider 不发 usage 时此处不会兜底。 msg.status = 'ok' streamingMessageId.value = null break @@ -204,6 +211,7 @@ export const useChatStore = defineStore('chat', () => { isStreaming, loadConversations, openConversation, + closeCurrent, sendMessage, cancelMessage, retryMessage, diff --git a/web/src/views/admin/LLMEndpointsView.vue b/web/src/views/admin/LLMEndpointsView.vue index 761c021..3ae5c83 100644 --- a/web/src/views/admin/LLMEndpointsView.vue +++ b/web/src/views/admin/LLMEndpointsView.vue @@ -54,6 +54,7 @@ 保存 @@ -66,7 +67,7 @@