You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
128 lines
3.2 KiB
128 lines
3.2 KiB
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,
|
|
}
|
|
}
|
|
|