import { defineStore } from 'pinia' import type { RawMessage, AttachedFileMeta, ToolStatus, ChatSession, } from '@src/pages/home/model/ChatModel' import { extractText, isToolOnlyMessage, isToolResultRole, isInternalMessage } from '@src/pages/home/model/ChatModel' import { hostApiFetch } from '@lib/host-api' import { gatewayRpc, onGatewayEvent } from '@lib/gateway-client' import { useProviderStore } from '@stores/providers' import { IPC_EVENTS, CONFIG_KEYS } from '@lib/constants' // ── Constants ─────────────────────────────────────────────────── const DEFAULT_SESSION_KEY = 'agent:main:main' // const DEFAULT_CANONICAL_PREFIX = 'agent:main' const DEFAULT_AGENT_ID = '1953462165250859011' const SESSION_LOAD_MIN_INTERVAL_MS = 1_200 const HISTORY_LOAD_MIN_INTERVAL_MS = 800 const HISTORY_POLL_SILENCE_WINDOW_MS = 2_500 const CHAT_EVENT_DEDUPE_TTL_MS = 30_000 const SAFETY_TIMEOUT_MS = 90_000 const ERROR_RECOVERY_GRACE_MS = 15_000 // const CHAT_SEND_TIMEOUT_MS = 120_000 // ── Module-level state (timers, caches, locks) ────────────────── let _lastChatEventAt = 0 let _historyPollTimer: ReturnType | null = null let _errorRecoveryTimer: ReturnType | null = null let _loadSessionsInFlight: Promise | null = null let _lastLoadSessionsAt = 0 const _historyLoadInFlight = new Map>() const _lastHistoryLoadAtBySession = new Map() const _chatEventDedupe = new Map() const IMAGE_CACHE_MAX = 100 // ── Helpers: Timers ───────────────────────────────────────────── function clearErrorRecoveryTimer(): void { if (_errorRecoveryTimer) { clearTimeout(_errorRecoveryTimer) _errorRecoveryTimer = null } } function clearHistoryPoll(): void { if (_historyPollTimer) { clearTimeout(_historyPollTimer) _historyPollTimer = null } } // ── Helpers: Dedupe ───────────────────────────────────────────── function pruneChatEventDedupe(now: number): void { for (const [key, ts] of _chatEventDedupe.entries()) { if (now - ts > CHAT_EVENT_DEDUPE_TTL_MS) { _chatEventDedupe.delete(key) } } } function buildChatEventDedupeKey(eventState: string, event: Record): string | null { const runId = event.runId != null ? String(event.runId) : '' const sessionKey = event.sessionKey != null ? String(event.sessionKey) : '' const seq = event.seq != null ? String(event.seq) : '' if (runId || sessionKey || seq || eventState) { return [runId, sessionKey, seq, eventState].join('|') } const msg = event.message && typeof event.message === 'object' ? (event.message as Record) : null if (msg) { const messageId = msg.id != null ? String(msg.id) : '' const stopReason = (msg.stopReason ?? msg.stop_reason) as string | undefined if (messageId || stopReason) { return `msg|${messageId}|${String(stopReason ?? '')}|${eventState}` } } return null } function isDuplicateChatEvent(eventState: string, event: Record): boolean { const key = buildChatEventDedupeKey(eventState, event) if (!key) return false const now = Date.now() pruneChatEventDedupe(now) if (_chatEventDedupe.has(key)) return true _chatEventDedupe.set(key, now) return false } // ── Helpers: Image Cache ──────────────────────────────────────── let _imageCache: Map = new Map() let _imageCacheInitialized = false async function initImageCache(): Promise { if (_imageCacheInitialized) return _imageCache = await loadImageCache() _imageCacheInitialized = true } async function loadImageCache(): Promise> { try { const raw = await window.api.invoke(IPC_EVENTS.GET_CONFIG, CONFIG_KEYS.IMAGE_CACHE) if (Array.isArray(raw)) { return new Map(raw as Array<[string, AttachedFileMeta]>) } } catch { /* ignore */ } return new Map() } async function saveImageCache(cache: Map): Promise { try { const entries = Array.from(cache.entries()) const trimmed = entries.length > IMAGE_CACHE_MAX ? entries.slice(entries.length - IMAGE_CACHE_MAX) : entries await window.api.invoke(IPC_EVENTS.SET_CONFIG, CONFIG_KEYS.IMAGE_CACHE, trimmed) } catch { /* ignore */ } } // ── Helpers: Timestamp ────────────────────────────────────────── function toMs(ts: number): number { return ts < 1e12 ? ts * 1000 : ts } // ── Helpers: Session keys ─────────────────────────────────────── function getAgentIdFromSessionKey(sessionKey: string): string { if (!sessionKey.startsWith('agent:')) return DEFAULT_AGENT_ID const parts = sessionKey.split(':') return parts[1] || DEFAULT_AGENT_ID } // function buildFallbackMainSessionKey(agentId: string): string { // return `agent:${agentId}:main` // } function ensureSessionEntry(sessions: ChatSession[], sessionKey: string): ChatSession[] { if (sessions.some((s) => s.key === sessionKey)) return sessions return [...sessions, { key: sessionKey, displayName: sessionKey }] } function clearSessionEntryFromMap>(entries: T, sessionKey: string): T { return Object.fromEntries(Object.entries(entries).filter(([k]) => k !== sessionKey)) as T } function buildSessionSwitchPatch( state: Pick< ChatState, 'currentSessionKey' | 'messages' | 'sessions' | 'sessionLabels' | 'sessionLastActivity' >, nextSessionKey: string, ): Partial { const leavingEmpty = !state.currentSessionKey.endsWith(':main') && state.messages.length === 0 && !state.sessionLastActivity[state.currentSessionKey] && !state.sessionLabels[state.currentSessionKey] const nextSessions = leavingEmpty ? state.sessions.filter((s) => s.key !== state.currentSessionKey) : state.sessions return { currentSessionKey: nextSessionKey, currentAgentId: getAgentIdFromSessionKey(nextSessionKey), sessions: ensureSessionEntry(nextSessions, nextSessionKey), sessionLabels: leavingEmpty ? clearSessionEntryFromMap(state.sessionLabels, state.currentSessionKey) : state.sessionLabels, sessionLastActivity: leavingEmpty ? clearSessionEntryFromMap(state.sessionLastActivity, state.currentSessionKey) : state.sessionLastActivity, messages: [], streamingText: '', streamingMessage: null, streamingTools: [], activeRunId: null, error: null, pendingFinal: false, lastUserMessageAt: null, pendingToolImages: [], } } // ── Helpers: Tool status ──────────────────────────────────────── function normalizeToolStatus(rawStatus: unknown, fallback: 'running' | 'completed'): ToolStatus['status'] { const status = typeof rawStatus === 'string' ? rawStatus.toLowerCase() : '' if (status === 'error' || status === 'failed') return 'error' if (status === 'completed' || status === 'success' || status === 'done') return 'completed' return fallback } function mergeToolStatus(existing: ToolStatus['status'], incoming: ToolStatus['status']): ToolStatus['status'] { const order: Record = { running: 0, completed: 1, error: 2 } return order[incoming] >= order[existing] ? incoming : existing } function upsertToolStatuses(current: ToolStatus[], updates: ToolStatus[]): ToolStatus[] { if (updates.length === 0) return current const next = [...current] for (const update of updates) { const key = update.toolCallId || update.id || update.name if (!key) continue const index = next.findIndex((t) => (t.toolCallId || t.id || t.name) === key) if (index === -1) { next.push(update) continue } const existing = next[index] next[index] = { ...existing, ...update, name: update.name || existing.name, status: mergeToolStatus(existing.status, update.status), durationMs: update.durationMs ?? existing.durationMs, summary: update.summary ?? existing.summary, updatedAt: update.updatedAt || existing.updatedAt, } } return next } function collectToolUpdates(message: unknown): ToolStatus[] { if (!message || typeof message !== 'object') return [] const msg = message as Record const updates: ToolStatus[] = [] // zn-ai specific: toolCall from finish event const toolCall = msg.toolCall if (toolCall && typeof toolCall === 'object') { const tc = toolCall as Record updates.push({ id: String(tc.id || tc.name || 'tool'), toolCallId: typeof tc.id === 'string' ? tc.id : undefined, name: String(tc.name || 'tool'), status: normalizeToolStatus(tc.status, 'completed'), updatedAt: Date.now(), }) } // Anthropic format in content blocks const content = msg.content if (Array.isArray(content)) { for (const block of content as Array>) { if ((block.type === 'tool_use' || block.type === 'toolCall') && block.name) { updates.push({ id: String(block.id || block.name), toolCallId: typeof block.id === 'string' ? block.id : undefined, name: String(block.name), status: 'running', updatedAt: Date.now(), }) } } } return updates } // ── Helpers: File staging fallbacks ───────────────────────────── export async function stageFiles(filePaths: string[]): Promise> { // Try Host API first (ClawX-compatible) try { const result = await hostApiFetch>('/api/files/stage-paths', { method: 'POST', body: JSON.stringify({ filePaths }), }) if (Array.isArray(result) && result.length > 0) return result } catch { /* fallback */ } // Fallback: return local paths without actual staging return filePaths.map((p) => { const fileName = p.split(/[\\/]/).pop() || 'file' const ext = fileName.split('.').pop()?.toLowerCase() || '' const mimeMap: Record = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', bmp: 'image/bmp', svg: 'image/svg+xml', pdf: 'application/pdf', txt: 'text/plain', md: 'text/markdown', csv: 'text/csv', mp4: 'video/mp4', mov: 'video/quicktime', mp3: 'audio/mpeg', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', } return { id: crypto.randomUUID(), fileName, mimeType: mimeMap[ext] || 'application/octet-stream', fileSize: 0, stagedPath: p, preview: null, } }) } export async function stageBuffer(base64: string, fileName: string, mimeType: string): Promise<{ id: string fileName: string mimeType: string fileSize: number stagedPath: string preview: string | null }> { try { const result = await hostApiFetch<{ id: string fileName: string mimeType: string fileSize: number stagedPath: string preview: string | null }>('/api/files/stage-buffer', { method: 'POST', body: JSON.stringify({ base64, fileName, mimeType }), }) if (result && result.stagedPath) return result } catch { /* fallback */ } const dataUrl = `data:${mimeType};base64,${base64}` return { id: crypto.randomUUID(), fileName, mimeType, fileSize: Math.ceil(base64.length * 0.75), stagedPath: dataUrl, preview: mimeType.startsWith('image/') ? dataUrl : null, } } // ── Helpers: Convert zn-ai API types to RawMessage ────────────── function convertSessionMessageToRawMessage(msg: any): RawMessage { const role = msg.role === 'user' ? 'user' : 'assistant' return { role, content: msg.content || '', timestamp: msg.timestamp ? toMs(msg.timestamp) : msg.created_at ? new Date(msg.created_at).getTime() : Date.now(), id: msg.message_id || msg.id || `${role}-${Date.now()}`, } } function convertSessionRecordToChatSession(item: any): ChatSession { return { key: item.session_id || item.conversationId, displayName: item.title || item.conversationTitle || 'New Chat', updatedAt: item.updated_at ? new Date(item.updated_at).getTime() : Date.now(), } } // ── Store Interface ───────────────────────────────────────────── export interface ChatState { messages: RawMessage[] loading: boolean error: string | null sending: boolean activeRunId: string | null streamingText: string streamingMessage: unknown | null streamingTools: ToolStatus[] pendingFinal: boolean lastUserMessageAt: number | null pendingToolImages: AttachedFileMeta[] sessions: ChatSession[] currentSessionKey: string currentAgentId: string sessionLabels: Record sessionLastActivity: Record showThinking: boolean thinkingLevel: string | null gatewayStatus: 'connected' | 'disconnected' | 'reconnecting' // Actions subscribeToGateway: () => void loadSessions: () => Promise switchSession: (key: string) => void newSession: () => Promise deleteSession: (key: string) => Promise cleanupEmptySession: () => void loadHistory: (quiet?: boolean) => Promise sendMessage: ( text: string, attachments?: Array<{ fileName: string; mimeType: string; fileSize: number; stagedPath: string; preview: string | null }>, targetAgentId?: string | null, ) => Promise abortRun: () => Promise handleChatEvent: (event: Record) => void toggleThinking: () => void refresh: () => Promise clearError: () => void } export const useChatStore = defineStore('chat', { state: () => ({ messages: [] as RawMessage[], loading: false, error: null as string | null, sending: false, activeRunId: null as string | null, streamingText: '', streamingMessage: null as unknown | null, streamingTools: [] as ToolStatus[], pendingFinal: false, lastUserMessageAt: null as number | null, pendingToolImages: [] as AttachedFileMeta[], sessions: [] as ChatSession[], currentSessionKey: DEFAULT_SESSION_KEY, currentAgentId: DEFAULT_AGENT_ID, sessionLabels: {} as Record, sessionLastActivity: {} as Record, showThinking: true, thinkingLevel: null as string | null, gatewayStatus: 'disconnected' as 'connected' | 'disconnected' | 'reconnecting', }), actions: { // ── Gateway event subscription ────────────────────────────── subscribeToGateway() { if ((useChatStore as any)._gatewaySubscribed) return ;(useChatStore as any)._gatewaySubscribed = true const store = this onGatewayEvent((event) => { if (event.type === 'gateway:status') { store.gatewayStatus = event.status return } // Map gateway events to the existing handleChatEvent format const sessionKey = event.sessionKey if (sessionKey && sessionKey !== store.currentSessionKey) return let mappedEvent: Record | null = null switch (event.type) { case 'chat:delta': mappedEvent = { state: 'delta', runId: event.runId, message: { role: 'assistant', content: event.delta, }, } break case 'chat:final': mappedEvent = { state: 'final', runId: event.runId, message: event.message, } break case 'chat:error': mappedEvent = { state: 'error', runId: event.runId, errorMessage: event.error, } break case 'chat:aborted': mappedEvent = { state: 'aborted', runId: event.runId, } break } if (mappedEvent) { store.handleChatEvent(mappedEvent) } }) }, // ── Sessions ──────────────────────────────────────────────── async loadSessions() { const now = Date.now() if (_loadSessionsInFlight) { await _loadSessionsInFlight return } if (now - _lastLoadSessionsAt < SESSION_LOAD_MIN_INTERVAL_MS) return _loadSessionsInFlight = (async () => { try { // Load local sessions from Gateway const localKeys = await gatewayRpc('session.list') let sessions: ChatSession[] = localKeys.map((key) => ({ key, displayName: this.sessionLabels[key] || 'New Chat', updatedAt: this.sessionLastActivity[key] || Date.now(), })) // Preserve any existing non-local sessions already in state const existingNonLocal = this.sessions.filter((s) => !s.key.startsWith('local:')) sessions = [...existingNonLocal, ...sessions] const { currentSessionKey } = this let nextSessionKey = currentSessionKey || DEFAULT_SESSION_KEY if (!sessions.find((s) => s.key === nextSessionKey) && sessions.length > 0) { const hasLocalPending = this.sessions.some((s) => s.key === nextSessionKey) if (!hasLocalPending) { nextSessionKey = sessions[0].key } } const sessionsWithCurrent = !sessions.find((s) => s.key === nextSessionKey) && nextSessionKey ? [...sessions, { key: nextSessionKey, displayName: nextSessionKey }] : sessions const discoveredActivity = Object.fromEntries( sessionsWithCurrent .filter((s) => typeof s.updatedAt === 'number' && Number.isFinite(s.updatedAt)) .map((s) => [s.key, s.updatedAt!]), ) this.sessions = sessionsWithCurrent this.currentSessionKey = nextSessionKey this.currentAgentId = getAgentIdFromSessionKey(nextSessionKey) this.sessionLastActivity = { ...this.sessionLastActivity, ...discoveredActivity, } if (currentSessionKey !== nextSessionKey) { await this.loadHistory(true) } // Background load labels for local sessions via Gateway history const localSessionsToLabel = sessionsWithCurrent.filter( (s) => s.key.startsWith('local:') && !this.sessionLabels[s.key] ) if (localSessionsToLabel.length > 0) { void Promise.all( localSessionsToLabel.map(async (session) => { try { const msgs = await gatewayRpc('chat.history', { sessionKey: session.key, limit: 50, }) const firstUser = msgs.find((m) => m.role === 'user') if (firstUser) { const labelText = extractText(firstUser).trim() if (labelText) { const truncated = labelText.length > 50 ? `${labelText.slice(0, 50)}…` : labelText this.sessionLabels = { ...this.sessionLabels, [session.key]: truncated, } } } const lastMsg = msgs[msgs.length - 1] if (lastMsg?.timestamp) { this.sessionLastActivity = { ...this.sessionLastActivity, [session.key]: lastMsg.timestamp, } } } catch { // ignore } }), ) } } catch (err) { console.warn('Failed to load sessions:', err) } finally { _lastLoadSessionsAt = Date.now() } })() try { await _loadSessionsInFlight } finally { _loadSessionsInFlight = null } }, switchSession(key: string) { if (key === this.currentSessionKey) return clearHistoryPoll() const patch = buildSessionSwitchPatch(this, key) Object.assign(this, patch) this.loadHistory() }, async newSession() { const { currentSessionKey, messages, sessionLastActivity, sessionLabels, sessions } = this const leavingEmpty = !currentSessionKey.endsWith(':main') && messages.length === 0 && !sessionLastActivity[currentSessionKey] && !sessionLabels[currentSessionKey] const providerStore = useProviderStore() if (!providerStore.defaultAccountId) { this.error = '请先前往模型管理页面配置并设置一个默认模型' return } const newKey = `local:${providerStore.defaultAccountId}:${crypto.randomUUID()}` this.currentAgentId = 'local' const newSessionEntry: ChatSession = { key: newKey, displayName: 'New Chat' } this.currentSessionKey = newKey this.sessions = [ ...(leavingEmpty ? sessions.filter((s) => s.key !== currentSessionKey) : sessions), newSessionEntry, ] this.sessionLabels = leavingEmpty ? clearSessionEntryFromMap(sessionLabels, currentSessionKey) : sessionLabels this.sessionLastActivity = leavingEmpty ? clearSessionEntryFromMap(sessionLastActivity, currentSessionKey) : sessionLastActivity this.messages = [] this.streamingText = '' this.streamingMessage = null this.streamingTools = [] this.activeRunId = null this.error = null this.pendingFinal = false this.lastUserMessageAt = null this.pendingToolImages = [] }, async deleteSession(key: string) { const { currentSessionKey, sessions } = this const remaining = sessions.filter((s) => s.key !== key) if (currentSessionKey === key) { const next = remaining[0] this.sessions = remaining this.sessionLabels = clearSessionEntryFromMap(this.sessionLabels, key) this.sessionLastActivity = clearSessionEntryFromMap(this.sessionLastActivity, key) this.messages = [] this.streamingText = '' this.streamingMessage = null this.streamingTools = [] this.activeRunId = null this.error = null this.pendingFinal = false this.lastUserMessageAt = null this.pendingToolImages = [] this.currentSessionKey = next?.key ?? DEFAULT_SESSION_KEY this.currentAgentId = getAgentIdFromSessionKey(next?.key ?? DEFAULT_SESSION_KEY) if (next) { await this.loadHistory() } } else { this.sessions = remaining this.sessionLabels = clearSessionEntryFromMap(this.sessionLabels, key) this.sessionLastActivity = clearSessionEntryFromMap(this.sessionLastActivity, key) } }, cleanupEmptySession() { const { currentSessionKey, messages, sessionLastActivity, sessionLabels } = this const isEmptyNonMain = !currentSessionKey.endsWith(':main') && messages.length === 0 && !sessionLastActivity[currentSessionKey] && !sessionLabels[currentSessionKey] if (!isEmptyNonMain) return this.sessions = this.sessions.filter((s) => s.key !== currentSessionKey) this.sessionLabels = clearSessionEntryFromMap(sessionLabels, currentSessionKey) this.sessionLastActivity = clearSessionEntryFromMap(sessionLastActivity, currentSessionKey) }, // ── History ─────────────────────────────────────────────────── async loadHistory(quiet = false) { const { currentSessionKey } = this if (!currentSessionKey || currentSessionKey === DEFAULT_SESSION_KEY) { this.messages = [] this.loading = false return } const existingLoad = _historyLoadInFlight.get(currentSessionKey) if (existingLoad) { await existingLoad return } const lastLoadAt = _lastHistoryLoadAtBySession.get(currentSessionKey) || 0 if (quiet && Date.now() - lastLoadAt < HISTORY_LOAD_MIN_INTERVAL_MS) return if (!quiet) this.loading = true let loadingTimedOut = false const loadingSafetyTimer = quiet ? null : setTimeout(() => { loadingTimedOut = true this.loading = false }, 15_000) const loadPromise = (async () => { try { const messages = await gatewayRpc('chat.history', { sessionKey: currentSessionKey, limit: 50, }) if (this.currentSessionKey !== currentSessionKey) return // Preserve optimistic user message during active send let finalMessages = messages const userMsgAt = this.lastUserMessageAt if (this.sending && userMsgAt) { const hasRecentUser = messages.some( (m) => m.role === 'user' && m.timestamp && Math.abs(m.timestamp - userMsgAt) < 5000, ) if (!hasRecentUser) { const optimistic = [...this.messages].reverse().find( (m) => m.role === 'user' && m.timestamp && Math.abs(m.timestamp - userMsgAt) < 5000, ) if (optimistic) { finalMessages = [...messages, optimistic] } } } this.messages = finalMessages this.loading = false // Update session label from first user message const isMainSession = currentSessionKey.endsWith(':main') if (!isMainSession) { const firstUserMsg = finalMessages.find((m) => m.role === 'user') if (firstUserMsg) { const labelText = extractText(firstUserMsg).trim() if (labelText) { const truncated = labelText.length > 50 ? `${labelText.slice(0, 50)}…` : labelText this.sessionLabels = { ...this.sessionLabels, [currentSessionKey]: truncated, } } } } // Update last activity const lastMsg = finalMessages[finalMessages.length - 1] if (lastMsg?.timestamp) { this.sessionLastActivity = { ...this.sessionLastActivity, [currentSessionKey]: lastMsg.timestamp, } } } catch (err) { console.warn('Failed to load chat history:', err) if (!quiet) this.error = String(err) this.loading = false } })() _historyLoadInFlight.set(currentSessionKey, loadPromise) try { await loadPromise } finally { if (loadingSafetyTimer) clearTimeout(loadingSafetyTimer) if (!loadingTimedOut) { _lastHistoryLoadAtBySession.set(currentSessionKey, Date.now()) } const active = _historyLoadInFlight.get(currentSessionKey) if (active === loadPromise) { _historyLoadInFlight.delete(currentSessionKey) } } }, // ── Send message ────────────────────────────────────────────── async sendMessage( text: string, attachments?: Array<{ fileName: string mimeType: string fileSize: number stagedPath: string preview: string | null }>, _targetAgentId?: string | null, ) { const trimmed = text.trim() if (!trimmed && (!attachments || attachments.length === 0)) return const providerStore = useProviderStore() const defaultAccountId = providerStore.defaultAccountId if (!defaultAccountId) { this.error = '请先前往模型管理页面配置并设置一个默认模型' return } const currentSessionKey = this.currentSessionKey let targetSessionKey = currentSessionKey // Create local session if none selected if (!currentSessionKey || currentSessionKey === DEFAULT_SESSION_KEY) { targetSessionKey = `local:${defaultAccountId}:${crypto.randomUUID()}` this.currentSessionKey = targetSessionKey this.currentAgentId = 'local' this.sessions = ensureSessionEntry(this.sessions, targetSessionKey) const newSession = this.sessions.find((s) => s.key === targetSessionKey) if (newSession) { newSession.displayName = 'New Chat' } } // Optimistic user message const nowMs = Date.now() const userMsg: RawMessage = { role: 'user', content: trimmed || (attachments?.length ? '(file attached)' : ''), timestamp: nowMs, id: crypto.randomUUID(), _attachedFiles: attachments?.map((a) => ({ fileName: a.fileName, mimeType: a.mimeType, fileSize: a.fileSize, preview: a.preview, filePath: a.stagedPath, })), } this.messages = [...this.messages, userMsg] this.sending = true this.activeRunId = null this.error = null this.streamingText = '' this.streamingMessage = null this.streamingTools = [] this.pendingFinal = false this.lastUserMessageAt = nowMs // Update session label const isFirstMessage = !this.messages.slice(0, -1).some((m) => m.role === 'user') if (!targetSessionKey.endsWith(':main') && isFirstMessage && !this.sessionLabels[targetSessionKey] && trimmed) { const truncated = trimmed.length > 50 ? `${trimmed.slice(0, 50)}…` : trimmed this.sessionLabels = { ...this.sessionLabels, [targetSessionKey]: truncated, } } this.sessionLastActivity = { ...this.sessionLastActivity, [targetSessionKey]: nowMs, } // Start safety timeout and history poll _lastChatEventAt = Date.now() clearHistoryPoll() clearErrorRecoveryTimer() const POLL_START_DELAY = 3_000 const POLL_INTERVAL = 4_000 const pollHistory = () => { if (!this.sending) { clearHistoryPoll() return } if (this.streamingMessage) { _historyPollTimer = setTimeout(pollHistory, POLL_INTERVAL) return } if (Date.now() - _lastChatEventAt < HISTORY_POLL_SILENCE_WINDOW_MS) { _historyPollTimer = setTimeout(pollHistory, POLL_INTERVAL) return } this.loadHistory(true) _historyPollTimer = setTimeout(pollHistory, POLL_INTERVAL) } _historyPollTimer = setTimeout(pollHistory, POLL_START_DELAY) const checkStuck = () => { if (!this.sending) return if (this.streamingMessage || this.streamingText) return if (this.pendingFinal) { setTimeout(checkStuck, 10_000) return } if (Date.now() - _lastChatEventAt < SAFETY_TIMEOUT_MS) { setTimeout(checkStuck, 10_000) return } clearHistoryPoll() this.error = '未收到模型响应,请检查网络或稍后重试' this.sending = false this.activeRunId = null this.lastUserMessageAt = null } setTimeout(checkStuck, 30_000) // Send via Gateway try { // Cache image attachments if (attachments && attachments.length > 0) { await initImageCache() for (const a of attachments) { _imageCache.set(a.stagedPath, { fileName: a.fileName, mimeType: a.mimeType, fileSize: a.fileSize, preview: a.preview, }) } await saveImageCache(_imageCache) } let messageContent = trimmed if (attachments && attachments.length > 0) { const refs = attachments .map((a) => `[media attached: ${a.fileName} (${a.mimeType}) | ${a.stagedPath}]`) .join('\n') messageContent = messageContent ? `${messageContent}\n\n${refs}` : refs } const { runId } = await gatewayRpc<{ runId: string }>('chat.send', { sessionKey: targetSessionKey, message: { role: 'user', content: messageContent }, options: { providerAccountId: defaultAccountId, }, }) this.activeRunId = runId } catch (err) { clearHistoryPoll() this.error = String(err) this.sending = false this.activeRunId = null this.lastUserMessageAt = null } }, // ── Abort active run ────────────────────────────────────────── async abortRun() { clearHistoryPoll() clearErrorRecoveryTimer() const { currentSessionKey } = this this.sending = false this.streamingText = '' this.streamingMessage = null this.pendingFinal = false this.lastUserMessageAt = null this.pendingToolImages = [] this.streamingTools = [] this.activeRunId = null try { if (currentSessionKey?.startsWith('local:')) { await gatewayRpc('chat.abort', { sessionKey: currentSessionKey }) } } catch (err) { this.error = String(err) } }, // ── Handle incoming chat events ─────────────────────────────── handleChatEvent(event: Record) { const runId = String(event.runId || '') const eventState = String(event.state || '') const eventSessionKey = event.sessionKey != null ? String(event.sessionKey) : null const { activeRunId, currentSessionKey } = this if (eventSessionKey != null && eventSessionKey !== currentSessionKey) return if (activeRunId && runId && runId !== activeRunId) return if (isDuplicateChatEvent(eventState, event)) return _lastChatEventAt = Date.now() // Infer state if missing let resolvedState = eventState if (!resolvedState && event.message && typeof event.message === 'object') { const msg = event.message as Record const stopReason = (msg.stopReason ?? msg.stop_reason) as string | undefined if (stopReason) { resolvedState = 'final' } else if (msg.role || msg.content) { resolvedState = 'delta' } } const hasUsefulData = resolvedState === 'delta' || resolvedState === 'final' || resolvedState === 'error' || resolvedState === 'aborted' if (hasUsefulData) { clearHistoryPoll() if (!this.sending && runId) { this.sending = true this.activeRunId = runId this.error = null } } switch (resolvedState) { case 'started': { if (!this.sending && runId) { this.sending = true this.activeRunId = runId this.error = null } break } case 'delta': { clearErrorRecoveryTimer() if (this.error) this.error = null const updates = collectToolUpdates(event.message) const deltaMsg = event.message as RawMessage | undefined this.streamingMessage = (() => { if (!deltaMsg) return this.streamingMessage if (isToolResultRole(deltaMsg.role)) return this.streamingMessage const prev = (this.streamingMessage as RawMessage | null) || { role: 'assistant', content: '', timestamp: Date.now() / 1000, } const prevContent = typeof prev.content === 'string' ? prev.content : extractText(prev) const deltaContent = typeof deltaMsg.content === 'string' ? deltaMsg.content : extractText(deltaMsg) return { ...prev, content: prevContent + deltaContent, } })() if (updates.length > 0) { this.streamingTools = upsertToolStatuses(this.streamingTools, updates) } break } case 'final': { clearErrorRecoveryTimer() if (this.error) this.error = null const finalMsg = event.message as RawMessage | undefined if (finalMsg) { // Append any remaining delta content to streamingMessage first if (this.streamingMessage && typeof finalMsg.content === 'string') { const stream = this.streamingMessage as RawMessage const streamContent = typeof stream.content === 'string' ? stream.content : extractText(stream) this.streamingMessage = { ...stream, content: streamContent + finalMsg.content, } } const updates = collectToolUpdates(finalMsg) const composed = this.streamingMessage as RawMessage | null const msgId = finalMsg.id || `run-${this.activeRunId || Date.now()}` const hasOutput = !!extractText(composed || finalMsg).trim() const toolOnly = isToolOnlyMessage(composed || finalMsg) const pendingImgs = this.pendingToolImages const msgWithMeta: RawMessage = { ...(composed || finalMsg), role: (finalMsg.role || 'assistant') as RawMessage['role'], id: msgId, question: finalMsg.question, toolCall: finalMsg.toolCall, _attachedFiles: pendingImgs.length > 0 ? [...(finalMsg._attachedFiles || []), ...pendingImgs] : finalMsg._attachedFiles, } const alreadyExists = this.messages.some((m) => m.id === msgId) if (!alreadyExists) { this.messages = [...this.messages, msgWithMeta] } this.streamingText = '' this.streamingMessage = null this.pendingFinal = !hasOutput || toolOnly this.pendingToolImages = [] this.streamingTools = hasOutput ? [] : upsertToolStatuses(this.streamingTools, updates) if (hasOutput && !toolOnly) { clearHistoryPoll() this.sending = false this.activeRunId = null this.lastUserMessageAt = null // Quiet reload history to get complete data this.loadHistory(true) } } else { this.streamingText = '' this.streamingMessage = null this.pendingFinal = true } break } case 'error': { const errorMsg = String(event.errorMessage || 'An error occurred') const wasSending = this.sending // Snapshot current streaming message const currentStream = this.streamingMessage as RawMessage | null if (currentStream && (currentStream.role === 'assistant' || !currentStream.role)) { const snapId = currentStream.id || `error-snap-${Date.now()}` if (!this.messages.some((m) => m.id === snapId)) { this.messages = [ ...this.messages, { ...currentStream, role: 'assistant', id: snapId }, ] } } this.error = errorMsg this.streamingText = '' this.streamingMessage = null this.streamingTools = [] this.pendingFinal = false this.pendingToolImages = [] if (wasSending) { clearErrorRecoveryTimer() _errorRecoveryTimer = setTimeout(() => { _errorRecoveryTimer = null if (this.sending && !this.streamingMessage) { clearHistoryPoll() this.sending = false this.activeRunId = null this.lastUserMessageAt = null this.loadHistory(true) } }, ERROR_RECOVERY_GRACE_MS) } else { clearHistoryPoll() this.sending = false this.activeRunId = null this.lastUserMessageAt = null } break } case 'aborted': { clearHistoryPoll() clearErrorRecoveryTimer() this.sending = false this.activeRunId = null this.streamingText = '' this.streamingMessage = null this.streamingTools = [] this.pendingFinal = false this.lastUserMessageAt = null this.pendingToolImages = [] break } default: { if (this.sending && event.message && typeof event.message === 'object') { const updates = collectToolUpdates(event.message) this.streamingMessage = event.message ?? this.streamingMessage if (updates.length > 0) { this.streamingTools = upsertToolStatuses(this.streamingTools, updates) } } break } } }, toggleThinking() { this.showThinking = !this.showThinking }, async refresh() { await Promise.all([this.loadHistory(), this.loadSessions()]) }, clearError() { this.error = null }, }, })