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 });