10 changed files with 581 additions and 263 deletions
@ -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, |
|||
} |
|||
} |
|||
@ -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 |
|||
@ -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, '&') |
|||
.replace(/</g, '<') |
|||
.replace(/>/g, '>') |
|||
.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> |
|||
@ -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); |
|||
} |
|||
} |
|||
Binary file not shown.
Loading…
Reference in new issue