fix(web): code review pass — scroll watch, SSE same-origin, store concurrency, modal reset, admin validation

This commit is contained in:
2026-05-04 20:58:48 +08:00
parent ff283013da
commit b8d29d8ea7
11 changed files with 145 additions and 85 deletions
+1 -11
View File
@@ -1,4 +1,4 @@
import { request } from './client' import { buildQuery, request } from './client'
export type MessageRole = 'user' | 'assistant' export type MessageRole = 'user' | 'assistant'
export type MessageStatus = 'pending' | 'ok' | 'error' | 'cancelled' export type MessageStatus = 'pending' | 'ok' | 'error' | 'cancelled'
@@ -95,16 +95,6 @@ export interface UsageDailyItem {
const enc = (v: string) => encodeURIComponent(v) 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 = { export const chatApi = {
// ===== Conversations ===== // ===== Conversations =====
listConversations: (params: ListConversationsParams = {}) => listConversations: (params: ListConversationsParams = {}) =>
+12
View File
@@ -75,3 +75,15 @@ export async function request<T>(path: string, opts: RequestOptions = {}): Promi
clearTimeout(timer) 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 : ''
}
+1 -11
View File
@@ -1,4 +1,4 @@
import { request } from './client' import { buildQuery, request } from './client'
export type LLMProvider = 'anthropic' | 'openai' | 'gemini' export type LLMProvider = 'anthropic' | 'openai' | 'gemini'
@@ -79,16 +79,6 @@ export interface AdminUsageItem {
const enc = (v: string) => encodeURIComponent(v) 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 = { export const llmAdminApi = {
// ===== Endpoints ===== // ===== Endpoints =====
listEndpoints: () => listEndpoints: () =>
+51 -55
View File
@@ -1,4 +1,3 @@
import { ref } from 'vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
export type ChatStreamEventType = export type ChatStreamEventType =
@@ -26,65 +25,62 @@ export interface ChatStreamHandle {
} }
/** /**
* useChatStream 通过 fetch + ReadableStream 自实现 SSE 解析, * startChatStream 通过 fetch + ReadableStream 自实现 SSE 解析,
* 以支持 Authorization Bearer 头(EventSource 不支持自定义 header)。 * 以支持 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 auth = useAuthStore()
const reading = ref(false) const url = new URL(streamUrl, window.location.origin)
if (url.origin !== window.location.origin) {
function start( throw new Error('refusing cross-origin SSE')
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<string, string> = { 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() }
} }
url.searchParams.set('since', String(since))
return { start, reading } const ctrl = new AbortController()
void (async () => {
try {
const headers: Record<string, string> = { 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 { function parseSSE(raw: string): ChatStreamEvent | null {
+14 -6
View File
@@ -8,7 +8,7 @@ import {
type ListConversationsParams, type ListConversationsParams,
} from '@/api/chat' } from '@/api/chat'
import { import {
useChatStream, startChatStream,
type ChatStreamEvent, type ChatStreamEvent,
type ChatStreamHandle, type ChatStreamHandle,
} from '@/composables/useChatStream' } from '@/composables/useChatStream'
@@ -61,6 +61,7 @@ export const useChatStore = defineStore('chat', () => {
async function sendMessage(content: string, attachmentIds: string[] = []) { async function sendMessage(content: string, attachmentIds: string[] = []) {
if (!currentConversation.value) return if (!currentConversation.value) return
if (streamingMessageId.value !== null) return
const conv = currentConversation.value const conv = currentConversation.value
const resp = await chatApi.sendMessage(conv.id, { const resp = await chatApi.sendMessage(conv.id, {
content, content,
@@ -119,16 +120,20 @@ export const useChatStore = defineStore('chat', () => {
await chatApi.deleteConversation(id) await chatApi.deleteConversation(id)
conversations.value = conversations.value.filter((c) => c.id !== id) conversations.value = conversations.value.filter((c) => c.id !== id)
if (currentConversation.value?.id === id) { if (currentConversation.value?.id === id) {
currentConversation.value = null closeCurrent()
messages.value = []
detachStream()
} }
} }
function closeCurrent() {
detachStream()
currentConversation.value = null
messages.value = []
streamingMessageId.value = null
}
function attachStream(streamUrl: string, sinceMessageID: number) { function attachStream(streamUrl: string, sinceMessageID: number) {
detachStream() detachStream()
const stream = useChatStream() streamHandle.value = startChatStream(streamUrl, sinceMessageID, {
streamHandle.value = stream.start(streamUrl, sinceMessageID, {
onEvent: (ev) => applyStreamEvent(ev), onEvent: (ev) => applyStreamEvent(ev),
onClose: () => { onClose: () => {
streamHandle.value = null streamHandle.value = null
@@ -183,6 +188,8 @@ export const useChatStore = defineStore('chat', () => {
break break
} }
case 'done': case 'done':
// 后端事件顺序:EventUsage → EventDone(参见 internal/chat/streamer.go)。
// tokens 已在 'usage' 分支写入;某些 provider 不发 usage 时此处不会兜底。
msg.status = 'ok' msg.status = 'ok'
streamingMessageId.value = null streamingMessageId.value = null
break break
@@ -204,6 +211,7 @@ export const useChatStore = defineStore('chat', () => {
isStreaming, isStreaming,
loadConversations, loadConversations,
openConversation, openConversation,
closeCurrent,
sendMessage, sendMessage,
cancelMessage, cancelMessage,
retryMessage, retryMessage,
+12 -1
View File
@@ -54,6 +54,7 @@
<NButton <NButton
type="primary" type="primary"
:loading="saving" :loading="saving"
:disabled="!canSave"
@click="save" @click="save"
> >
保存 保存
@@ -66,7 +67,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { h, onMounted, ref } from 'vue' import { computed, h, onMounted, ref } from 'vue'
import { import {
NButton, NButton,
NDataTable, NDataTable,
@@ -105,6 +106,15 @@ const saving = ref(false)
const msg = useMessage() const msg = useMessage()
const dlg = useDialog() const dlg = useDialog()
const canSave = computed(() => {
const f = form.value
return (
!!f.display_name.trim() &&
!!f.base_url.trim() &&
(!!editing.value || !!f.api_key.trim())
)
})
const providerOpts: { label: string; value: LLMProvider }[] = [ const providerOpts: { label: string; value: LLMProvider }[] = [
{ label: 'Anthropic', value: 'anthropic' }, { label: 'Anthropic', value: 'anthropic' },
{ label: 'OpenAI / Compatible', value: 'openai' }, { label: 'OpenAI / Compatible', value: 'openai' },
@@ -196,6 +206,7 @@ async function save() {
api_key: form.value.api_key, api_key: form.value.api_key,
}) })
} }
msg.success('已保存')
showForm.value = false showForm.value = false
await reload() await reload()
} catch (e) { } catch (e) {
+13
View File
@@ -117,6 +117,7 @@
<NButton <NButton
type="primary" type="primary"
:loading="saving" :loading="saving"
:disabled="!canSave"
@click="save" @click="save"
> >
保存 保存
@@ -210,6 +211,17 @@ const endpointOpts = computed(() =>
})), })),
) )
const canSave = computed(() => {
const f = form.value
return (
!!f.endpoint_id &&
!!f.model_id.trim() &&
!!f.display_name.trim() &&
f.context_window > 0 &&
f.max_output_tokens > 0
)
})
const endpointName = (id: string) => const endpointName = (id: string) =>
endpoints.value.find((e) => e.id === id)?.display_name ?? id endpoints.value.find((e) => e.id === id)?.display_name ?? id
@@ -347,6 +359,7 @@ async function save() {
} }
await llmAdminApi.createModel(body) await llmAdminApi.createModel(body)
} }
msg.success('已保存')
showForm.value = false showForm.value = false
await reload() await reload()
} catch (e) { } catch (e) {
+16 -1
View File
@@ -150,8 +150,23 @@ onMounted(async () => {
} }
}) })
// 路由 conversationId 切换:在 /chat ↔ /chat/:id 同组件复用时同步状态。
watch( watch(
() => chat.messages.length, () => route.params.conversationId,
async (cid) => {
if (typeof cid === 'string' && cid !== '') {
if (chat.currentConversation?.id !== cid) {
await chat.openConversation(cid)
}
} else {
chat.closeCurrent()
}
},
)
// 流式输出时 messages.length 不变,只 content 增长 —— 同时观察末消息内容长度。
watch(
() => [chat.messages.length, chat.messages.at(-1)?.content?.length ?? 0],
() => { () => {
nextTick(() => { nextTick(() => {
const el = scrollEl.value const el = scrollEl.value
@@ -116,6 +116,25 @@ watch(
{ immediate: true }, { immediate: true },
) )
// 每次从外部重新打开时把表单还原到初始状态。
watch(
() => props.show,
(open) => {
if (open) reset()
},
)
function reset() {
form.value = { model_id: '', template_id: null, system_prompt: '' }
if (props.projectId) {
mountKind.value = 'project'
mountID.value = props.projectId
} else {
mountKind.value = null
mountID.value = ''
}
}
function update(v: boolean) { function update(v: boolean) {
emit('update:show', v) emit('update:show', v)
} }
@@ -57,6 +57,11 @@ async function upload(opts: UploadCustomRequestOptions) {
opts.onError() opts.onError()
return return
} }
if (f.type.startsWith('audio/') && !props.modelCapabilities?.audio) {
msg.error('当前模型不支持音频')
opts.onError()
return
}
if (f.size > 20 * 1024 * 1024) { if (f.size > 20 * 1024 * 1024) {
msg.error('单文件不能超过 20 MB') msg.error('单文件不能超过 20 MB')
opts.onError() opts.onError()
@@ -161,6 +161,7 @@ async function save() {
scope: form.value.scope, scope: form.value.scope,
}) })
} }
msg.success('已保存')
showForm.value = false showForm.value = false
await reload() await reload()
} catch (e) { } catch (e) {