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 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 = {}) =>
|
||||||
|
|||||||
@@ -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
@@ -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: () =>
|
||||||
|
|||||||
@@ -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,23 +25,25 @@ 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(
|
||||||
const auth = useAuthStore()
|
|
||||||
const reading = ref(false)
|
|
||||||
|
|
||||||
function start(
|
|
||||||
streamUrl: string,
|
streamUrl: string,
|
||||||
since: number,
|
since: number,
|
||||||
h: ChatStreamHandlers,
|
h: ChatStreamHandlers,
|
||||||
): ChatStreamHandle {
|
): ChatStreamHandle {
|
||||||
|
const auth = useAuthStore()
|
||||||
const url = new URL(streamUrl, window.location.origin)
|
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))
|
url.searchParams.set('since', String(since))
|
||||||
|
|
||||||
const ctrl = new AbortController()
|
const ctrl = new AbortController()
|
||||||
reading.value = true
|
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
@@ -76,17 +77,12 @@ export function useChatStream() {
|
|||||||
const e = err as { name?: string }
|
const e = err as { name?: string }
|
||||||
if (e?.name === 'AbortError') return
|
if (e?.name === 'AbortError') return
|
||||||
h.onError?.(err instanceof Error ? err : new Error(String(err)))
|
h.onError?.(err instanceof Error ? err : new Error(String(err)))
|
||||||
} finally {
|
|
||||||
reading.value = false
|
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
return { stop: () => ctrl.abort() }
|
return { stop: () => ctrl.abort() }
|
||||||
}
|
}
|
||||||
|
|
||||||
return { start, reading }
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseSSE(raw: string): ChatStreamEvent | null {
|
function parseSSE(raw: string): ChatStreamEvent | null {
|
||||||
let id = 0
|
let id = 0
|
||||||
let event = ''
|
let event = ''
|
||||||
|
|||||||
+13
-5
@@ -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) {
|
||||||
|
closeCurrent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCurrent() {
|
||||||
|
detachStream()
|
||||||
currentConversation.value = null
|
currentConversation.value = null
|
||||||
messages.value = []
|
messages.value = []
|
||||||
detachStream()
|
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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user