Browse Source

feat: 添加聊天功能和气泡组件,支持用户与助手的消息交互

acas
npmrun 1 month ago
parent
commit
db0d7f46ad
  1. 10
      AGENTS.md
  2. 128
      app/composables/useLlmChat.ts
  3. 381
      app/pages/settings/llm-test/index.vue
  4. 28
      packages/bolt-ui/README.md
  5. 5
      packages/bolt-ui/components/ChatBubble/index.ts
  6. 66
      packages/bolt-ui/components/ChatBubble/src/ChatBubble.vue
  7. 3
      packages/bolt-ui/components/index.ts
  8. 217
      packages/bolt-ui/theme-chalk/src/chat-bubble.scss
  9. BIN
      packages/drizzle-pkg/db.sqlite
  10. 6
      server/api/llm/chat/index.post.ts

10
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.
## 必须遵循

128
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<LlmChatMessage[]>([])
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,
}
}

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

@ -1,5 +1,4 @@
<script setup lang="ts">
import { processDataStream } from 'ai'
interface ModelOption {
id: number
name: string
@ -16,11 +15,7 @@ interface ProviderWithModels {
models: ModelOption[]
}
interface ChatMessage {
id: string
role: 'user' | 'assistant'
content: string
}
const { $toast } = useNuxtApp()
const { data: modelsData, refresh: refreshModels } = await useHttpFetch('/api/llm/chat/models', {
getCachedData: () => undefined,
@ -43,28 +38,22 @@ const allModels = computed(() => {
})
const selectedModelId = ref<number | null>(null)
const systemPrompt = ref('')
const enableThinking = ref(false)
const chatContainer = ref<HTMLElement | null>(null)
const messages = ref<ChatMessage[]>([])
const inputMessage = ref('')
const isLoading = ref(false)
const errorMessage = ref('')
let abortController: AbortController | null = null
const selectedModel = computed(() => {
if (!selectedModelId.value) return null
return allModels.value.find(m => m.id === selectedModelId.value) ?? null
})
function showToast(message: string, type: 'success' | 'error' = 'success') {
if (import.meta.client) {
const event = new CustomEvent('toast', { detail: { message, type } })
window.dispatchEvent(event)
}
}
const { messages, isLoading, errorMessage, sendMessage, stopGeneration, clearChat } = useLlmChat({
modelId: () => selectedModelId.value,
systemPrompt: () => systemPrompt.value,
enableThinking: () => enableThinking.value,
})
function generateId() {
return Date.now().toString(36) + Math.random().toString(36).slice(2)
}
const inputMessage = ref('')
watch(messages, () => {
nextTick(() => {
@ -74,87 +63,23 @@ watch(messages, () => {
})
}, { deep: true })
async function sendMessage() {
watch(errorMessage, (val) => {
if (val) {
$toast.error(val)
}
})
async function handleSend() {
const text = inputMessage.value.trim()
if (!text || !selectedModelId.value || isLoading.value) return
errorMessage.value = ''
inputMessage.value = ''
const userMsg: ChatMessage = { id: generateId(), role: 'user', content: text }
messages.value.push(userMsg)
const assistantMsg: ChatMessage = { 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('/api/llm/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
modelId: selectedModelId.value,
messages: messages.value
.filter(m => m.content)
.map(m => ({ role: m.role, content: m.content })),
}),
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,
onTextPart: (text) => {
messages.value[assistantIdx].content += text
},
onErrorPart: (error) => {
errorMessage.value = error || '流式响应出错'
showToast(errorMessage.value, 'error')
},
})
} catch (err: any) {
if (err.name === 'AbortError') {
// user stopped
} else {
errorMessage.value = err.message || '请求失败'
showToast(err.message || '请求失败', 'error')
if (!messages.value[assistantIdx].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 = ''
await sendMessage(text)
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
sendMessage()
handleSend()
}
}
@ -167,20 +92,10 @@ function modelTypeClass(type: string) {
const map: Record<string, string> = { text: 'type-text', vision: 'type-vision', multimodal: 'type-multimodal' }
return map[type] ?? 'type-text'
}
function renderContent(content: string): string {
if (!content) return ''
return content
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
}
</script>
<template>
<ClientOnly>
<div class="llm-test-page">
<header class="page-header">
<div class="header-left">
@ -203,6 +118,29 @@ function renderContent(content: string): string {
</select>
</div>
<div class="panel-section">
<label class="panel-label">系统提示词</label>
<textarea
v-model="systemPrompt"
class="system-prompt-input"
placeholder="设定模型的角色和行为..."
rows="3"
></textarea>
</div>
<div class="panel-section">
<label class="panel-label thinking-toggle">
<span>思考模式</span>
<button
:class="['toggle-switch', { active: enableThinking }]"
@click="enableThinking = !enableThinking"
>
<span class="toggle-knob"></span>
</button>
</label>
<p class="panel-hint">启用后支持推理的模型会展示思考过程</p>
</div>
<div v-if="selectedModel" class="model-info-card">
<div class="model-info-header">
<span class="model-info-name">{{ selectedModel.name }}</span>
@ -254,34 +192,14 @@ function renderContent(content: string): string {
<p class="welcome-hint">输入消息测试模型连接和响应效果</p>
</div>
<template v-for="msg in messages" :key="msg.id">
<div
v-if="msg.content || msg.role === 'user'"
class="chat-message"
:class="[`message-${msg.role}`]"
>
<div class="message-avatar">
<Icon v-if="msg.role === 'user'" name="lucide:user" />
<Icon v-else name="lucide:bot" />
</div>
<div class="message-body">
<div class="message-role">{{ msg.role === 'user' ? '你' : '助手' }}</div>
<div class="message-content" v-html="renderContent(msg.content)"></div>
</div>
</div>
</template>
<div v-if="isLoading && messages.length > 0 && messages[messages.length - 1]?.content === ''" class="chat-message message-assistant">
<div class="message-avatar">
<Icon name="lucide:bot" />
</div>
<div class="message-body">
<div class="message-role">助手</div>
<div class="message-content typing-indicator">
<span></span><span></span><span></span>
</div>
</div>
</div>
<BoChatBubble
v-for="msg in messages"
:key="msg.id"
:role="msg.role"
:content="msg.content"
:reasoning="msg.reasoning"
:loading="msg.role === 'assistant' && isLoading && !msg.content && !msg.reasoning"
/>
</div>
<div v-if="errorMessage" class="chat-error">
@ -312,7 +230,7 @@ function renderContent(content: string): string {
v-else
class="btn-send"
:disabled="!inputMessage.trim() || !selectedModelId"
@click="sendMessage"
@click="handleSend"
>
<Icon name="lucide:send" />
</button>
@ -323,6 +241,7 @@ function renderContent(content: string): string {
</div>
</div>
</div>
</ClientOnly>
</template>
<style scoped>
@ -398,6 +317,75 @@ function renderContent(content: string): string {
box-shadow: 0 0 0 3px rgba(204, 120, 92, 0.15);
}
.system-prompt-input {
width: 100%;
padding: 10px 14px;
background: var(--color-surface-card);
border: 1px solid var(--color-hairline);
border-radius: 8px;
font-size: 13px;
color: var(--color-ink);
resize: vertical;
line-height: 1.5;
font-family: inherit;
min-height: 60px;
}
.system-prompt-input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(204, 120, 92, 0.15);
}
.system-prompt-input::placeholder {
color: var(--color-muted);
}
.thinking-toggle {
display: flex;
align-items: center;
justify-content: space-between;
}
.toggle-switch {
position: relative;
width: 36px;
height: 20px;
background: var(--color-hairline);
border: none;
border-radius: 10px;
cursor: pointer;
transition: background 0.2s ease;
padding: 0;
flex-shrink: 0;
}
.toggle-switch.active {
background: var(--color-primary);
}
.toggle-knob {
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
background: #fff;
border-radius: 50%;
transition: transform 0.2s ease;
}
.toggle-switch.active .toggle-knob {
transform: translateX(16px);
}
.panel-hint {
font-size: 12px;
color: var(--color-muted);
margin: 0;
line-height: 1.4;
}
.model-info-card {
background: var(--color-surface-card);
border-radius: 10px;
@ -578,129 +566,6 @@ function renderContent(content: string): string {
margin: 0;
}
.chat-message {
display: flex;
gap: 12px;
max-width: 85%;
}
.message-user {
align-self: flex-end;
flex-direction: row-reverse;
}
.message-assistant {
align-self: flex-start;
}
.message-avatar {
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.message-user .message-avatar {
background: var(--color-primary);
color: var(--color-on-primary);
}
.message-assistant .message-avatar {
background: var(--color-surface-soft);
color: var(--color-body-strong);
}
.message-avatar :deep(svg) {
width: 16px;
height: 16px;
}
.message-body {
display: flex;
flex-direction: column;
gap: 4px;
}
.message-role {
font-size: 11px;
font-weight: 500;
color: var(--color-muted);
}
.message-user .message-role {
text-align: right;
}
.message-content {
font-size: 14px;
line-height: 1.6;
color: var(--color-body);
word-break: break-word;
}
.message-user .message-content {
background: var(--color-primary);
color: var(--color-on-primary);
padding: 10px 14px;
border-radius: 12px 12px 4px 12px;
}
.message-assistant .message-content {
background: var(--color-canvas);
padding: 10px 14px;
border-radius: 12px 12px 12px 4px;
border: 1px solid var(--color-hairline);
}
.message-content :deep(code) {
background: rgba(0, 0, 0, 0.06);
padding: 1px 5px;
border-radius: 4px;
font-size: 13px;
font-family: monospace;
}
.message-user .message-content :deep(code) {
background: rgba(255, 255, 255, 0.2);
}
.typing-indicator {
display: flex;
gap: 4px;
align-items: center;
padding: 10px 14px;
}
.typing-indicator span {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--color-muted);
animation: typing 1.4s infinite;
}
.typing-indicator span:nth-child(2) {
animation-delay: 0.2s;
}
.typing-indicator span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes typing {
0%, 60%, 100% {
opacity: 0.3;
transform: scale(0.8);
}
30% {
opacity: 1;
transform: scale(1);
}
}
.chat-error {
display: flex;
align-items: center;
@ -835,9 +700,5 @@ function renderContent(content: string): string {
.chat-panel {
min-height: 500px;
}
.chat-message {
max-width: 95%;
}
}
</style>

28
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
<BoChatBubble role="user" content="你好" />
<BoChatBubble role="assistant" content="**你好!** 有什么可以帮你的?" />
<BoChatBubble role="assistant" :loading="true" />
```
### Props
| 名称 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `role` | `'user' \| 'assistant'` | — | 消息角色,决定气泡方向和样式 |
| `content` | `string` | `''` | 消息文本内容,支持简易 Markdown(code、bold、换行) |
| `loading` | `boolean` | `false` | 是否显示打字指示器(三点动画) |
### Slots
| 名称 | 说明 |
|------|------|
| `default` | 自定义消息内容,覆盖 content 渲染 |
| `avatar` | 自定义头像,覆盖默认图标 |

5
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

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

@ -0,0 +1,66 @@
<template>
<div :class="[b(), m(role)]">
<div :class="e('avatar')">
<slot name="avatar">
<svg v-if="role === 'user'" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/></svg>
</slot>
</div>
<div :class="e('body')">
<div :class="e('role')">{{ role === 'user' ? '你' : '助手' }}</div>
<div v-if="role === 'assistant' && reasoning" :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 :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>
<div :class="e('content')">
<slot>
<span v-if="!content && !reasoning && loading" :class="e('typing')">
<span></span><span></span><span></span>
</span>
<span v-else v-html="renderedContent"></span>
</slot>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useNamespace } from 'bolt-ui/utils/hooks/use-namespace'
const props = withDefaults(
defineProps<{
role: 'user' | 'assistant'
content?: string
reasoning?: string
loading?: boolean
}>(),
{
content: '',
reasoning: '',
loading: false,
},
)
const { b, m, e } = useNamespace('chat-bubble')
defineOptions({ name: 'BoChatBubble' })
const reasoningExpanded = ref(true)
function renderSimpleMarkdown(text: string): string {
if (!text) return ''
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
}
const renderedContent = computed(() => renderSimpleMarkdown(props.content))
const renderedReasoning = computed(() => renderSimpleMarkdown(props.reasoning))
</script>

3
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'
export * from './MdEditor'
export * from './ChatBubble'

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

BIN
packages/drizzle-pkg/db.sqlite

Binary file not shown.

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

Loading…
Cancel
Save