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