From 300c3f136f55368f4073552a54d94653fe729708 Mon Sep 17 00:00:00 2001 From: npmrun <1549469775@qq.com> Date: Wed, 5 Aug 2026 09:42:18 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=8E=A8=E7=90=86?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E6=94=AF=E6=8C=81=EF=BC=8C=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E8=81=8A=E5=A4=A9=E7=BB=84=E4=BB=B6=EF=BC=8C=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E4=BE=9D=E8=B5=96=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 1 + app/composables/useLlmChat.ts | 33 +++++++++++-- app/pages/settings/llm-test/index.vue | 4 +- bun.lock | 5 ++ package.json | 1 + .../components/ChatBubble/src/ChatBubble.vue | 24 ++++++++-- packages/bolt-ui/theme-chalk/src/chat-bubble.scss | 28 +++++++++++ packages/drizzle-pkg/db.sqlite | Bin 282624 -> 282624 bytes server/api/llm/chat/index.post.ts | 51 ++++++++++++++++----- 9 files changed, 127 insertions(+), 20 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9160f34..4755846 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,7 @@ ## 必须遵循 +- 注意代码的解耦,做到高内聚,低耦合 - 安装依赖时,必须写死依赖的版本,禁止任何升级的可能,如存在需要升级的场景,必须手动执行安装新版本命令 - 新建的mono包必须在根目录安装 - 使用nuxt时遇到不清楚的,必须先加载nuxt-remote查询文档再进行分析 diff --git a/app/composables/useLlmChat.ts b/app/composables/useLlmChat.ts index 5960ffa..4e369da 100644 --- a/app/composables/useLlmChat.ts +++ b/app/composables/useLlmChat.ts @@ -5,6 +5,8 @@ export interface LlmChatMessage { role: 'user' | 'assistant' content: string reasoning?: string + reasoningLoading?: boolean + reasoningDuration?: number } export interface UseLlmChatOptions { @@ -42,13 +44,22 @@ export function useLlmChat(options: UseLlmChatOptions) { const userMsg: LlmChatMessage = { id: generateId(), role: 'user', content: trimmed } messages.value.push(userMsg) - const assistantMsg: LlmChatMessage = { id: generateId(), role: 'assistant', content: '' } + const assistantMsg: LlmChatMessage = { + id: generateId(), + role: 'assistant', + content: '', + reasoning: '', + reasoningLoading: false, + reasoningDuration: 0, + } messages.value.push(assistantMsg) const assistantIdx = messages.value.length - 1 isLoading.value = true abortController = new AbortController() + let reasoningStartTime: number | null = null + try { const res = await fetch(apiEndpoint, { method: 'POST', @@ -79,16 +90,32 @@ export function useLlmChat(options: UseLlmChatOptions) { stream: res.body, onReasoningPart: (text) => { const msg = messages.value[assistantIdx] - if (msg) msg.reasoning = (msg.reasoning ?? '') + text + if (!msg) return + if (reasoningStartTime === null) { + reasoningStartTime = Date.now() + msg.reasoningLoading = true + } + msg.reasoning = (msg.reasoning ?? '') + text }, onTextPart: (text) => { const msg = messages.value[assistantIdx] - if (msg) msg.content += text + if (!msg) return + if (reasoningStartTime !== null && msg.reasoningLoading) { + msg.reasoningDuration = Date.now() - reasoningStartTime + msg.reasoningLoading = false + } + msg.content += text }, onErrorPart: (error) => { errorMessage.value = error || '流式响应出错' }, }) + + const msg = messages.value[assistantIdx] + if (msg && reasoningStartTime !== null && msg.reasoningLoading) { + msg.reasoningDuration = Date.now() - reasoningStartTime + msg.reasoningLoading = false + } } catch (err: any) { if (err.name === 'AbortError') { // user stopped diff --git a/app/pages/settings/llm-test/index.vue b/app/pages/settings/llm-test/index.vue index d80d928..03af481 100644 --- a/app/pages/settings/llm-test/index.vue +++ b/app/pages/settings/llm-test/index.vue @@ -138,7 +138,7 @@ function modelTypeClass(type: string) { -

启用后,支持推理的模型会展示思考过程

+

启用后,支持推理的模型会展示思考过程(仅对支持推理的模型有效,如 DeepSeek-R1、o1 等)

@@ -198,6 +198,8 @@ function modelTypeClass(type: string) { :role="msg.role" :content="msg.content" :reasoning="msg.reasoning" + :reasoning-loading="msg.reasoningLoading" + :reasoning-duration="msg.reasoningDuration" :loading="msg.role === 'assistant' && isLoading && !msg.content && !msg.reasoning" />
diff --git a/bun.lock b/bun.lock index cd5b52c..07089e1 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "person-panel", "dependencies": { "@ai-sdk/openai": "1.3.16", + "@ai-sdk/openai-compatible": "0.2.16", "@bytemd/plugin-gfm": "1.22.0", "@bytemd/vue-next": "1.22.0", "@libsql/client": "0.17.3", @@ -92,6 +93,8 @@ "packages": { "@ai-sdk/openai": ["@ai-sdk/openai@1.3.16", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.7" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-pjtiBKt1GgaSKZryTbM3tqgoegJwgAUlp1+X5uN6T+VPnI4FLSymV65tyloWzDlyqZmi9HXnnSRPu76VoL5D5g=="], + "@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@0.2.16", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-LkvfcM8slJedRyJa/MiMiaOzcMjV1zNDwzTHEGz7aAsgsQV0maLfmJRi/nuSwf5jmp0EouC+JXXDUj2l94HgQw=="], + "@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="], "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.7", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-kM0xS3GWg3aMChh9zfeM+80vEZfXzR3JEUBdycZLtbRZ2TRT8xOj3WodGHPb06sUK5yD7pAXC/P7ctsi2fvUGQ=="], @@ -2050,6 +2053,8 @@ "zwitch": ["zwitch@2.0.4", "https://registry.npmmirror.com/zwitch/-/zwitch-2.0.4.tgz", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="], + "@ai-sdk/react/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="], "@ai-sdk/ui-utils/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="], diff --git a/package.json b/package.json index 68f30f5..69536d7 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ }, "dependencies": { "@ai-sdk/openai": "1.3.16", + "@ai-sdk/openai-compatible": "0.2.16", "@bytemd/plugin-gfm": "1.22.0", "@bytemd/vue-next": "1.22.0", "@libsql/client": "0.17.3", diff --git a/packages/bolt-ui/components/ChatBubble/src/ChatBubble.vue b/packages/bolt-ui/components/ChatBubble/src/ChatBubble.vue index 2fc0190..eecba30 100644 --- a/packages/bolt-ui/components/ChatBubble/src/ChatBubble.vue +++ b/packages/bolt-ui/components/ChatBubble/src/ChatBubble.vue @@ -8,17 +8,22 @@
{{ role === 'user' ? '你' : '助手' }}
-
+
- - 思考过程 + + + {{ reasoningLoading ? '思考中' : '思考过程' }} + {{ formatDuration(reasoningDuration) }}
-
+
+ + +
- + @@ -37,11 +42,15 @@ const props = withDefaults( content?: string reasoning?: string loading?: boolean + reasoningLoading?: boolean + reasoningDuration?: number }>(), { content: '', reasoning: '', loading: false, + reasoningLoading: false, + reasoningDuration: 0, }, ) @@ -50,6 +59,11 @@ defineOptions({ name: 'BoChatBubble' }) const reasoningExpanded = ref(true) +function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms` + return `${(ms / 1000).toFixed(1)}s` +} + function renderSimpleMarkdown(text: string): string { if (!text) return '' return text diff --git a/packages/bolt-ui/theme-chalk/src/chat-bubble.scss b/packages/bolt-ui/theme-chalk/src/chat-bubble.scss index 70cea8d..18cca7d 100644 --- a/packages/bolt-ui/theme-chalk/src/chat-bubble.scss +++ b/packages/bolt-ui/theme-chalk/src/chat-bubble.scss @@ -75,6 +75,16 @@ &:hover { background: var(--color-hairline); } + + .reasoning-spin { + animation: bo-chat-bubble-spin 0.8s linear infinite; + } + } + + #{e('reasoning-duration')} { + font-size: 11px; + color: var(--color-muted-soft, #8e8b82); + font-weight: 400; } #{e('reasoning-arrow')} { @@ -105,6 +115,13 @@ } } + #{e('reasoning-cursor')} { + display: inline-block; + animation: bo-chat-bubble-blink 1s step-end infinite; + color: var(--color-muted); + font-weight: 600; + } + #{e('typing')} { display: flex; gap: 4px; @@ -215,3 +232,14 @@ transform: scale(1); } } + +@keyframes bo-chat-bubble-spin { + to { + transform: rotate(360deg); + } +} + +@keyframes bo-chat-bubble-blink { + 0%, 50% { opacity: 1; } + 51%, 100% { opacity: 0; } +} diff --git a/packages/drizzle-pkg/db.sqlite b/packages/drizzle-pkg/db.sqlite index 832dfa3f8de8953cc63b16059bf87600b7ccb3d4..4d5781168e6ef6d2a3f6df3fac5747e7c7d9271d 100644 GIT binary patch delta 329 zcmZozAlR@#aDp`BtBEqsjITB(=$>a}ZSFh2z3)6@*#~Ap9*^zp2ber~3{8?u%q-KA zlXR1eEs}IiOiavlEetJ9byJKDlTr-Q%ndCK61f@Yf6kh{fN}o$IPnGB8_qDbF!KrW zdNA;F^RMSE~4dzfrVhr70aF=NclbRs^YzF?{{9pOs^FQXl z%YU7JKmSht&HQWm7xFLXpUwZ0|04fs{-Z#n3;8Es_|LL^{(Gj}4NUyOKoM7dB?VS) z21Y?aQBDR1R#xUjASg+#C;>V&DPswQoL;(~c{&sSyRCwZ55OkKFauRGGBUG(NG2vO h1_lP8Gz0&4{&)OOfvT?aPk(ownS+saJCG7;001`GXn_C# delta 288 zcmZozAlR@#aDp`B^NBLfjL$bF=$>a}Y3@6}z3)6@*#~9;o`&u02ber~49yMGERzh( zOmtI|EfRH2EYp&8Ee%pkbS+F%jZDl85|a|mlDQe@zjL3Z$T zg$(@M{OfrOdEfAx@E+!~HKZ{ZJP}h(@o#^3 Io>`~?0A6HWd;kCd diff --git a/server/api/llm/chat/index.post.ts b/server/api/llm/chat/index.post.ts index 092f9c4..e085988 100644 --- a/server/api/llm/chat/index.post.ts +++ b/server/api/llm/chat/index.post.ts @@ -1,11 +1,38 @@ import { requireUser } from "#server/utils/context"; import { getProviderById, getModelById } from "#server/service/llm"; -import { createOpenAI } from "@ai-sdk/openai"; -import { streamText } from "ai"; +import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; +import { streamText, type LanguageModelV1 } from "ai"; import log4js from "logger"; const logger = log4js.getLogger("APP"); +function resolveModel( + provider: { + name: string; + apiKey: string | null; + baseUrl: string | null; + parseMode: string; + }, + modelId: string, +): LanguageModelV1 { + const baseUrl = provider.baseUrl?.replace(/\/+$/, "") || undefined; + + if (provider.parseMode === "anthropic") { + throw createError({ + statusCode: 400, + statusMessage: "Anthropic 解析模式暂不支持流式对话,请使用 OpenAI 兼容模式", + }); + } + + const openaiCompatible = createOpenAICompatible({ + name: provider.name, + apiKey: provider.apiKey || undefined, + baseURL: baseUrl || "https://api.openai.com/v1", + }); + + return openaiCompatible(modelId) as LanguageModelV1; +} + export default defineEventHandler(async (event) => { const user = await requireUser(event); if (!user) { @@ -41,28 +68,30 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 400, statusMessage: "供应商未配置 API Key" }); } - const baseUrl = provider.baseUrl?.replace(/\/+$/, "") || undefined; - logger.info( - "[%s] [LLM-CHAT] userId=%d modelId=%d provider=%s messages=%d thinking=%s", + "[%s] [LLM-CHAT] userId=%d modelId=%d provider=%s parseMode=%s messages=%d thinking=%s", event.context.requestId ?? "-", user.id, llmModelId, provider.name, + provider.parseMode, messages.length, enableThinking ? "on" : "off", ); - const openai = createOpenAI({ - apiKey: provider.apiKey, - baseURL: baseUrl, - }); + const languageModel = resolveModel(provider, model.modelId); const result = streamText({ - model: openai(model.modelId), + model: languageModel, messages, maxTokens: model.maxTokens || undefined, - ...(enableThinking ? { providerOptions: { openai: { reasoningEffort: 'high' } } } : {}), + ...(enableThinking + ? { + providerOptions: { + openaiCompatible: { reasoningEffort: "high" }, + }, + } + : {}), }); return result.toDataStreamResponse({ sendReasoning: true });