Files
zn-ai/src/stores/chat.ts

1207 lines
42 KiB
TypeScript

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<typeof setTimeout> | null = null
let _errorRecoveryTimer: ReturnType<typeof setTimeout> | null = null
let _loadSessionsInFlight: Promise<void> | null = null
let _lastLoadSessionsAt = 0
const _historyLoadInFlight = new Map<string, Promise<void>>()
const _lastHistoryLoadAtBySession = new Map<string, number>()
const _chatEventDedupe = new Map<string, number>()
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, unknown>): 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<string, unknown>)
: 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<string, unknown>): 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<string, AttachedFileMeta> = new Map()
let _imageCacheInitialized = false
async function initImageCache(): Promise<void> {
if (_imageCacheInitialized) return
_imageCache = await loadImageCache()
_imageCacheInitialized = true
}
async function loadImageCache(): Promise<Map<string, AttachedFileMeta>> {
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<string, AttachedFileMeta>): Promise<void> {
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<T extends Record<string, unknown>>(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<ChatState> {
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<ToolStatus['status'], number> = { 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<string, unknown>
const updates: ToolStatus[] = []
// zn-ai specific: toolCall from finish event
const toolCall = msg.toolCall
if (toolCall && typeof toolCall === 'object') {
const tc = toolCall as Record<string, unknown>
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<Record<string, unknown>>) {
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<Array<{
id: string
fileName: string
mimeType: string
fileSize: number
stagedPath: string
preview: string | null
}>> {
// Try Host API first (ClawX-compatible)
try {
const result = await hostApiFetch<Array<{
id: string
fileName: string
mimeType: string
fileSize: number
stagedPath: string
preview: string | null
}>>('/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<string, string> = {
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<string, string>
sessionLastActivity: Record<string, number>
showThinking: boolean
thinkingLevel: string | null
gatewayStatus: 'connected' | 'disconnected' | 'reconnecting'
// Actions
subscribeToGateway: () => void
loadSessions: () => Promise<void>
switchSession: (key: string) => void
newSession: () => Promise<void>
deleteSession: (key: string) => Promise<void>
cleanupEmptySession: () => void
loadHistory: (quiet?: boolean) => Promise<void>
sendMessage: (
text: string,
attachments?: Array<{ fileName: string; mimeType: string; fileSize: number; stagedPath: string; preview: string | null }>,
targetAgentId?: string | null,
) => Promise<void>
abortRun: () => Promise<void>
handleChatEvent: (event: Record<string, unknown>) => void
toggleThinking: () => void
refresh: () => Promise<void>
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<string, string>,
sessionLastActivity: {} as Record<string, number>,
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<string, unknown> | 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<string[]>('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<RawMessage[]>('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<RawMessage[]>('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<string, unknown>) {
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<string, unknown>
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
},
},
})