From db0d7f46ad1ab5a2db14891f4bdf3e65729e0bc0 Mon Sep 17 00:00:00 2001 From: npmrun <1549469775@qq.com> Date: Fri, 3 Jul 2026 17:58:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E5=92=8C=E6=B0=94=E6=B3=A1=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E7=94=A8=E6=88=B7=E4=B8=8E=E5=8A=A9?= =?UTF-8?q?=E6=89=8B=E7=9A=84=E6=B6=88=E6=81=AF=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 10 + app/composables/useLlmChat.ts | 128 +++++++ app/pages/settings/llm-test/index.vue | 381 +++++++-------------- packages/bolt-ui/README.md | 28 ++ packages/bolt-ui/components/ChatBubble/index.ts | 5 + .../components/ChatBubble/src/ChatBubble.vue | 66 ++++ packages/bolt-ui/components/index.ts | 3 +- packages/bolt-ui/theme-chalk/src/chat-bubble.scss | 217 ++++++++++++ packages/drizzle-pkg/db.sqlite | Bin 282624 -> 282624 bytes server/api/llm/chat/index.post.ts | 6 +- 10 files changed, 581 insertions(+), 263 deletions(-) create mode 100644 app/composables/useLlmChat.ts create mode 100644 packages/bolt-ui/components/ChatBubble/index.ts create mode 100644 packages/bolt-ui/components/ChatBubble/src/ChatBubble.vue create mode 100644 packages/bolt-ui/theme-chalk/src/chat-bubble.scss diff --git a/AGENTS.md b/AGENTS.md index 8bf23bc..9160f34 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,3 +1,13 @@ +## AI八荣八耻 + +1. Shame in guessing APIs, Honor in careful research. +2. Shame in vague execution, Honor in seeking confirmation. +3. Shame in assuming business logic, Honor in human verification. +4. Shame in creating interfaces, Honor in reusing existing ones. +5. Shame in skipping validation, Honor in proactive testing. +6. Shame in breaking architecture, Honor in following specifications. +7. Shame in pretending to understand, Honor in honest ignorance. +8. Shame in blind modification, Honor in careful refactoring. ## 必须遵循 diff --git a/app/composables/useLlmChat.ts b/app/composables/useLlmChat.ts new file mode 100644 index 0000000..5960ffa --- /dev/null +++ b/app/composables/useLlmChat.ts @@ -0,0 +1,128 @@ +import { processDataStream } from 'ai' + +export interface LlmChatMessage { + id: string + role: 'user' | 'assistant' + content: string + reasoning?: string +} + +export interface UseLlmChatOptions { + modelId: () => number | null + apiEndpoint?: string + systemPrompt?: () => string + enableThinking?: () => boolean +} + +export function useLlmChat(options: UseLlmChatOptions) { + const { + modelId, + apiEndpoint = '/api/llm/chat', + systemPrompt, + enableThinking, + } = options + + const messages = ref([]) + const isLoading = ref(false) + const errorMessage = ref('') + + let abortController: AbortController | null = null + + function generateId(): string { + return Date.now().toString(36) + Math.random().toString(36).slice(2) + } + + async function sendMessage(text: string) { + const trimmed = text.trim() + const mid = modelId() + if (!trimmed || mid === null || isLoading.value) return + + errorMessage.value = '' + + const userMsg: LlmChatMessage = { id: generateId(), role: 'user', content: trimmed } + messages.value.push(userMsg) + + const assistantMsg: LlmChatMessage = { id: generateId(), role: 'assistant', content: '' } + messages.value.push(assistantMsg) + const assistantIdx = messages.value.length - 1 + + isLoading.value = true + abortController = new AbortController() + + try { + const res = await fetch(apiEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + modelId: mid, + messages: [ + ...(systemPrompt?.() ? [{ role: 'system' as const, content: systemPrompt() }] : []), + ...messages.value + .filter(m => m.content) + .map(m => ({ role: m.role, content: m.content })), + ], + enableThinking: enableThinking?.() ?? false, + }), + signal: abortController.signal, + }) + + if (!res.ok) { + const errText = await res.text() + throw new Error(errText || `请求失败 (${res.status})`) + } + + if (!res.body) { + throw new Error('响应体为空') + } + + await processDataStream({ + stream: res.body, + onReasoningPart: (text) => { + const msg = messages.value[assistantIdx] + if (msg) msg.reasoning = (msg.reasoning ?? '') + text + }, + onTextPart: (text) => { + const msg = messages.value[assistantIdx] + if (msg) msg.content += text + }, + onErrorPart: (error) => { + errorMessage.value = error || '流式响应出错' + }, + }) + } catch (err: any) { + if (err.name === 'AbortError') { + // user stopped + } else { + errorMessage.value = err.message || '请求失败' + const msg = messages.value[assistantIdx] + if (msg && !msg.content) { + messages.value.splice(assistantIdx, 1) + } + } + } finally { + isLoading.value = false + abortController = null + } + } + + function stopGeneration() { + if (abortController) { + abortController.abort() + abortController = null + } + } + + function clearChat() { + messages.value = [] + errorMessage.value = '' + } + + return { + messages, + isLoading, + errorMessage, + sendMessage, + stopGeneration, + clearChat, + } +} diff --git a/app/pages/settings/llm-test/index.vue b/app/pages/settings/llm-test/index.vue index 45ad4cf..d80d928 100644 --- a/app/pages/settings/llm-test/index.vue +++ b/app/pages/settings/llm-test/index.vue @@ -1,5 +1,4 @@ diff --git a/packages/bolt-ui/README.md b/packages/bolt-ui/README.md index 520d460..3132ba9 100644 --- a/packages/bolt-ui/README.md +++ b/packages/bolt-ui/README.md @@ -20,6 +20,7 @@ When used via the `bolt-ui/nuxt` module (already wired in `nuxt.config.ts`), com | `BoDialog` | Modal dialog with Teleport + Mask + Transition | | `BoDrawer` | Side-sliding panel (left/right) with mask, focus trap, and body scroll lock | | `BoMask` | Full-screen mask overlay | +| `BoChatBubble` | Chat message bubble with user/assistant variants, simple Markdown rendering, and typing indicator | ## Drawer 抽屉 @@ -81,3 +82,30 @@ useClickOutside(elementRef, (event) => { ``` 监听元素外部的点击事件,可选地忽略某些元素。SSR 期间无操作,客户端 mount 后挂载。 + +## ChatBubble 聊天气泡 + +通用聊天气泡组件,支持用户/助手两种角色,内置简易 Markdown 渲染和打字指示器。 + +### 基础用法 + +```vue + + + +``` + +### Props + +| 名称 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `role` | `'user' \| 'assistant'` | — | 消息角色,决定气泡方向和样式 | +| `content` | `string` | `''` | 消息文本内容,支持简易 Markdown(code、bold、换行) | +| `loading` | `boolean` | `false` | 是否显示打字指示器(三点动画) | + +### Slots + +| 名称 | 说明 | +|------|------| +| `default` | 自定义消息内容,覆盖 content 渲染 | +| `avatar` | 自定义头像,覆盖默认图标 | diff --git a/packages/bolt-ui/components/ChatBubble/index.ts b/packages/bolt-ui/components/ChatBubble/index.ts new file mode 100644 index 0000000..0f48a68 --- /dev/null +++ b/packages/bolt-ui/components/ChatBubble/index.ts @@ -0,0 +1,5 @@ +import { withInstall } from 'bolt-ui/utils/vue/install' +import ChatBubble from './src/ChatBubble.vue' + +export const BoChatBubble = withInstall(ChatBubble) +export default BoChatBubble diff --git a/packages/bolt-ui/components/ChatBubble/src/ChatBubble.vue b/packages/bolt-ui/components/ChatBubble/src/ChatBubble.vue new file mode 100644 index 0000000..2fc0190 --- /dev/null +++ b/packages/bolt-ui/components/ChatBubble/src/ChatBubble.vue @@ -0,0 +1,66 @@ + + + diff --git a/packages/bolt-ui/components/index.ts b/packages/bolt-ui/components/index.ts index ebfe828..9562338 100644 --- a/packages/bolt-ui/components/index.ts +++ b/packages/bolt-ui/components/index.ts @@ -4,4 +4,5 @@ export * from './Container' export * from './Dialog' export * from './Mask' export * from './Drawer' -export * from './MdEditor' \ No newline at end of file +export * from './MdEditor' +export * from './ChatBubble' \ No newline at end of file diff --git a/packages/bolt-ui/theme-chalk/src/chat-bubble.scss b/packages/bolt-ui/theme-chalk/src/chat-bubble.scss new file mode 100644 index 0000000..70cea8d --- /dev/null +++ b/packages/bolt-ui/theme-chalk/src/chat-bubble.scss @@ -0,0 +1,217 @@ +@use 'core/_base' as *; + +@include setNamespace('chat-bubble'); + +#{b()} { + display: flex; + gap: 10px; + max-width: 85%; + align-items: flex-start; + + #{e('avatar')} { + width: 28px; + height: 28px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-top: 2px; + } + + #{e('body')} { + display: flex; + flex-direction: column; + gap: 4px; + } + + #{e('role')} { + font-size: 12px; + font-weight: 500; + color: var(--color-muted); + } + + #{e('content')} { + font-size: 14px; + line-height: 1.6; + color: var(--color-body); + word-break: break-word; + padding: 10px 14px; + position: relative; + + :deep(code) { + background: rgba(0, 0, 0, 0.06); + padding: 1px 5px; + border-radius: 4px; + font-size: 13px; + font-family: monospace; + } + + :deep(strong) { + font-weight: 600; + } + } + + #{e('reasoning')} { + margin-bottom: 4px; + } + + #{e('reasoning-header')} { + display: flex; + align-items: center; + gap: 4px; + padding: 6px 10px; + font-size: 12px; + font-weight: 500; + color: var(--color-muted); + cursor: pointer; + border-radius: 8px 8px 0 0; + background: var(--color-surface-soft); + border: 1px solid var(--color-hairline); + border-bottom: none; + user-select: none; + transition: background 0.15s ease; + + &:hover { + background: var(--color-hairline); + } + } + + #{e('reasoning-arrow')} { + margin-left: auto; + transition: transform 0.2s ease; + + &.is-expanded { + transform: rotate(180deg); + } + } + + #{e('reasoning-content')} { + padding: 8px 10px; + font-size: 13px; + line-height: 1.5; + color: var(--color-muted); + background: var(--color-surface-soft); + border: 1px solid var(--color-hairline); + border-radius: 0 0 8px 8px; + white-space: pre-wrap; + + :deep(code) { + background: rgba(0, 0, 0, 0.06); + padding: 1px 5px; + border-radius: 4px; + font-size: 12px; + font-family: monospace; + } + } + + #{e('typing')} { + display: flex; + gap: 4px; + align-items: center; + + span { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--color-muted); + animation: bo-chat-bubble-typing 1.4s infinite; + + &:nth-child(2) { + animation-delay: 0.2s; + } + + &:nth-child(3) { + animation-delay: 0.4s; + } + } + } + + // ── user variant ── + &#{m('user')} { + align-self: flex-end; + flex-direction: row-reverse; + + #{e('avatar')} { + background: var(--color-primary); + color: var(--color-on-primary); + } + + #{e('role')} { + text-align: right; + } + + #{e('content')} { + background: var(--color-primary); + color: var(--color-on-primary); + border-radius: 12px 12px 4px 12px; + + &::after { + content: ''; + position: absolute; + right: -6px; + top: 8px; + width: 0; + height: 0; + border-left: 6px solid var(--color-primary); + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; + } + + :deep(code) { + background: rgba(255, 255, 255, 0.2); + } + } + } + + // ── assistant variant ── + &#{m('assistant')} { + align-self: flex-start; + + #{e('avatar')} { + background: var(--color-surface-soft); + color: var(--color-body-strong); + } + + #{e('content')} { + background: var(--color-canvas); + border-radius: 12px 12px 12px 4px; + border: 1px solid var(--color-hairline); + + &::after { + content: ''; + position: absolute; + left: -6px; + top: 8px; + width: 0; + height: 0; + border-right: 6px solid var(--color-canvas); + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; + } + + &::before { + content: ''; + position: absolute; + left: -7px; + top: 8px; + width: 0; + height: 0; + border-right: 7px solid var(--color-hairline); + border-top: 4.5px solid transparent; + border-bottom: 4.5px solid transparent; + } + } + } +} + +@keyframes bo-chat-bubble-typing { + 0%, 60%, 100% { + opacity: 0.3; + transform: scale(0.8); + } + 30% { + opacity: 1; + transform: scale(1); + } +} diff --git a/packages/drizzle-pkg/db.sqlite b/packages/drizzle-pkg/db.sqlite index b4abbac3a9c7ee2249d3e124fa682e77903c89df..1816764899706222ba0c2b60e38a06e077972c86 100644 GIT binary patch delta 413 zcmZozAlR@#aDp`B-H9^JjCVIC@SJC4o}7DLfsv)T?|ggTdB*L1=b0)lu$40KAK)+D zY^adQ@9oLL&A@1CFUrZlz{<)TSe}|^tQ+Q|YvH8pnO9s=RGM4@64S>fQIc9w!Z=^u zvU?AN+x_4Ffj4oXW;+He;;UV6aVzS`^6jNHYUIho1%d1*QM<;>GR-e(qOXX0mI;P2xHs{G1V-^k6(!@#Jl>@Ha>33MW| zawMG@B_##LR{Hvh1(|wC!g|Sh`elas1*v(7nZ?<}+{`K4J^iOD7WnPsUU?^%^Y$nAaanac#Wvut4g s!Y{(j%*xEj$PM-1D+d0r{I9kOGT!6o=3@SJC4nw)!Hfswhn?|ggTdB*L1=b0)lu(dGoAK-7< zY^YGdZ)3&G&A_OwEy~Huz{<*$lA2mjoSK@gTV|qLP?V3xElI5?VVtkHp4E(>g3NpUp=iz3S; H7H$IoB~wY@ diff --git a/server/api/llm/chat/index.post.ts b/server/api/llm/chat/index.post.ts index 621e79a..7c49a6d 100644 --- a/server/api/llm/chat/index.post.ts +++ b/server/api/llm/chat/index.post.ts @@ -10,9 +10,10 @@ export default defineEventHandler(async (event) => { } const body = await readBody(event); - const { modelId: llmModelId, messages } = body as { + const { modelId: llmModelId, messages, enableThinking } = body as { modelId: number; messages: { role: "user" | "assistant" | "system"; content: string }[]; + enableThinking?: boolean; }; if (!llmModelId || !messages || !Array.isArray(messages) || messages.length === 0) { @@ -48,7 +49,8 @@ export default defineEventHandler(async (event) => { model: openai(model.modelId), messages, maxTokens: model.maxTokens || undefined, + ...(enableThinking ? { providerOptions: { openai: { reasoningEffort: 'high' } } } : {}), }); - return result.toDataStreamResponse(); + return result.toDataStreamResponse({ sendReasoning: true }); });