Files
chat/frontend/src/stores/chat.js
T
2026-07-16 10:15:42 +08:00

364 lines
10 KiB
JavaScript

import { defineStore } from 'pinia'
import { ref } from 'vue'
import api from '@/api'
function parseSseBlock(block) {
const lines = block.split('\n')
let event = 'message'
let data = null
for (const line of lines) {
if (line.startsWith('event:')) {
event = line.slice(6).trim()
} else if (line.startsWith('data:')) {
const raw = line.slice(5).trim()
try {
data = JSON.parse(raw)
} catch {
data = { content: raw }
}
}
}
return { event, data }
}
function normalizeList(data) {
if (Array.isArray(data)) return data
if (data && typeof data === 'object') {
if (Array.isArray(data.list)) return data.list
return Object.values(data)
}
return []
}
function isValidMessage(msg) {
if (!msg || typeof msg !== 'object') return false
const hasContent = !!(msg.content && String(msg.content).trim())
const attachments = msg.attachments || []
const hasAttachments = Array.isArray(attachments) ? attachments.length > 0 : false
return hasContent || hasAttachments
}
function hasPendingJobs(list) {
return (list || []).some(m => m && m.pending_job)
}
export const useChatStore = defineStore('chat', () => {
const conversations = ref([])
const currentId = ref(null)
const messages = ref([])
const loading = ref(false)
const sending = ref(false)
const streamingContent = ref('')
const streamingAttachments = ref([])
const sidebarOpen = ref(false)
/** 侧边栏当前选中的模型,发送/新建会话都会使用它 */
const selectedModelId = ref(null)
let pendingPollTimer = null
let pendingPollInFlight = false
function stopPendingJobPolling() {
if (pendingPollTimer) {
clearInterval(pendingPollTimer)
pendingPollTimer = null
}
pendingPollInFlight = false
}
function startPendingJobPolling() {
stopPendingJobPolling()
if (!currentId.value || !hasPendingJobs(messages.value)) return
pendingPollTimer = setInterval(async () => {
if (!currentId.value || pendingPollInFlight) return
if (!hasPendingJobs(messages.value)) {
stopPendingJobPolling()
return
}
pendingPollInFlight = true
try {
await loadMessages(currentId.value, { quiet: true })
} catch {
// 轮询失败时下次再试
} finally {
pendingPollInFlight = false
}
if (!hasPendingJobs(messages.value)) {
stopPendingJobPolling()
await fetchConversations().catch(() => {})
}
}, 4000)
}
async function fetchConversations() {
const res = await api.get('/conversations')
const data = res.data?.data
conversations.value = normalizeList(data?.list ?? data)
}
async function loadMessages(id = currentId.value, options = {}) {
if (!id) return
const quiet = !!options.quiet
const res = await api.get(`/conversations/${id}/messages`)
// 切换会话后丢弃过期响应
if (id !== currentId.value) return
const list = normalizeList(res.data?.data)
messages.value = list.filter(isValidMessage)
if (!quiet) {
if (hasPendingJobs(messages.value)) {
startPendingJobPolling()
} else {
stopPendingJobPolling()
}
} else if (!hasPendingJobs(messages.value)) {
stopPendingJobPolling()
} else if (!pendingPollTimer) {
startPendingJobPolling()
}
}
async function createConversation(modelId = null) {
const mid = modelId ?? selectedModelId.value
const res = await api.post('/conversations', { model_id: mid })
const conv = res.data.data
conversations.value.unshift(conv)
currentId.value = conv.id
if (conv.model_id != null) {
selectedModelId.value = Number(conv.model_id)
}
messages.value = []
stopPendingJobPolling()
return conv
}
async function selectConversation(id) {
currentId.value = id
stopPendingJobPolling()
const conv = conversations.value.find(c => c.id === id)
if (conv?.model_id != null) {
selectedModelId.value = Number(conv.model_id)
}
loading.value = true
try {
await loadMessages(id)
} finally {
loading.value = false
}
}
async function setSelectedModel(modelId) {
const mid = modelId == null || modelId === '' ? null : Number(modelId)
selectedModelId.value = mid
// 已有会话时立即切换绑定模型,否则下拉只改了 UI、实际仍走旧模型
if (!currentId.value || mid == null) return
const conv = conversations.value.find(c => c.id === currentId.value)
if (conv && Number(conv.model_id) === mid) return
const res = await api.put(`/conversations/${currentId.value}`, { model_id: mid })
const updated = res.data?.data
if (updated) {
conversations.value = conversations.value.map(c =>
c.id === currentId.value ? { ...c, ...updated } : c
)
} else if (conv) {
conv.model_id = mid
}
}
async function deleteConversation(id) {
await api.delete(`/conversations/${id}`)
conversations.value = conversations.value.filter(c => c.id !== id)
if (currentId.value === id) {
currentId.value = null
messages.value = []
stopPendingJobPolling()
}
}
async function sendMessage(content, attachments = []) {
if (!currentId.value) {
await createConversation(selectedModelId.value)
} else if (selectedModelId.value != null) {
// 发送前确保当前会话已绑定侧边栏所选模型
const conv = conversations.value.find(c => c.id === currentId.value)
if (conv && Number(conv.model_id) !== Number(selectedModelId.value)) {
await setSelectedModel(selectedModelId.value)
}
}
const userMsg = {
id: Date.now(),
role: 'user',
content,
attachments,
created_at: new Date().toISOString()
}
messages.value.push(userMsg)
sending.value = true
streamingContent.value = ''
streamingAttachments.value = []
try {
await streamChat(content, attachments)
await loadMessages()
} catch (e) {
// 连接断开/代理超时:任务可能已落库为 pending,先同步再决定是否抛错
messages.value = messages.value.filter(m => m.id !== userMsg.id)
await loadMessages().catch(() => {})
if (hasPendingJobs(messages.value)) {
startPendingJobPolling()
return
}
throw e
} finally {
sending.value = false
streamingContent.value = ''
streamingAttachments.value = []
}
}
async function streamChat(content, attachments) {
const token = localStorage.getItem('token')
const response = await fetch('/api/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
conversation_id: currentId.value,
content,
attachments,
stream: true
})
})
const contentType = response.headers.get('content-type') || ''
if (!contentType.includes('text/event-stream')) {
const json = await response.json().catch(() => null)
const msg = json?.message || `请求失败 (${response.status})`
throw new Error(msg)
}
if (!response.body) {
throw new Error('浏览器不支持流式响应')
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
let fullContent = ''
let gotAttachments = false
let pendingDone = false
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const blocks = buffer.split('\n\n')
buffer = blocks.pop() || ''
for (const block of blocks) {
if (!block.trim()) continue
const { event, data } = parseSseBlock(block)
if (event === 'error') {
throw new Error(data?.message || 'AI 请求失败')
}
if (event === 'progress' && data?.content) {
streamingContent.value = data.content
}
if (event === 'image' && Array.isArray(data?.attachments) && data.attachments.length) {
streamingAttachments.value = data.attachments
gotAttachments = true
}
if (event === 'message' && data?.content) {
fullContent += data.content
streamingContent.value = fullContent
}
if (event === 'done') {
if (data?.pending) {
pendingDone = true
if (data?.content) {
streamingContent.value = data.content
}
}
if (data?.content && !fullContent) {
fullContent = data.content
streamingContent.value = fullContent
}
if (Array.isArray(data?.attachments) && data.attachments.length) {
streamingAttachments.value = data.attachments
gotAttachments = true
}
}
}
}
if (pendingDone) {
await loadMessages().catch(() => {})
startPendingJobPolling()
await fetchConversations().catch(() => {})
return
}
if (!fullContent.trim() && !gotAttachments) {
// 生图可能已落库为 pending:刷新后由轮询恢复,不在此当成失败
await loadMessages().catch(() => {})
if (hasPendingJobs(messages.value) || messages.value.some(m =>
Array.isArray(m.attachments) && m.attachments.some(a => a?.type === 'image')
)) {
startPendingJobPolling()
return
}
throw new Error('AI 未返回内容,请在管理后台测试模型配置是否正确')
}
await fetchConversations()
}
function toggleSidebar() {
sidebarOpen.value = !sidebarOpen.value
}
function closeSidebar() {
sidebarOpen.value = false
}
return {
conversations,
currentId,
messages,
loading,
sending,
streamingContent,
streamingAttachments,
sidebarOpen,
selectedModelId,
fetchConversations,
createConversation,
selectConversation,
setSelectedModel,
deleteConversation,
sendMessage,
loadMessages,
toggleSidebar,
closeSidebar
}
})