1207 lines
42 KiB
TypeScript
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
|
|
},
|
|
},
|
|
})
|