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 b4abbac..1816764 100644 Binary files a/packages/drizzle-pkg/db.sqlite and b/packages/drizzle-pkg/db.sqlite differ 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 }); });