| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678 |
- import { defineStore } from 'pinia'
- import request from '@/utils/request'
- import { message } from 'ant-design-vue'
- export interface Message {
- id: number
- forum_id: number
- persona_id: number
- moderator_id?: number | null
- speaker_name: string
- content: string
- thought?: string | null // Added thought field
- timestamp: string
- }
- export interface Moderator {
- id: number
- name: string
- title: string
- bio: string
- system_prompt?: string
- greeting_template?: string
- closing_template?: string
- summary_template?: string
- creator_id: number
- created_at: string
- }
- export interface Forum {
- id: number
- topic: string
- creator_id: number
- moderator_id?: number | null
- moderator?: Moderator | null
- status: string
- start_time?: string | null
- summary_history: string[]
- participants?: any[]
- duration_minutes?: number
- }
- export interface SystemLog {
- id?: number
- timestamp: string
- level: 'info' | 'warning' | 'error' | 'thought' | 'speech'
- content: string
- source?: string
- }
- export const useForumStore = defineStore('forum', {
- state: () => ({
- forums: [] as Forum[],
- currentForum: null as Forum | null,
- messages: [] as Message[],
- moderators: [] as Moderator[],
- systemLogs: [] as SystemLog[],
- loading: false,
- thinking: false,
-
- // WebSocket Global State
- ws: null as WebSocket | null,
- isConnected: false,
- wsForumId: null as number | null,
- heartbeatInterval: null as any,
- reconnectTimeout: null as any,
- reconnectAttempts: 0,
- isManuallyClosed: false
- }),
- actions: {
- systemLogKey(log: SystemLog) {
- const timestamp = new Date(log.timestamp).getTime()
- return `${timestamp}|${log.level}|${log.source || ''}|${log.content}`
- },
- mergeSystemLogs(...collections: SystemLog[][]) {
- const unique = new Map<string, SystemLog>()
- for (const log of collections.flat()) {
- const key = this.systemLogKey(log)
- const existing = unique.get(key)
- if (!existing || (existing.id == null && log.id != null)) {
- unique.set(key, log)
- }
- }
- return [...unique.values()].sort(
- (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
- )
- },
- // --- Persistence ---
- saveToStorage() {
- if (!this.currentForum) return
- const data = {
- forum: this.currentForum,
- messages: this.messages,
- logs: this.systemLogs,
- thinking: this.thinking,
- timestamp: Date.now()
- }
- try {
- localStorage.setItem(`forum_data_${this.currentForum.id}`, JSON.stringify(data))
- } catch (e) {
- console.error('Failed to save to storage', e)
- }
- },
- loadFromStorage(forumId: number): boolean {
- try {
- const raw = localStorage.getItem(`forum_data_${forumId}`)
- if (!raw) return false
- const data = JSON.parse(raw)
-
- // Validate data integrity
- // Must have a valid forum object with ID matching request
- if (!data.forum || data.forum.id !== forumId) {
- return false
- }
-
- this.currentForum = data.forum
- // Ensure messages are valid (must have speaker_name)
- this.messages = Array.isArray(data.messages)
- ? data.messages.filter((m: any) => m && typeof m.speaker_name === 'string')
- : []
- this.systemLogs = Array.isArray(data.logs) ? data.logs : []
- this.thinking = !!data.thinking
- return true
- } catch (e) {
- console.error('Failed to load from storage', e)
- return false
- }
- },
- // --- WebSocket Actions ---
- resolveWsBase() {
- const raw = (import.meta.env.VITE_WS_BASE_URL as string | undefined)?.trim()
- if (!raw) {
- const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
- return `${protocol}//${window.location.host}`
- }
- if (raw.startsWith('ws://') || raw.startsWith('wss://')) {
- return raw.replace(/\/$/, '')
- }
- if (raw.startsWith('http://') || raw.startsWith('https://')) {
- return raw.replace(/^http/, 'ws').replace(/\/$/, '')
- }
- return raw.replace(/\/$/, '')
- },
-
- clearTimers() {
- if (this.heartbeatInterval) clearInterval(this.heartbeatInterval)
- if (this.reconnectTimeout) clearTimeout(this.reconnectTimeout)
- this.heartbeatInterval = null
- this.reconnectTimeout = null
- },
- disconnectWebSocket() {
- this.isManuallyClosed = true
- this.clearTimers()
- if (this.ws) {
- try {
- // Remove listeners to prevent reconnect loops on manual close
- this.ws.onclose = null
- this.ws.onerror = null
- this.ws.onmessage = null
- this.ws.onopen = null
- this.ws.close(1000, "Client initiated disconnect")
- } catch (e) { /* ignore */ }
- this.ws = null
- this.isConnected = false
- this.wsForumId = null
- }
- },
- connectWebSocket(forumId: number) {
- // If already connected to this forum, just return
- if (this.ws && this.isConnected && this.wsForumId === forumId && this.ws.readyState === WebSocket.OPEN) {
- return
- }
- // If connected to a DIFFERENT forum, disconnect first
- if (this.ws && (this.wsForumId !== forumId || this.ws.readyState !== WebSocket.OPEN)) {
- this.disconnectWebSocket()
- }
- // Start new connection
- this.isManuallyClosed = false
- this.wsForumId = forumId
- const wsBase = this.resolveWsBase()
- const token = localStorage.getItem('token')
- const wsUrl = `${wsBase}/api/v1/forums/${forumId}/ws${token ? `?token=${encodeURIComponent(token)}` : ''}`
- const maxReconnectAttempts = 10
- console.log(`[WS Global] Connecting to forum ${forumId}`)
- try {
- this.ws = new WebSocket(wsUrl)
-
- this.ws.onopen = () => {
- console.log('[WS Global] Connected successfully')
- this.isConnected = true
- this.reconnectAttempts = 0
-
- // Sync data on connect
- this.fetchMessages(forumId)
-
- this.clearTimers()
-
- // Heartbeat
- this.heartbeatInterval = setInterval(() => {
- if (this.ws && this.ws.readyState === WebSocket.OPEN) {
- this.ws.send('ping')
- }
- }, 30000)
- }
-
- this.ws.onmessage = (event) => {
- try {
- if (event.data === 'pong') return
-
- const data = JSON.parse(event.data)
-
- if (data.type === 'new_message' && data.data) {
- this.addMessage(data.data)
- } else if (data.type === 'message_chunk' && data.data) {
- this.updateStreamingMessage(data.data)
- } else if (data.type === 'system_log' && data.data) {
- this.addSystemLog(data.data)
- } else if (data.type === 'system' && data.content) {
- this.addSystemLog({
- timestamp: new Date().toISOString(),
- level: 'info',
- content: data.content,
- source: 'System'
- })
- } else if (data.type === 'status_update' && data.status && this.currentForum) {
- this.currentForum.status = data.status
- this.thinking = data.status === 'running'
- }
- } catch (e) {
- console.error('[WS Global] Parse Error', e)
- }
- }
-
- this.ws.onclose = (e) => {
- console.log(`[WS Global] Closed (Code: ${e.code})`)
- this.isConnected = false
- this.clearTimers()
-
- if (!this.isManuallyClosed && this.reconnectAttempts < maxReconnectAttempts) {
- const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000)
- console.log(`[WS Global] Reconnecting in ${delay}ms...`)
-
- this.reconnectTimeout = setTimeout(() => {
- this.reconnectAttempts++
- // Recursive call via store instance?
- // We are inside action, so 'this' is store.
- // But setTimeout changes context. Need to capture 'this' or use arrow.
- this.connectWebSocket(forumId)
- }, delay)
- }
- }
-
- this.ws.onerror = () => {
- console.warn('[WS Global] Connection error')
- }
-
- } catch (e) {
- console.error('[WS Global] Connection Failed', e)
- }
- },
-
- async fetchSystemLogs(forumId: number) {
- try {
- const res = await request.get(`/forums/${forumId}/logs`)
-
- if (Array.isArray(res.data)) {
- const backendLogs = res.data as SystemLog[]
-
- // Smart Merge Strategy:
- // 1. Trust Backend Logs as base history.
- // 2. Keep Local Logs that are NOT present in Backend Logs (likely pending persistence).
-
- // Create signature set for O(1) lookup
- // Signature = timestamp + content (source/level might vary slightly but usually consistent)
- this.systemLogs = this.mergeSystemLogs(backendLogs, this.systemLogs)
-
- } else {
- // If response is invalid, keep local logs
- console.warn('Invalid logs response', res.data)
- }
-
- // Restore "thinking" or "speaking" state based on last log
- if (this.systemLogs.length > 0) {
- const lastLog = this.systemLogs[this.systemLogs.length - 1]
- if (lastLog.level === 'thought' || lastLog.content.includes('正在思考')) {
- this.thinking = true
- } else {
- this.thinking = false
- }
- }
- } catch (error) {
- console.error('Failed to fetch system logs:', error)
- }
- },
- addSystemLog(log: SystemLog) {
- this.systemLogs = this.mergeSystemLogs(this.systemLogs, [log])
- },
- updateStreamingMessage(chunk: {
- speaker_name: string,
- content: string,
- persona_id: number | null,
- moderator_id?: number | null,
- stream_id?: string,
- thought?: string | null,
- timestamp: string
- }) {
- if (!this.currentForum) return // Guard against updates when no forum loaded
-
- // Robust logic: Use stream_id if available to find the message
- // If stream_id is missing, fallback to last message match (legacy behavior)
-
- let targetMsg: Message | undefined
-
- if (chunk.stream_id) {
- targetMsg = this.messages.find(m => (m as any).stream_id === chunk.stream_id)
- } else {
- // Fallback: Check last message
- const lastMsg = this.messages[this.messages.length - 1]
- if (lastMsg && lastMsg.speaker_name === chunk.speaker_name && (lastMsg as any).isStreaming) {
- targetMsg = lastMsg
- }
- }
-
- if (targetMsg) {
- targetMsg.content += (chunk.content || '')
- // Thought usually comes with the first chunk or separately, update if present
- if (chunk.thought && !targetMsg.thought) {
- targetMsg.thought = chunk.thought
- }
- } else {
- // Start new streaming message
- const newMsg: Message = {
- id: Date.now(), // Temp ID, will be replaced by final message
- forum_id: this.currentForum?.id || 0,
- persona_id: chunk.persona_id || 0,
- moderator_id: chunk.moderator_id || null,
- speaker_name: chunk.speaker_name || 'Unknown',
- content: chunk.content || '',
- thought: chunk.thought, // Initialize thought
- timestamp: chunk.timestamp || new Date().toISOString(),
- }
- ;(newMsg as any).isStreaming = true
- ;(newMsg as any).stream_id = chunk.stream_id // Store stream_id for future chunks
- this.messages.push(newMsg)
- }
-
- // Throttle save for streaming (save every ~50 chars or just rely on manual save on exit?)
- // To be safe and meet "long time retention", let's save occasionally
- if (Math.random() < 0.1) { // 10% chance to save on chunk update
- this.saveToStorage()
- }
- },
- addMessage(msg: Message & { stream_id?: string }) {
- if (!this.currentForum) return // Guard against updates when no forum loaded
-
- // When the full message arrives (type: 'new_message'), replace the streaming one
- // Match by stream_id if available, otherwise fallback
-
- let streamingMsgIndex = -1
-
- if (msg.stream_id) {
- streamingMsgIndex = this.messages.findIndex(m => (m as any).stream_id === msg.stream_id)
- }
-
- // Fallback match if stream_id not found or not provided
- if (streamingMsgIndex === -1) {
- streamingMsgIndex = this.messages.findIndex(m => m.speaker_name === msg.speaker_name && (m as any).isStreaming)
- }
-
- if (streamingMsgIndex !== -1) {
- // Replace streaming message with the final one
- this.messages.splice(streamingMsgIndex, 1, msg)
- } else {
- // Check if message already exists by ID to prevent duplicates
- const exists = this.messages.find(m => m.id === msg.id)
- if (!exists) {
- this.messages.push(msg)
- }
- }
-
- this.saveToStorage() // Always save on full message
- // Auto-scroll logic could be triggered here or in component watcher
- },
- async fetchForums() {
- // Background update if data exists
- const isBackground = this.forums.length > 0
- if (!isBackground) {
- this.loading = true
- }
- try {
- const res = await request.get('/forums/', { params: { limit: 500 } })
- this.forums = res.data
- } catch (error) {
- console.error('Failed to fetch forums:', error)
- } finally {
- this.loading = false
- }
- },
- async fetchForum(id: number) {
- // 1. Check if ID is valid
- if (!id || isNaN(id)) {
- console.error('Invalid forum ID', id)
- this.currentForum = null
- return
- }
- // 2. Memory Cache: If we already have THIS forum loaded, just refresh it
- if (this.currentForum && this.currentForum.id === id) {
- this.refreshForumData(id)
- return
- }
- // 3. Switching or Initial Load: ALWAYS clear old data first to prevent ghosting
- this.clearForumData()
- // 4. Storage Cache: Try to load from localStorage
- if (this.loadFromStorage(id)) {
- // If loaded from storage, we can show it immediately
- // But if it's 'pending', we don't even need to refresh messages/logs
- if (this.currentForum?.status !== 'pending') {
- this.refreshForumData(id)
- }
- return
- }
- // 5. Fresh Load from Network
- this.loading = true
- try {
- // First, get the forum metadata to check status
- const forumRes = await request.get(`/forums/${id}`)
- if (!forumRes.data) throw new Error('Empty response')
-
- this.currentForum = forumRes.data
- // If forum is 'pending', it's brand new or hasn't started, no need to fetch messages/logs
- if (this.currentForum?.status !== 'pending') {
- const [messagesRes, logsRes] = await Promise.all([
- request.get(`/forums/${id}/messages`).catch(e => ({ data: [] })),
- request.get(`/forums/${id}/logs`).catch(e => ({ data: [] }))
- ])
- // Process messages
- if (Array.isArray(messagesRes.data)) {
- this.messages = messagesRes.data.filter((m: any) => m && typeof m.speaker_name === 'string')
- }
- // Process logs
- if (Array.isArray(logsRes.data)) {
- this.systemLogs = logsRes.data
- }
-
- // Restore thinking state
- this.updateThinkingState()
- } else {
- // Brand new or pending forum - ensure clean state
- this.messages = []
- this.systemLogs = []
- this.thinking = false
- }
- this.saveToStorage()
- } catch (error) {
- const status = (error as { response?: { status?: number } })?.response?.status
- if (status !== 401 && status !== 403 && status !== 404) {
- console.error(`Failed to fetch forum ${id}:`, error)
- }
- this.currentForum = null
- } finally {
- this.loading = false
- }
- },
- updateThinkingState() {
- if (this.systemLogs.length > 0) {
- const lastLog = this.systemLogs[this.systemLogs.length - 1]
- if (lastLog.level === 'thought' || lastLog.content.includes('正在思考')) {
- this.thinking = true
- } else {
- this.thinking = false
- }
- } else {
- this.thinking = false
- }
- },
- async refreshForumData(id: number) {
- // Background refresh logic
- try {
- const forumRes = await request.get(`/forums/${id}`).catch(e => null)
- if (!forumRes || !forumRes.data) return
- // Only update if we are still on the same forum
- if (!this.currentForum || this.currentForum.id !== id) return
-
- this.currentForum = { ...this.currentForum, ...forumRes.data }
- // Only fetch messages/logs if NOT pending
- if (this.currentForum.status !== 'pending') {
- const [messagesRes, logsRes] = await Promise.all([
- request.get(`/forums/${id}/messages`).catch(e => null),
- request.get(`/forums/${id}/logs`).catch(e => null)
- ])
- if (messagesRes && Array.isArray(messagesRes.data)) {
- this.messages = messagesRes.data.filter((m: any) => m && typeof m.speaker_name === 'string')
- }
- if (logsRes && Array.isArray(logsRes.data)) {
- this.systemLogs = logsRes.data
- this.updateThinkingState()
- }
- }
- this.saveToStorage()
- } catch (e) {
- console.error('Background fetch failed', e)
- }
- },
- async fetchMessages(forumId: number) {
- try {
- const res = await request.get(`/forums/${forumId}/messages`)
- // Validate array
- if (Array.isArray(res.data)) {
- // Filter invalid messages
- this.messages = res.data.filter((m: any) => m && typeof m.speaker_name === 'string')
- } else {
- console.warn('Invalid messages format', res.data)
- this.messages = []
- }
-
- await this.fetchSystemLogs(forumId)
-
- // Save after successful fetch to keep storage fresh
- if (this.currentForum && this.currentForum.id === forumId) {
- this.saveToStorage()
- }
- } catch (error) {
- console.error(`Failed to fetch messages for forum ${forumId}:`, error)
- // Do not clear messages on error to keep cache displayed
- }
- },
- async fetchModerators() {
- try {
- const res = await request.get('/moderators/')
- this.moderators = res.data
- } catch (error) {
- console.error('Failed to fetch moderators:', error)
- this.moderators = []
- }
- },
- async createForum(topic: string, participantIds: number[], duration: number, moderatorId?: number) {
- this.loading = true
- try {
- const normalizedParticipantIds = Array.from(
- new Set(
- participantIds
- .map(id => Number(id))
- .filter(id => Number.isInteger(id) && id > 0)
- )
- )
- const res = await request.post('/forums/', {
- topic,
- participant_ids: normalizedParticipantIds,
- moderator_id: moderatorId,
- duration_minutes: duration
- })
- message.success('论坛创建成功')
- // Optimistic update: Add to list immediately
- this.forums.unshift(res.data)
- return res.data
- } catch (error) {
- console.error('Failed to create forum:', error)
- throw error
- } finally {
- this.loading = false
- }
- },
- async startForum(id: number) {
- try {
- const res = await request.post(`/forums/${id}/start`)
- message.success('论坛已开始')
- if (this.currentForum && this.currentForum.id === id) {
- this.currentForum.status = 'running'
- if (res.data?.start_time) this.currentForum.start_time = res.data.start_time
- if (res.data?.duration_minutes) this.currentForum.duration_minutes = res.data.duration_minutes
- }
- } catch (error) {
- console.error('Failed to start forum:', error)
- message.error('启动失败')
- }
- },
- async deleteForum(id: number) {
- // Optimistic update: Remove locally first
- const previousForums = [...this.forums]
- const previousCurrentForum = this.currentForum
-
- this.forums = this.forums.filter(f => f.id !== id)
-
- // Clear memory if deleting current
- if (this.currentForum && this.currentForum.id === id) {
- this.clearForumData()
- }
-
- try {
- await request.delete(`/forums/${id}`)
- // Clean storage
- localStorage.removeItem(`forum_data_${id}`)
- } catch (error) {
- console.error('Failed to delete forum:', error)
- // Rollback on failure
- this.forums = previousForums
- this.currentForum = previousCurrentForum
- // Also restore storage if needed? (Too complex, assume delete failure implies data still exists)
- throw error
- }
- },
- // New Action: Stop Forum
- async stopForum(id: number) {
- try {
- await request.post(`/forums/${id}/stop`)
- // Update local status if applicable
- const f = this.forums.find(f => f.id === id)
- if (f) f.status = 'closed'
- if (this.currentForum && this.currentForum.id === id) {
- this.currentForum.status = 'closed'
- }
- message.success('论坛已停止')
- } catch (error) {
- console.error('Failed to stop forum:', error)
- message.error('停止失败')
- }
- },
- leaveForum() {
- // Save current state before leaving
- this.saveToStorage()
-
- // Don't clear messages immediately to prevent flicker when switching
- // But clearing currentForum is fine
- // Actually, clearing messages is safer to avoid showing wrong forum data
- // MODIFIED: Don't clear if we are just navigating back but might return (keep cache)
- // But user asked "即使用户点击返回,页面也不会卸载,以便不重复读取"
- // So we should NOT clear messages here.
-
- // this.messages = [] // Keep messages in store
- // this.systemLogs = [] // Keep logs
-
- // But if we enter ANOTHER forum, we must clear.
- // fetchForum() handles clearing: `this.currentForum = null` and re-fetching.
-
- // However, we should stop thinking state?
- this.thinking = false
- this.loading = false
-
- // We only clear currentForum ref but keep data until overwritten?
- // No, if we clear currentForum, UI might break if it relies on it.
- // Let's keep currentForum too, but maybe mark as "inactive"?
- // The requirement says: "user current executing forum, page won't unload".
- // This implies keeping the state.
-
- // So leaveForum should be minimal.
- },
-
- // New Action: Clear Forum Data (explicitly called when needed, e.g. entering NEW forum)
- clearForumData() {
- this.messages = []
- this.systemLogs = []
- this.currentForum = null
- this.thinking = false
- }
- }
- })
|