You've already forked agentic-coding-workflow
fix(web): code review pass — scroll watch, SSE same-origin, store concurrency, modal reset, admin validation
This commit is contained in:
+1
-11
@@ -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 = {}) =>
|
||||
|
||||
@@ -75,3 +75,15 @@ export async function request<T>(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 : ''
|
||||
}
|
||||
|
||||
+1
-11
@@ -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: () =>
|
||||
|
||||
@@ -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<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() }
|
||||
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<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 {
|
||||
|
||||
+14
-6
@@ -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,
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
<NButton
|
||||
type="primary"
|
||||
:loading="saving"
|
||||
:disabled="!canSave"
|
||||
@click="save"
|
||||
>
|
||||
保存
|
||||
@@ -66,7 +67,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { h, onMounted, ref } from 'vue'
|
||||
import { computed, h, onMounted, ref } from 'vue'
|
||||
import {
|
||||
NButton,
|
||||
NDataTable,
|
||||
@@ -105,6 +106,15 @@ const saving = ref(false)
|
||||
const msg = useMessage()
|
||||
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 }[] = [
|
||||
{ label: 'Anthropic', value: 'anthropic' },
|
||||
{ label: 'OpenAI / Compatible', value: 'openai' },
|
||||
@@ -196,6 +206,7 @@ async function save() {
|
||||
api_key: form.value.api_key,
|
||||
})
|
||||
}
|
||||
msg.success('已保存')
|
||||
showForm.value = false
|
||||
await reload()
|
||||
} catch (e) {
|
||||
|
||||
@@ -117,6 +117,7 @@
|
||||
<NButton
|
||||
type="primary"
|
||||
:loading="saving"
|
||||
:disabled="!canSave"
|
||||
@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) =>
|
||||
endpoints.value.find((e) => e.id === id)?.display_name ?? id
|
||||
|
||||
@@ -347,6 +359,7 @@ async function save() {
|
||||
}
|
||||
await llmAdminApi.createModel(body)
|
||||
}
|
||||
msg.success('已保存')
|
||||
showForm.value = false
|
||||
await reload()
|
||||
} catch (e) {
|
||||
|
||||
@@ -150,8 +150,23 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
// 路由 conversationId 切换:在 /chat ↔ /chat/:id 同组件复用时同步状态。
|
||||
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(() => {
|
||||
const el = scrollEl.value
|
||||
|
||||
@@ -116,6 +116,25 @@ watch(
|
||||
{ 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) {
|
||||
emit('update:show', v)
|
||||
}
|
||||
|
||||
@@ -57,6 +57,11 @@ async function upload(opts: UploadCustomRequestOptions) {
|
||||
opts.onError()
|
||||
return
|
||||
}
|
||||
if (f.type.startsWith('audio/') && !props.modelCapabilities?.audio) {
|
||||
msg.error('当前模型不支持音频')
|
||||
opts.onError()
|
||||
return
|
||||
}
|
||||
if (f.size > 20 * 1024 * 1024) {
|
||||
msg.error('单文件不能超过 20 MB')
|
||||
opts.onError()
|
||||
|
||||
@@ -161,6 +161,7 @@ async function save() {
|
||||
scope: form.value.scope,
|
||||
})
|
||||
}
|
||||
msg.success('已保存')
|
||||
showForm.value = false
|
||||
await reload()
|
||||
} catch (e) {
|
||||
|
||||
Reference in New Issue
Block a user