364 lines
10 KiB
JavaScript
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
|
|
}
|
|
})
|