Files
Hermes-ui/packages/client/src/stores/hermes/chat.ts
T

1052 lines
37 KiB
TypeScript
Raw Normal View History

import { startRun, streamRunEvents, type ChatMessage, type RunEvent } from '@/api/hermes/chat'
import { deleteSession as deleteSessionApi, fetchSession, fetchSessions, fetchSessionUsageSingle, type HermesMessage, type SessionDetail, type SessionSummary } from '@/api/hermes/sessions'
2026-04-11 15:59:14 +08:00
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useAppStore } from './app'
import { useProfilesStore } from './profiles'
2026-04-11 15:59:14 +08:00
export interface Attachment {
id: string
name: string
type: string
size: number
url: string
file?: File
}
2026-04-11 15:59:14 +08:00
export interface Message {
id: string
role: 'user' | 'assistant' | 'system' | 'tool'
content: string
timestamp: number
toolName?: string
toolPreview?: string
toolArgs?: string
toolResult?: string
2026-04-11 15:59:14 +08:00
toolStatus?: 'running' | 'done' | 'error'
isStreaming?: boolean
attachments?: Attachment[]
2026-04-11 15:59:14 +08:00
}
export interface Session {
2026-04-11 15:59:14 +08:00
id: string
title: string
source?: string
2026-04-11 15:59:14 +08:00
messages: Message[]
createdAt: number
updatedAt: number
model?: string
provider?: string
messageCount?: number
inputTokens?: number
outputTokens?: number
endedAt?: number | null
lastActiveAt?: number
2026-04-11 15:59:14 +08:00
}
function uid(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
}
async function uploadFiles(attachments: Attachment[]): Promise<{ name: string; path: string }[]> {
if (attachments.length === 0) return []
const formData = new FormData()
for (const att of attachments) {
if (att.file) formData.append('file', att.file, att.name)
}
const token = localStorage.getItem('hermes_api_key') || ''
const res = await fetch('/upload', {
method: 'POST',
body: formData,
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
if (!res.ok) throw new Error(`Upload failed: ${res.status}`)
const data = await res.json() as { files: { name: string; path: string }[] }
return data.files
}
function mapHermesMessages(msgs: HermesMessage[]): Message[] {
// Build lookups from assistant messages with tool_calls
const toolNameMap = new Map<string, string>()
const toolArgsMap = new Map<string, string>()
for (const msg of msgs) {
if (msg.role === 'assistant' && msg.tool_calls) {
for (const tc of msg.tool_calls) {
if (tc.id) {
if (tc.function?.name) toolNameMap.set(tc.id, tc.function.name)
if (tc.function?.arguments) toolArgsMap.set(tc.id, tc.function.arguments)
}
}
}
2026-04-11 15:59:14 +08:00
}
const result: Message[] = []
for (const msg of msgs) {
// Skip assistant messages that only contain tool_calls (no meaningful content)
if (msg.role === 'assistant' && msg.tool_calls?.length && !msg.content?.trim()) {
// Emit a tool.started message for each tool call
for (const tc of msg.tool_calls) {
result.push({
id: String(msg.id) + '_' + tc.id,
role: 'tool',
content: '',
timestamp: Math.round(msg.timestamp * 1000),
toolName: tc.function?.name || 'tool',
toolArgs: tc.function?.arguments || undefined,
toolStatus: 'done',
})
}
continue
}
// Tool result messages
if (msg.role === 'tool') {
const tcId = msg.tool_call_id || ''
const toolName = msg.tool_name || toolNameMap.get(tcId) || 'tool'
const toolArgs = toolArgsMap.get(tcId) || undefined
// Extract a short preview from the content
let preview = ''
if (msg.content) {
try {
const parsed = JSON.parse(msg.content)
preview = parsed.url || parsed.title || parsed.preview || parsed.summary || ''
} catch {
preview = msg.content.slice(0, 80)
}
}
// Find and remove the matching placeholder from tool_calls above
const placeholderIdx = result.findIndex(
m => m.role === 'tool' && m.toolName === toolName && !m.toolResult && m.id.includes('_' + tcId)
)
if (placeholderIdx !== -1) {
result.splice(placeholderIdx, 1)
}
result.push({
id: String(msg.id),
role: 'tool',
content: '',
timestamp: Math.round(msg.timestamp * 1000),
toolName,
toolArgs,
toolPreview: typeof preview === 'string' ? preview.slice(0, 100) || undefined : undefined,
toolResult: msg.content || undefined,
toolStatus: 'done',
})
continue
}
// Normal user/assistant messages
result.push({
id: String(msg.id),
role: msg.role,
content: msg.content || '',
timestamp: Math.round(msg.timestamp * 1000),
})
}
return result
2026-04-11 15:59:14 +08:00
}
function mapHermesSession(s: SessionSummary): Session {
return {
id: s.id,
title: s.title || '',
source: s.source || undefined,
messages: [],
createdAt: Math.round(s.started_at * 1000),
updatedAt: Math.round((s.last_active || s.ended_at || s.started_at) * 1000),
model: s.model,
provider: (s as any).billing_provider || '',
messageCount: s.message_count,
endedAt: s.ended_at != null ? Math.round(s.ended_at * 1000) : null,
lastActiveAt: s.last_active != null ? Math.round(s.last_active * 1000) : undefined,
}
2026-04-11 15:59:14 +08:00
}
function assistantTranscriptLength(msgs: Message[]): number {
return msgs.reduce((total, m) => total + (m.role === 'assistant' ? (m.content?.length ?? 0) : 0), 0)
}
function userTurnCount(msgs: Message[]): number {
return msgs.filter(m => m.role === 'user').length
}
function serverMessagesAreAheadOrEqual(local: Message[], server: Message[]): boolean {
const localUsers = userTurnCount(local)
const serverUsers = userTurnCount(server)
return serverUsers > localUsers
|| (serverUsers === localUsers && assistantTranscriptLength(server) >= assistantTranscriptLength(local))
}
function serverHasCaughtUpToLocalTurn(local: Message[], server: Message[]): boolean {
return userTurnCount(server) >= userTurnCount(local)
}
function applySessionMetaFromDetail(target: Session, detail: SessionDetail) {
if (detail.title) target.title = detail.title
target.endedAt = detail.ended_at != null ? Math.round(detail.ended_at * 1000) : null
target.lastActiveAt = detail.last_active != null ? Math.round(detail.last_active * 1000) : target.lastActiveAt
target.updatedAt = Math.round((detail.last_active || detail.ended_at || target.updatedAt / 1000) * 1000)
}
function applyServerMessagesIfAhead(target: Session, detail: SessionDetail): boolean {
const mapped = mapHermesMessages(detail.messages || [])
applySessionMetaFromDetail(target, detail)
if (serverMessagesAreAheadOrEqual(target.messages, mapped)) {
target.messages = mapped
return true
}
return false
}
// Cache keys for stale-while-revalidate loading of sessions / messages.
// All keys include the active profile name to isolate cache between profiles.
// Rendering from cache on boot avoids the multi-round-trip wait the user sees
// every time they open the page (esp. noticeable on mobile).
const STORAGE_KEY_PREFIX = 'hermes_active_session_'
const SESSIONS_CACHE_KEY_PREFIX = 'hermes_sessions_cache_v1_'
const LEGACY_STORAGE_KEY = 'hermes_active_session'
const LEGACY_SESSIONS_CACHE_KEY = 'hermes_sessions_cache_v1'
const IN_FLIGHT_TTL_MS = 15 * 60 * 1000 // Give up after 15 minutes
const POLL_INTERVAL_MS = 2000
const POLL_STABLE_EXITS = 3 // 3 × 2s = 6s of no change → assume run finished
const LIVE_BADGE_WINDOW_MS = 5 * 60 * 1000
// 获取当前 profile 名称,用于隔离缓存。
// 从 profiles store 的 activeProfileName(同步 localStorage)读取,
// 避免异步加载导致 chat store 初始化时拿到 null。
function getProfileName(): string {
try {
return useProfilesStore().activeProfileName || 'default'
} catch {
return 'default'
}
}
function storageKey(): string { return STORAGE_KEY_PREFIX + getProfileName() }
function sessionsCacheKey(): string { return SESSIONS_CACHE_KEY_PREFIX + getProfileName() }
function msgsCacheKey(sid: string): string { return `hermes_session_msgs_v1_${getProfileName()}_${sid}_` }
function inFlightKey(sid: string): string { return `hermes_in_flight_v1_${getProfileName()}_${sid}` }
function legacyStorageKey(): string | null { return getProfileName() === 'default' ? LEGACY_STORAGE_KEY : null }
function legacySessionsCacheKey(): string | null { return getProfileName() === 'default' ? LEGACY_SESSIONS_CACHE_KEY : null }
function legacyMsgsCacheKey(sid: string): string | null { return getProfileName() === 'default' ? `hermes_session_msgs_v1_${sid}` : null }
function legacyInFlightKey(sid: string): string | null { return getProfileName() === 'default' ? `hermes_in_flight_v1_${sid}` : null }
interface InFlightRun {
runId: string
startedAt: number
}
function loadJson<T>(key: string): T | null {
try {
const raw = localStorage.getItem(key)
return raw ? (JSON.parse(raw) as T) : null
} catch {
return null
}
}
function isQuotaExceededError(error: unknown): boolean {
if (!error || typeof error !== 'object') return false
const e = error as { name?: string, code?: number }
return e.name === 'QuotaExceededError' || e.code === 22 || e.code === 1014
}
function recoverStorageQuota() {
try {
const prefixes = [
sessionsCacheKey(),
`hermes_session_msgs_v1_${getProfileName()}_`,
`hermes_in_flight_v1_${getProfileName()}_`,
]
const legacySessions = legacySessionsCacheKey()
if (legacySessions) prefixes.push(legacySessions)
if (getProfileName() === 'default') {
prefixes.push('hermes_session_msgs_v1_')
prefixes.push('hermes_in_flight_v1_')
}
const keysToRemove: string[] = []
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (!key) continue
if (key === storageKey() || key === LEGACY_STORAGE_KEY) continue
if (prefixes.some(prefix => key.startsWith(prefix))) {
keysToRemove.push(key)
}
}
keysToRemove.forEach(key => removeItem(key))
} catch {
// ignore
}
}
function setItemBestEffort(key: string, value: string) {
try {
localStorage.setItem(key, value)
return
} catch (error) {
if (!isQuotaExceededError(error)) return
}
recoverStorageQuota()
try {
localStorage.setItem(key, value)
} catch {
// quota exceeded or private mode — ignore, cache is best-effort
}
}
function saveJson(key: string, value: unknown) {
try {
setItemBestEffort(key, JSON.stringify(value))
} catch {
// quota exceeded or private mode — ignore, cache is best-effort
}
}
function removeItem(key: string) {
try {
localStorage.removeItem(key)
} catch {
// ignore
}
}
function loadJsonWithFallback<T>(key: string, legacyKey?: string | null): T | null {
const value = loadJson<T>(key)
if (value != null) return value
if (!legacyKey) return null
return loadJson<T>(legacyKey)
}
function saveJsonWithLegacy(key: string, value: unknown, legacyKey?: string | null) {
saveJson(key, value)
if (legacyKey) removeItem(legacyKey)
}
function removeItemWithLegacy(key: string, legacyKey?: string | null) {
removeItem(key)
if (legacyKey) removeItem(legacyKey)
}
// Strip the circular `file: File` reference from attachments before caching —
// File objects don't serialize and we only need name/type/size/url for display.
function sanitizeForCache(msgs: Message[]): Message[] {
return msgs.map(m => {
if (!m.attachments?.length) return m
return {
...m,
attachments: m.attachments.map(a => ({ id: a.id, name: a.name, type: a.type, size: a.size, url: a.url })),
}
})
}
2026-04-11 15:59:14 +08:00
export const useChatStore = defineStore('chat', () => {
const sessions = ref<Session[]>([])
const activeSessionId = ref<string | null>(null)
2026-04-22 14:00:34 +08:00
const focusMessageId = ref<string | null>(null)
const streamStates = ref<Map<string, AbortController>>(new Map())
const isStreaming = computed(() => activeSessionId.value != null && streamStates.value.has(activeSessionId.value))
const isLoadingSessions = ref(false)
const sessionsLoaded = ref(false)
const isLoadingMessages = ref(false)
// tmux-like resume state: true when we recovered an in-flight run from
// localStorage after a refresh and are polling fetchSession for progress.
// UI shows the thinking indicator while this is set.
const resumingRuns = ref<Set<string>>(new Set())
const isRunActive = computed(() =>
isStreaming.value
|| (activeSessionId.value != null && resumingRuns.value.has(activeSessionId.value))
)
const pollTimers = new Map<string, ReturnType<typeof setInterval>>()
const pollSignatures = new Map<string, { sig: string, stableTicks: number }>()
2026-04-11 15:59:14 +08:00
const activeSession = ref<Session | null>(null)
const messages = computed<Message[]>(() => activeSession.value?.messages || [])
2026-04-11 15:59:14 +08:00
2026-04-19 21:51:25 +08:00
function isSessionLive(sessionId: string): boolean {
if (streamStates.value.has(sessionId) || resumingRuns.value.has(sessionId)) return true
const session = sessions.value.find(candidate => candidate.id === sessionId)
if (!session?.lastActiveAt || session.endedAt != null) return false
return Date.now() - session.lastActiveAt <= LIVE_BADGE_WINDOW_MS
2026-04-19 21:51:25 +08:00
}
function persistSessionsList() {
// Cache lightweight summaries only (messages are cached per-session).
saveJsonWithLegacy(
sessionsCacheKey(),
sessions.value.map(s => ({ ...s, messages: [] })),
legacySessionsCacheKey(),
)
}
function persistActiveMessages() {
const sid = activeSessionId.value
if (!sid) return
const s = sessions.value.find(sess => sess.id === sid)
if (s) saveJsonWithLegacy(msgsCacheKey(sid), sanitizeForCache(s.messages), legacyMsgsCacheKey(sid))
}
function markInFlight(sid: string, runId: string) {
saveJsonWithLegacy(inFlightKey(sid), { runId, startedAt: Date.now() } as InFlightRun, legacyInFlightKey(sid))
}
function clearInFlight(sid: string, runId?: string): boolean {
if (runId && readInFlight(sid)?.runId !== runId) return false
removeItemWithLegacy(inFlightKey(sid), legacyInFlightKey(sid))
return true
}
function readInFlight(sid: string): InFlightRun | null {
const rec = loadJsonWithFallback<InFlightRun>(inFlightKey(sid), legacyInFlightKey(sid))
if (!rec) return null
if (Date.now() - rec.startedAt > IN_FLIGHT_TTL_MS) {
removeItemWithLegacy(inFlightKey(sid), legacyInFlightKey(sid))
return null
}
return rec
}
function stopPolling(sid: string) {
const t = pollTimers.get(sid)
if (t) {
clearInterval(t)
pollTimers.delete(sid)
}
pollSignatures.delete(sid)
resumingRuns.value = new Set([...resumingRuns.value].filter(x => x !== sid))
}
// Poll fetchSession while an in-flight run is recovering. Exits when the
// server reports a terminal session and its message signature is stable for
// POLL_STABLE_EXITS ticks, TTL elapses, or the user explicitly starts streaming.
function startPolling(sid: string) {
if (pollTimers.has(sid)) return
resumingRuns.value = new Set([...resumingRuns.value, sid])
const timer = setInterval(async () => {
// If a fresh SSE stream started for this session, polling is redundant.
if (streamStates.value.has(sid)) {
stopPolling(sid)
return
}
const inFlight = readInFlight(sid)
if (!inFlight) {
stopPolling(sid)
return
}
try {
const detail = await fetchSession(sid)
if (!detail) return
const mapped = mapHermesMessages(detail.messages || [])
const target = sessions.value.find(s => s.id === sid)
if (!target) return
const serverIsCaughtUp = serverHasCaughtUpToLocalTurn(target.messages, mapped)
const serverIsAhead = serverMessagesAreAheadOrEqual(target.messages, mapped)
const serverIsTerminal = detail.ended_at != null
applySessionMetaFromDetail(target, detail)
if (serverIsAhead) {
target.messages = mapped
if (sid === activeSessionId.value) persistActiveMessages()
}
// Stability detection ONLY matters when the server has caught up to
// our latest user turn AND the session is terminal. During long tool
// calls the persisted transcript may be stable while the run is still
// active; treating that as completion is the truncation failure mode.
if (!serverIsCaughtUp || !serverIsTerminal) {
pollSignatures.delete(sid)
} else {
const last = mapped[mapped.length - 1]
const sig = `${mapped.length}|${last?.content?.slice(-40) || ''}|${last?.toolStatus || ''}`
const prev = pollSignatures.get(sid)
if (prev && prev.sig === sig) {
prev.stableTicks += 1
if (prev.stableTicks >= POLL_STABLE_EXITS) {
// Server confirms this run is terminal. Keep any longer local
// stream text if the final session export lags behind.
if (serverIsAhead) target.messages = mapped
if (sid === activeSessionId.value) persistActiveMessages()
if (clearInFlight(sid, inFlight.runId)) {
stopPolling(sid)
}
}
} else {
pollSignatures.set(sid, { sig, stableTicks: 0 })
}
}
} catch {
// transient network error — ignore, next tick tries again
}
}, POLL_INTERVAL_MS)
pollTimers.set(sid, timer)
}
async function loadSessions() {
isLoadingSessions.value = true
try {
// 从 profile 对应的缓存中恢复,实现 instant render
const cachedSessions = loadJsonWithFallback<Session[]>(sessionsCacheKey(), legacySessionsCacheKey())
if (cachedSessions?.length) {
sessions.value = cachedSessions
const savedId = localStorage.getItem(storageKey()) || (legacyStorageKey() ? localStorage.getItem(legacyStorageKey()!) : null)
if (savedId) {
const cachedActive = cachedSessions.find(s => s.id === savedId) || null
if (cachedActive) {
const cachedMsgs = loadJsonWithFallback<Message[]>(msgsCacheKey(savedId), legacyMsgsCacheKey(savedId))
if (cachedMsgs) cachedActive.messages = cachedMsgs
activeSession.value = cachedActive
activeSessionId.value = savedId
}
}
}
const list = await fetchSessions()
const fresh = list.map(mapHermesSession)
const freshIds = new Set(fresh.map(s => s.id))
// Preserve already-loaded messages for sessions that are still present,
// so we don't blow away the active session's messages on refresh.
const msgsByIdBefore = new Map(sessions.value.map(s => [s.id, s.messages]))
for (const s of fresh) {
const prev = msgsByIdBefore.get(s.id)
if (prev && prev.length) s.messages = prev
}
// Preserve local-only sessions the server hasn't seen yet — e.g. a chat
// that was just created and whose first run is still in-flight. Without
// this, refreshing mid-run would wipe the session and fall back to
// sessions[0], which is exactly what the user reported.
// Sessions without an active in-flight run are considered deleted and
// cleaned up along with their cached messages.
const localOnly = sessions.value.filter(s => {
if (freshIds.has(s.id)) return false
if (readInFlight(s.id)) return true
// Session no longer exists on server and no active run — clean up cache
removeItemWithLegacy(msgsCacheKey(s.id), legacyMsgsCacheKey(s.id))
removeItemWithLegacy(inFlightKey(s.id), legacyInFlightKey(s.id))
return false
})
sessions.value = [...localOnly, ...fresh]
persistSessionsList()
// Restore last active session, fallback to most recent
const savedId = activeSessionId.value
const targetId = savedId && sessions.value.some(s => s.id === savedId)
? savedId
: sessions.value[0]?.id
if (targetId) {
await switchSession(targetId)
}
} catch (err) {
console.error('Failed to load sessions:', err)
} finally {
isLoadingSessions.value = false
sessionsLoaded.value = true
}
}
2026-04-11 15:59:14 +08:00
// Re-pull active session from server without retreating from longer local
// streamed text. Used on SSE drop and tab-visible events — mobile browsers
// can kill EventSource while the backend run continues.
async function refreshActiveSession(): Promise<boolean> {
const sid = activeSessionId.value
if (!sid) return false
try {
const detail = await fetchSession(sid)
if (!detail) return false
const target = sessions.value.find(s => s.id === sid)
if (!target) return false
const applied = applyServerMessagesIfAhead(target, detail)
if (applied && sid === activeSessionId.value) persistActiveMessages()
return applied
} catch (err) {
console.error('Failed to refresh active session:', err)
return false
}
}
async function reconcileSessionAfterCompletion(sid: string): Promise<void> {
try {
const detail = await fetchSession(sid)
if (!detail) return
const target = sessions.value.find(s => s.id === sid)
if (!target) return
const applied = applyServerMessagesIfAhead(target, detail)
if (applied && sid === activeSessionId.value) persistActiveMessages()
} catch (err) {
console.error('Failed to reconcile completed session:', err)
}
}
2026-04-11 15:59:14 +08:00
function createSession(): Session {
const session: Session = {
id: uid(),
title: '',
source: 'api_server',
2026-04-11 15:59:14 +08:00
messages: [],
createdAt: Date.now(),
updatedAt: Date.now(),
}
sessions.value.unshift(session)
// Persist immediately so a refresh before run.completed can still find
// this session in the cache.
persistSessionsList()
2026-04-11 15:59:14 +08:00
return session
}
2026-04-22 14:00:34 +08:00
async function switchSession(sessionId: string, focusId?: string | null) {
2026-04-11 15:59:14 +08:00
activeSessionId.value = sessionId
2026-04-22 14:00:34 +08:00
focusMessageId.value = focusId ?? null
setItemBestEffort(storageKey(), sessionId)
const legacyActiveKey = legacyStorageKey()
if (legacyActiveKey) removeItem(legacyActiveKey)
2026-04-11 15:59:14 +08:00
activeSession.value = sessions.value.find(s => s.id === sessionId) || null
if (!activeSession.value) return
// Hydrate messages from localStorage cache first (instant render), then
// revalidate from server in the background. If no cache exists, show the
// loading state while we fetch.
const hasLocalMessages = activeSession.value.messages.length > 0
if (!hasLocalMessages) {
const cachedMsgs = loadJsonWithFallback<Message[]>(msgsCacheKey(sessionId), legacyMsgsCacheKey(sessionId))
if (cachedMsgs?.length) {
activeSession.value.messages = cachedMsgs
}
}
const needsBlockingLoad = activeSession.value.messages.length === 0
if (needsBlockingLoad) isLoadingMessages.value = true
try {
const detail = await fetchSession(sessionId)
if (detail && detail.messages) {
const mapped = mapHermesMessages(detail.messages)
// Pick whichever view has more information. Simple array length
// comparison is wrong because mapHermesMessages folds tool-call-only
// assistant msgs into tool-result msgs. Also, tool boundaries can
// split one assistant turn into pre-tool and post-tool assistant
// segments, so comparing only the last assistant segment can retreat
// a fuller local transcript to stale pre-tool server text. Compare
// user-turn count plus total assistant transcript length instead.
if (serverMessagesAreAheadOrEqual(activeSession.value.messages, mapped)) {
activeSession.value.messages = mapped
}
// Update title: use Hermes title, or fallback to first user message
if (detail.title) {
activeSession.value.title = detail.title
} else if (!activeSession.value.title) {
const firstUser = (activeSession.value.messages).find(m => m.role === 'user')
if (firstUser) {
const t = firstUser.content.slice(0, 40)
activeSession.value.title = t + (firstUser.content.length > 40 ? '...' : '')
}
}
persistActiveMessages()
}
} catch (err) {
console.error('Failed to load session messages:', err)
} finally {
isLoadingMessages.value = false
}
// tmux-like resume: if this session has a recent in-flight run and we're
// not currently streaming, start polling fetchSession to pick up progress
// that happened while we were gone. Exits automatically on stability.
if (readInFlight(sessionId) && !streamStates.value.has(sessionId)) {
startPolling(sessionId)
}
// Fetch token usage for this session from web-ui DB
try {
const usage = await fetchSessionUsageSingle(sessionId)
if (usage) {
activeSession.value.inputTokens = usage.input_tokens
activeSession.value.outputTokens = usage.output_tokens
}
} catch { /* non-critical */ }
2026-04-11 15:59:14 +08:00
}
function newChat() {
if (isStreaming.value) return
const session = createSession()
// Inherit current global model
const appStore = useAppStore()
session.model = appStore.selectedModel || undefined
2026-04-11 15:59:14 +08:00
switchSession(session.id)
}
async function switchSessionModel(modelId: string, provider?: string) {
if (!activeSession.value) return
activeSession.value.model = modelId
activeSession.value.provider = provider || ''
// If provider changed, update global config too (Hermes requires it)
if (provider) {
const { useAppStore } = await import('./app')
await useAppStore().switchModel(modelId, provider)
}
}
async function deleteSession(sessionId: string) {
await deleteSessionApi(sessionId)
2026-04-11 15:59:14 +08:00
sessions.value = sessions.value.filter(s => s.id !== sessionId)
removeItemWithLegacy(msgsCacheKey(sessionId), legacyMsgsCacheKey(sessionId))
persistSessionsList()
2026-04-11 15:59:14 +08:00
if (activeSessionId.value === sessionId) {
if (sessions.value.length > 0) {
await switchSession(sessions.value[0].id)
2026-04-11 15:59:14 +08:00
} else {
const session = createSession()
switchSession(session.id)
}
}
}
function getSessionMsgs(sessionId: string): Message[] {
const s = sessions.value.find(s => s.id === sessionId)
return s?.messages || []
}
function addMessage(sessionId: string, msg: Message) {
const s = sessions.value.find(s => s.id === sessionId)
if (s) s.messages.push(msg)
}
function updateMessage(sessionId: string, id: string, update: Partial<Message>) {
const s = sessions.value.find(s => s.id === sessionId)
if (!s) return
const idx = s.messages.findIndex(m => m.id === id)
if (idx !== -1) {
s.messages[idx] = { ...s.messages[idx], ...update }
}
}
2026-04-11 15:59:14 +08:00
function updateSessionTitle(sessionId: string) {
const target = sessions.value.find(s => s.id === sessionId)
if (!target) return
if (!target.title) {
const firstUser = target.messages.find(m => m.role === 'user')
2026-04-11 15:59:14 +08:00
if (firstUser) {
const title = firstUser.attachments?.length
? firstUser.attachments.map(a => a.name).join(', ')
: firstUser.content
target.title = title.slice(0, 40) + (title.length > 40 ? '...' : '')
2026-04-11 15:59:14 +08:00
}
}
target.updatedAt = Date.now()
2026-04-11 15:59:14 +08:00
}
async function sendMessage(content: string, attachments?: Attachment[]) {
if ((!content.trim() && !(attachments && attachments.length > 0)) || isStreaming.value) return
2026-04-11 15:59:14 +08:00
if (!activeSession.value) {
const session = createSession()
switchSession(session.id)
}
// Capture session ID at send time — all callbacks use this, not activeSessionId
const sid = activeSessionId.value!
2026-04-11 15:59:14 +08:00
const userMsg: Message = {
id: uid(),
role: 'user',
content: content.trim(),
timestamp: Date.now(),
attachments: attachments && attachments.length > 0 ? attachments : undefined,
2026-04-11 15:59:14 +08:00
}
addMessage(sid, userMsg)
updateSessionTitle(sid)
// Persist immediately so a refresh before the first SSE event (e.g. the
// user closes the tab right after sending) still has the user's message
// and session title in the cache.
if (sid === activeSessionId.value) {
persistActiveMessages()
persistSessionsList()
}
2026-04-11 15:59:14 +08:00
try {
// Build conversation history from past messages
const sessionMsgs = getSessionMsgs(sid)
const history: ChatMessage[] = sessionMsgs
2026-04-11 15:59:14 +08:00
.filter(m => (m.role === 'user' || m.role === 'assistant') && m.content.trim())
.map(m => ({ role: m.role as 'user' | 'assistant' | 'system', content: m.content }))
// Upload attachments and build input with file paths
let inputText = content.trim()
if (attachments && attachments.length > 0) {
const uploaded = await uploadFiles(attachments)
const pathParts = uploaded.map(f => `[File: ${f.name}](${f.path})`)
inputText = inputText ? inputText + '\n\n' + pathParts.join('\n') : pathParts.join('\n')
}
const appStore = useAppStore()
const sessionModel = activeSession.value?.model || appStore.selectedModel
2026-04-11 15:59:14 +08:00
const run = await startRun({
input: inputText,
2026-04-11 15:59:14 +08:00
conversation_history: history,
session_id: sid,
model: sessionModel || undefined,
2026-04-11 15:59:14 +08:00
})
const runId = (run as any).run_id || (run as any).id
if (!runId) {
addMessage(sid, {
2026-04-11 15:59:14 +08:00
id: uid(),
role: 'system',
content: `Error: startRun returned no run ID. Response: ${JSON.stringify(run)}`,
timestamp: Date.now(),
})
return
}
// tmux-like resume: persist run_id so refresh/reopen can pick up the
// working indicator and poll for progress.
markInFlight(sid, runId)
// If we were already polling (e.g. user re-sent while resume was still
// polling an earlier run), cancel that polling — the new SSE stream is
// the authoritative live source.
stopPolling(sid)
// Helper to clean up this session's stream state
const cleanup = () => {
streamStates.value.delete(sid)
if (persistTimer) {
clearTimeout(persistTimer)
persistTimer = null
}
}
// Throttle in-flight cache writes so a refresh mid-stream still shows
// the partial reply. 800ms keeps quota pressure low while guaranteeing
// at most ~1s of unsaved delta on reload.
let persistTimer: ReturnType<typeof setTimeout> | null = null
const schedulePersist = () => {
if (sid !== activeSessionId.value || persistTimer) return
persistTimer = setTimeout(() => {
persistTimer = null
persistActiveMessages()
}, 800)
}
// Listen to SSE events — all closures capture `sid`
const ctrl = streamRunEvents(
2026-04-11 15:59:14 +08:00
runId,
// onEvent
(evt: RunEvent) => {
switch (evt.event) {
case 'run.started':
break
case 'message.delta':
case 'message': {
const msgs = getSessionMsgs(sid)
const last = msgs[msgs.length - 1]
2026-04-11 15:59:14 +08:00
if (last?.role === 'assistant' && last.isStreaming) {
last.content += evt.delta || ''
} else {
addMessage(sid, {
2026-04-11 15:59:14 +08:00
id: uid(),
role: 'assistant',
content: evt.delta || '',
timestamp: Date.now(),
isStreaming: true,
})
}
schedulePersist()
2026-04-11 15:59:14 +08:00
break
}
case 'tool.started': {
const msgs = getSessionMsgs(sid)
const last = msgs[msgs.length - 1]
2026-04-11 15:59:14 +08:00
if (last?.isStreaming) {
updateMessage(sid, last.id, { isStreaming: false })
2026-04-11 15:59:14 +08:00
}
addMessage(sid, {
2026-04-11 15:59:14 +08:00
id: uid(),
role: 'tool',
content: '',
timestamp: Date.now(),
toolName: evt.tool || evt.name,
toolPreview: evt.preview,
toolStatus: 'running',
})
schedulePersist()
2026-04-11 15:59:14 +08:00
break
}
case 'tool.completed': {
const msgs = getSessionMsgs(sid)
const toolMsgs = msgs.filter(
2026-04-11 15:59:14 +08:00
m => m.role === 'tool' && m.toolStatus === 'running',
)
if (toolMsgs.length > 0) {
const last = toolMsgs[toolMsgs.length - 1]
updateMessage(sid, last.id, { toolStatus: 'done' })
2026-04-11 15:59:14 +08:00
}
schedulePersist()
2026-04-11 15:59:14 +08:00
break
}
case 'run.completed': {
const msgs = getSessionMsgs(sid)
const lastMsg = msgs[msgs.length - 1]
2026-04-11 15:59:14 +08:00
if (lastMsg?.isStreaming) {
updateMessage(sid, lastMsg.id, { isStreaming: false })
2026-04-11 15:59:14 +08:00
}
if (evt.usage) {
const target = sessions.value.find(s => s.id === sid)
if (target) {
target.inputTokens = evt.usage.input_tokens
target.outputTokens = evt.usage.output_tokens
}
}
cleanup()
updateSessionTitle(sid)
// Persist the terminal local view before clearing the in-flight
// marker. If final SSE deltas were missed, reconcile once from
// the authoritative session export without retreating from
// longer local text.
if (sid === activeSessionId.value) persistActiveMessages()
void reconcileSessionAfterCompletion(sid).finally(() => {
if (readInFlight(sid)?.runId === runId) startPolling(sid)
})
2026-04-11 15:59:14 +08:00
break
}
2026-04-11 15:59:14 +08:00
case 'run.failed': {
const msgs = getSessionMsgs(sid)
const lastErr = msgs[msgs.length - 1]
2026-04-11 15:59:14 +08:00
if (lastErr?.isStreaming) {
updateMessage(sid, lastErr.id, {
2026-04-11 15:59:14 +08:00
isStreaming: false,
content: evt.error ? `Error: ${evt.error}` : 'Run failed',
role: 'system',
})
} else {
addMessage(sid, {
2026-04-11 15:59:14 +08:00
id: uid(),
role: 'system',
content: evt.error ? `Error: ${evt.error}` : 'Run failed',
timestamp: Date.now(),
})
}
msgs.forEach((m, i) => {
2026-04-11 15:59:14 +08:00
if (m.role === 'tool' && m.toolStatus === 'running') {
msgs[i] = { ...m, toolStatus: 'error' }
2026-04-11 15:59:14 +08:00
}
})
cleanup()
if (sid === activeSessionId.value) persistActiveMessages()
if (clearInFlight(sid, runId)) {
stopPolling(sid)
}
2026-04-11 15:59:14 +08:00
break
}
2026-04-11 15:59:14 +08:00
}
},
// onDone
() => {
const msgs = getSessionMsgs(sid)
const last = msgs[msgs.length - 1]
2026-04-11 15:59:14 +08:00
if (last?.isStreaming) {
updateMessage(sid, last.id, { isStreaming: false })
2026-04-11 15:59:14 +08:00
}
cleanup()
updateSessionTitle(sid)
2026-04-11 15:59:14 +08:00
},
// onError
// Mobile browsers drop EventSource when the tab backgrounds / screen
// locks / network flips. The backend run usually completes anyway, so
// rather than injecting a stale "SSE connection error" bubble we mark
// streaming as done and silently re-sync from the server, which has
// the real final answer. If the server fetch itself fails, we leave
// whatever text we already streamed in place — no visible error.
2026-04-11 15:59:14 +08:00
(err) => {
console.warn('SSE connection dropped, resyncing from server:', err.message)
const msgs = getSessionMsgs(sid)
const last = msgs[msgs.length - 1]
2026-04-11 15:59:14 +08:00
if (last?.isStreaming) {
updateMessage(sid, last.id, { isStreaming: false })
2026-04-11 15:59:14 +08:00
}
// Keep running tool state until refresh/polling sees the server's
// terminal transcript. A dropped SSE connection is not proof the
// tool completed.
cleanup()
if (sid === activeSessionId.value) {
void refreshActiveSession()
}
// The run might still be going on the server side (SSE drop doesn't
// abort it). If we still have an in-flight record, fall back to
// polling fetchSession to keep the user updated.
if (readInFlight(sid)) {
startPolling(sid)
}
2026-04-11 15:59:14 +08:00
},
)
streamStates.value.set(sid, ctrl)
2026-04-11 15:59:14 +08:00
} catch (err: any) {
addMessage(sid, {
2026-04-11 15:59:14 +08:00
id: uid(),
role: 'system',
content: `Error: ${err.message}`,
timestamp: Date.now(),
})
}
}
function stopStreaming() {
const sid = activeSessionId.value
if (!sid) return
const ctrl = streamStates.value.get(sid)
if (ctrl) {
ctrl.abort()
const msgs = getSessionMsgs(sid)
const lastMsg = msgs[msgs.length - 1]
if (lastMsg?.isStreaming) {
updateMessage(sid, lastMsg.id, { isStreaming: false })
}
streamStates.value.delete(sid)
clearInFlight(sid)
stopPolling(sid)
2026-04-11 15:59:14 +08:00
}
}
// Tab visibility: re-sync when returning to foreground
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && activeSessionId.value && !isStreaming.value) {
void refreshActiveSession()
if (readInFlight(activeSessionId.value)) {
startPolling(activeSessionId.value)
}
}
})
}
2026-04-11 15:59:14 +08:00
return {
sessions,
activeSessionId,
activeSession,
2026-04-22 14:00:34 +08:00
focusMessageId,
2026-04-11 15:59:14 +08:00
messages,
isStreaming,
isRunActive,
2026-04-19 21:51:25 +08:00
isSessionLive,
isLoadingSessions,
sessionsLoaded,
isLoadingMessages,
2026-04-11 15:59:14 +08:00
newChat,
switchSession,
switchSessionModel,
2026-04-11 15:59:14 +08:00
deleteSession,
sendMessage,
stopStreaming,
loadSessions,
refreshActiveSession,
2026-04-11 15:59:14 +08:00
}
})