Browse Source

feat: 添加推理功能支持,优化聊天组件,更新依赖项

acas
npmrun 18 hours ago
parent
commit
300c3f136f
  1. 1
      AGENTS.md
  2. 33
      app/composables/useLlmChat.ts
  3. 4
      app/pages/settings/llm-test/index.vue
  4. 5
      bun.lock
  5. 1
      package.json
  6. 24
      packages/bolt-ui/components/ChatBubble/src/ChatBubble.vue
  7. 28
      packages/bolt-ui/theme-chalk/src/chat-bubble.scss
  8. BIN
      packages/drizzle-pkg/db.sqlite
  9. 51
      server/api/llm/chat/index.post.ts

1
AGENTS.md

@ -11,6 +11,7 @@
## 必须遵循
- 注意代码的解耦,做到高内聚,低耦合
- 安装依赖时,必须写死依赖的版本,禁止任何升级的可能,如存在需要升级的场景,必须手动执行安装新版本命令
- 新建的mono包必须在根目录安装
- 使用nuxt时遇到不清楚的,必须先加载nuxt-remote查询文档再进行分析

33
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

4
app/pages/settings/llm-test/index.vue

@ -138,7 +138,7 @@ function modelTypeClass(type: string) {
<span class="toggle-knob"></span>
</button>
</label>
<p class="panel-hint">启用后支持推理的模型会展示思考过程</p>
<p class="panel-hint">启用后支持推理的模型会展示思考过程仅对支持推理的模型有效 DeepSeek-R1o1 </p>
</div>
<div v-if="selectedModel" class="model-info-card">
@ -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"
/>
</div>

5
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=="],

1
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",

24
packages/bolt-ui/components/ChatBubble/src/ChatBubble.vue

@ -8,17 +8,22 @@
</div>
<div :class="e('body')">
<div :class="e('role')">{{ role === 'user' ? '你' : '助手' }}</div>
<div v-if="role === 'assistant' && reasoning" :class="e('reasoning')">
<div v-if="role === 'assistant' && (reasoning || reasoningLoading)" :class="e('reasoning')">
<div :class="e('reasoning-header')" @click="reasoningExpanded = !reasoningExpanded">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a8 8 0 0 0-8 8c0 3.4 2.1 6.3 5 7.5V20h6v-2.5c2.9-1.2 5-4.1 5-7.5a8 8 0 0 0-8-8Z"/><path d="M9 22h6"/></svg>
<span>思考过程</span>
<svg v-if="reasoningLoading" class="reasoning-spin" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a8 8 0 0 0-8 8c0 3.4 2.1 6.3 5 7.5V20h6v-2.5c2.9-1.2 5-4.1 5-7.5a8 8 0 0 0-8-8Z"/><path d="M9 22h6"/></svg>
<span>{{ reasoningLoading ? '思考中' : '思考过程' }}</span>
<span v-if="reasoningDuration" :class="e('reasoning-duration')">{{ formatDuration(reasoningDuration) }}</span>
<svg :class="[e('reasoning-arrow'), { 'is-expanded': reasoningExpanded }]" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
</div>
<div v-show="reasoningExpanded" :class="e('reasoning-content')" v-html="renderedReasoning"></div>
<div v-show="reasoningExpanded" :class="e('reasoning-content')">
<span v-html="renderedReasoning"></span>
<span v-if="reasoningLoading" :class="e('reasoning-cursor')"></span>
</div>
</div>
<div :class="e('content')">
<slot>
<span v-if="!content && !reasoning && loading" :class="e('typing')">
<span v-if="!content && loading" :class="e('typing')">
<span></span><span></span><span></span>
</span>
<span v-else v-html="renderedContent"></span>
@ -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

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

BIN
packages/drizzle-pkg/db.sqlite

Binary file not shown.

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

Loading…
Cancel
Save