2026-04-16 08:38:18 +08:00
|
|
|
|
import { startRun, streamRunEvents, type ChatMessage, type RunEvent } from '@/api/hermes/chat'
|
2026-04-24 22:18:32 +08:00
|
|
|
|
import { deleteSession as deleteSessionApi, fetchSession, fetchSessions, fetchSessionUsageSingle, type HermesMessage, type SessionSummary } from '@/api/hermes/sessions'
|
2026-04-11 15:59:14 +08:00
|
|
|
|
import { defineStore } from 'pinia'
|
2026-04-15 10:28:53 +08:00
|
|
|
|
import { ref, computed } from 'vue'
|
2026-04-12 23:23:50 +08:00
|
|
|
|
import { useAppStore } from './app'
|
2026-04-18 14:32:54 +08:00
|
|
|
|
import { useProfilesStore } from './profiles'
|
2026-04-25 08:46:50 +08:00
|
|
|
|
import { detectThinkingBoundary } from '@/utils/thinking-parser'
|
2026-04-11 15:59:14 +08:00
|
|
|
|
|
2026-04-11 18:54:46 +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
|
2026-04-12 23:59:18 +08:00
|
|
|
|
toolArgs?: string
|
|
|
|
|
|
toolResult?: string
|
2026-04-11 15:59:14 +08:00
|
|
|
|
toolStatus?: 'running' | 'done' | 'error'
|
|
|
|
|
|
isStreaming?: boolean
|
2026-04-11 18:54:46 +08:00
|
|
|
|
attachments?: Attachment[]
|
2026-04-25 08:46:50 +08:00
|
|
|
|
// 思考/推理文本。两条来源:
|
|
|
|
|
|
// 1) 历史消息:来自 HermesMessage.reasoning 字段
|
|
|
|
|
|
// 2) 流式:由 reasoning.delta / thinking.delta / reasoning.available 事件累加
|
|
|
|
|
|
// 不含 <think> 包裹标签;内容自身可以为多段纯文本。
|
|
|
|
|
|
reasoning?: string
|
2026-04-11 15:59:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-11 21:33:04 +08:00
|
|
|
|
export interface Session {
|
2026-04-11 15:59:14 +08:00
|
|
|
|
id: string
|
|
|
|
|
|
title: string
|
2026-04-12 23:59:18 +08:00
|
|
|
|
source?: string
|
2026-04-11 15:59:14 +08:00
|
|
|
|
messages: Message[]
|
|
|
|
|
|
createdAt: number
|
|
|
|
|
|
updatedAt: number
|
2026-04-11 21:33:04 +08:00
|
|
|
|
model?: string
|
2026-04-12 23:23:50 +08:00
|
|
|
|
provider?: string
|
2026-04-11 21:33:04 +08:00
|
|
|
|
messageCount?: number
|
2026-04-14 14:47:18 +08:00
|
|
|
|
inputTokens?: number
|
|
|
|
|
|
outputTokens?: number
|
2026-04-23 04:49:00 +02:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-11 18:54:46 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
2026-04-15 19:29:44 +08:00
|
|
|
|
const token = localStorage.getItem('hermes_api_key') || ''
|
|
|
|
|
|
const res = await fetch('/upload', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
body: formData,
|
|
|
|
|
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
|
|
|
|
|
})
|
2026-04-11 18:54:46 +08:00
|
|
|
|
if (!res.ok) throw new Error(`Upload failed: ${res.status}`)
|
|
|
|
|
|
const data = await res.json() as { files: { name: string; path: string }[] }
|
|
|
|
|
|
return data.files
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-11 21:33:04 +08:00
|
|
|
|
function mapHermesMessages(msgs: HermesMessage[]): Message[] {
|
2026-04-12 23:59:18 +08:00
|
|
|
|
// Build lookups from assistant messages with tool_calls
|
2026-04-11 21:33:04 +08:00
|
|
|
|
const toolNameMap = new Map<string, string>()
|
2026-04-12 23:59:18 +08:00
|
|
|
|
const toolArgsMap = new Map<string, string>()
|
2026-04-11 21:33:04 +08:00
|
|
|
|
for (const msg of msgs) {
|
|
|
|
|
|
if (msg.role === 'assistant' && msg.tool_calls) {
|
|
|
|
|
|
for (const tc of msg.tool_calls) {
|
2026-04-12 23:59:18 +08:00
|
|
|
|
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 21:33:04 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-11 15:59:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-11 21:33:04 +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),
|
2026-04-15 16:36:04 +08:00
|
|
|
|
toolName: tc.function?.name || 'tool',
|
2026-04-12 23:59:18 +08:00
|
|
|
|
toolArgs: tc.function?.arguments || undefined,
|
2026-04-11 21:33:04 +08:00
|
|
|
|
toolStatus: 'done',
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Tool result messages
|
|
|
|
|
|
if (msg.role === 'tool') {
|
2026-04-12 23:59:18 +08:00
|
|
|
|
const tcId = msg.tool_call_id || ''
|
2026-04-15 16:36:04 +08:00
|
|
|
|
const toolName = msg.tool_name || toolNameMap.get(tcId) || 'tool'
|
2026-04-12 23:59:18 +08:00
|
|
|
|
const toolArgs = toolArgsMap.get(tcId) || undefined
|
2026-04-11 21:33:04 +08:00
|
|
|
|
// 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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-12 23:59:18 +08:00
|
|
|
|
// 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)
|
|
|
|
|
|
}
|
2026-04-11 21:33:04 +08:00
|
|
|
|
result.push({
|
|
|
|
|
|
id: String(msg.id),
|
|
|
|
|
|
role: 'tool',
|
|
|
|
|
|
content: '',
|
|
|
|
|
|
timestamp: Math.round(msg.timestamp * 1000),
|
|
|
|
|
|
toolName,
|
2026-04-12 23:59:18 +08:00
|
|
|
|
toolArgs,
|
2026-04-13 00:52:34 +08:00
|
|
|
|
toolPreview: typeof preview === 'string' ? preview.slice(0, 100) || undefined : undefined,
|
2026-04-12 23:59:18 +08:00
|
|
|
|
toolResult: msg.content || undefined,
|
2026-04-11 21:33:04 +08:00
|
|
|
|
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),
|
2026-04-25 08:46:50 +08:00
|
|
|
|
reasoning: msg.reasoning ? msg.reasoning : undefined,
|
2026-04-11 21:33:04 +08:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
return result
|
2026-04-11 15:59:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-11 21:33:04 +08:00
|
|
|
|
function mapHermesSession(s: SessionSummary): Session {
|
|
|
|
|
|
return {
|
|
|
|
|
|
id: s.id,
|
2026-04-15 16:36:04 +08:00
|
|
|
|
title: s.title || '',
|
2026-04-12 23:59:18 +08:00
|
|
|
|
source: s.source || undefined,
|
2026-04-11 21:33:04 +08:00
|
|
|
|
messages: [],
|
|
|
|
|
|
createdAt: Math.round(s.started_at * 1000),
|
2026-04-19 23:32:01 +08:00
|
|
|
|
updatedAt: Math.round((s.last_active || s.ended_at || s.started_at) * 1000),
|
2026-04-11 21:33:04 +08:00
|
|
|
|
model: s.model,
|
2026-04-12 23:23:50 +08:00
|
|
|
|
provider: (s as any).billing_provider || '',
|
2026-04-11 21:33:04 +08:00
|
|
|
|
messageCount: s.message_count,
|
2026-04-23 04:49:00 +02:00
|
|
|
|
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 21:33:04 +08:00
|
|
|
|
}
|
2026-04-11 15:59:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-18 00:00:24 +08:00
|
|
|
|
// Cache keys for stale-while-revalidate loading of sessions / messages.
|
2026-04-18 14:32:54 +08:00
|
|
|
|
// All keys include the active profile name to isolate cache between profiles.
|
2026-04-18 00:00:24 +08:00
|
|
|
|
// Rendering from cache on boot avoids the multi-round-trip wait the user sees
|
|
|
|
|
|
// every time they open the page (esp. noticeable on mobile).
|
2026-04-18 14:32:54 +08:00
|
|
|
|
const STORAGE_KEY_PREFIX = 'hermes_active_session_'
|
|
|
|
|
|
const SESSIONS_CACHE_KEY_PREFIX = 'hermes_sessions_cache_v1_'
|
2026-04-23 07:35:05 +08:00
|
|
|
|
const LEGACY_STORAGE_KEY = 'hermes_active_session'
|
|
|
|
|
|
const LEGACY_SESSIONS_CACHE_KEY = 'hermes_sessions_cache_v1'
|
2026-04-18 00:00:24 +08:00
|
|
|
|
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
|
2026-04-23 04:49:00 +02:00
|
|
|
|
const LIVE_BADGE_WINDOW_MS = 5 * 60 * 1000
|
2026-04-18 00:00:24 +08:00
|
|
|
|
|
2026-04-18 14:32:54 +08:00
|
|
|
|
// 获取当前 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}` }
|
2026-04-23 07:35:05 +08:00
|
|
|
|
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 }
|
2026-04-18 14:32:54 +08:00
|
|
|
|
|
2026-04-18 00:00:24 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-23 07:35:05 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-18 00:00:24 +08:00
|
|
|
|
function saveJson(key: string, value: unknown) {
|
|
|
|
|
|
try {
|
2026-04-23 07:35:05 +08:00
|
|
|
|
setItemBestEffort(key, JSON.stringify(value))
|
2026-04-18 00:00:24 +08:00
|
|
|
|
} catch {
|
|
|
|
|
|
// quota exceeded or private mode — ignore, cache is best-effort
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function removeItem(key: string) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
localStorage.removeItem(key)
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-23 07:35:05 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-18 00:00:24 +08:00
|
|
|
|
// 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-25 08:46:50 +08:00
|
|
|
|
// Heals assistant messages whose `reasoning` field was polluted by the
|
|
|
|
|
|
// old bug where `reasoning.available` clobbered it with the assistant
|
|
|
|
|
|
// content. Detection heuristic: reasoning is a prefix of content (the
|
|
|
|
|
|
// bug always derived `reasoning` from `content[:500]` with tags stripped).
|
|
|
|
|
|
// Legitimate reasoning is almost never a prefix of the final answer.
|
|
|
|
|
|
function scrubBuggyReasoningInCache(msgs: Message[] | null | undefined): Message[] {
|
|
|
|
|
|
if (!msgs) return []
|
|
|
|
|
|
return msgs.map(m => {
|
|
|
|
|
|
if (m.role !== 'assistant' || !m.reasoning || !m.content) return m
|
|
|
|
|
|
const r = m.reasoning.trim()
|
|
|
|
|
|
const c = m.content.trim()
|
|
|
|
|
|
if (!r || !c) return m
|
|
|
|
|
|
if (c === r || c.startsWith(r)) {
|
|
|
|
|
|
const { reasoning: _drop, ...rest } = m
|
|
|
|
|
|
return rest as Message
|
|
|
|
|
|
}
|
|
|
|
|
|
return m
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-11 15:59:14 +08:00
|
|
|
|
export const useChatStore = defineStore('chat', () => {
|
2026-04-11 21:33:04 +08:00
|
|
|
|
const sessions = ref<Session[]>([])
|
2026-04-18 14:32:54 +08:00
|
|
|
|
const activeSessionId = ref<string | null>(null)
|
2026-04-22 14:00:34 +08:00
|
|
|
|
const focusMessageId = ref<string | null>(null)
|
2026-04-15 11:00:47 +08:00
|
|
|
|
const streamStates = ref<Map<string, AbortController>>(new Map())
|
|
|
|
|
|
const isStreaming = computed(() => activeSessionId.value != null && streamStates.value.has(activeSessionId.value))
|
2026-04-11 21:33:04 +08:00
|
|
|
|
const isLoadingSessions = ref(false)
|
2026-04-22 02:09:58 +02:00
|
|
|
|
const sessionsLoaded = ref(false)
|
2026-04-11 21:33:04 +08:00
|
|
|
|
const isLoadingMessages = ref(false)
|
2026-04-18 00:00:24 +08:00
|
|
|
|
// 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
|
|
|
|
|
2026-04-11 21:33:04 +08:00
|
|
|
|
const activeSession = ref<Session | null>(null)
|
2026-04-15 11:00:47 +08:00
|
|
|
|
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 {
|
2026-04-23 04:49:00 +02:00
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-18 00:00:24 +08:00
|
|
|
|
function persistSessionsList() {
|
|
|
|
|
|
// Cache lightweight summaries only (messages are cached per-session).
|
2026-04-23 07:35:05 +08:00
|
|
|
|
saveJsonWithLegacy(
|
2026-04-18 14:32:54 +08:00
|
|
|
|
sessionsCacheKey(),
|
2026-04-18 00:00:24 +08:00
|
|
|
|
sessions.value.map(s => ({ ...s, messages: [] })),
|
2026-04-23 07:35:05 +08:00
|
|
|
|
legacySessionsCacheKey(),
|
2026-04-18 00:00:24 +08:00
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function persistActiveMessages() {
|
|
|
|
|
|
const sid = activeSessionId.value
|
|
|
|
|
|
if (!sid) return
|
|
|
|
|
|
const s = sessions.value.find(sess => sess.id === sid)
|
2026-04-23 07:35:05 +08:00
|
|
|
|
if (s) saveJsonWithLegacy(msgsCacheKey(sid), sanitizeForCache(s.messages), legacyMsgsCacheKey(sid))
|
2026-04-18 00:00:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function markInFlight(sid: string, runId: string) {
|
2026-04-23 07:35:05 +08:00
|
|
|
|
saveJsonWithLegacy(inFlightKey(sid), { runId, startedAt: Date.now() } as InFlightRun, legacyInFlightKey(sid))
|
2026-04-18 00:00:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-24 22:18:32 +08:00
|
|
|
|
function clearInFlight(sid: string) {
|
2026-04-23 07:35:05 +08:00
|
|
|
|
removeItemWithLegacy(inFlightKey(sid), legacyInFlightKey(sid))
|
2026-04-18 00:00:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function readInFlight(sid: string): InFlightRun | null {
|
2026-04-23 07:35:05 +08:00
|
|
|
|
const rec = loadJsonWithFallback<InFlightRun>(inFlightKey(sid), legacyInFlightKey(sid))
|
2026-04-18 00:00:24 +08:00
|
|
|
|
if (!rec) return null
|
|
|
|
|
|
if (Date.now() - rec.startedAt > IN_FLIGHT_TTL_MS) {
|
2026-04-23 07:35:05 +08:00
|
|
|
|
removeItemWithLegacy(inFlightKey(sid), legacyInFlightKey(sid))
|
2026-04-18 00:00:24 +08:00
|
|
|
|
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
|
2026-04-24 22:18:32 +08:00
|
|
|
|
// server's message signature is stable for POLL_STABLE_EXITS ticks (run
|
|
|
|
|
|
// presumed done), TTL elapses, or the user explicitly starts streaming.
|
2026-04-18 00:00:24 +08:00
|
|
|
|
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
|
2026-04-24 22:18:32 +08:00
|
|
|
|
// Use the same "content-aware" comparison as switchSession: server
|
|
|
|
|
|
// is ahead iff it knows about at least as many user turns and its
|
|
|
|
|
|
// last assistant text is at least as long as ours.
|
|
|
|
|
|
const local = target.messages
|
|
|
|
|
|
const localLastAssistant = [...local].reverse().find(m => m.role === 'assistant')
|
|
|
|
|
|
const serverLastAssistant = [...mapped].reverse().find(m => m.role === 'assistant')
|
|
|
|
|
|
const localAssistantLen = localLastAssistant?.content?.length ?? 0
|
|
|
|
|
|
const serverAssistantLen = serverLastAssistant?.content?.length ?? 0
|
|
|
|
|
|
const localUsers = local.filter(m => m.role === 'user').length
|
|
|
|
|
|
const serverUsers = mapped.filter(m => m.role === 'user').length
|
|
|
|
|
|
const serverIsCaughtUp = serverUsers >= localUsers
|
|
|
|
|
|
// Same rationale as switchSession: strictly more user turns means
|
|
|
|
|
|
// server is ahead (new turn complete). Equal user turns + longer
|
|
|
|
|
|
// assistant means server caught up on the current turn.
|
|
|
|
|
|
const serverIsAhead =
|
|
|
|
|
|
serverUsers > localUsers
|
|
|
|
|
|
|| (serverUsers === localUsers && serverAssistantLen >= localAssistantLen)
|
2026-04-18 00:00:24 +08:00
|
|
|
|
if (serverIsAhead) {
|
|
|
|
|
|
target.messages = mapped
|
2026-04-24 22:18:32 +08:00
|
|
|
|
if (detail.title && !target.title) target.title = detail.title
|
2026-04-18 00:00:24 +08:00
|
|
|
|
if (sid === activeSessionId.value) persistActiveMessages()
|
|
|
|
|
|
}
|
2026-04-24 22:18:32 +08:00
|
|
|
|
// Stability detection ONLY matters when the server has at least as
|
|
|
|
|
|
// many user turns as we do. Otherwise the server is still catching
|
|
|
|
|
|
// up (e.g. the new turn we just sent hasn't been flushed server-side
|
|
|
|
|
|
// yet) and a "stable" signature is a false positive — the stability
|
|
|
|
|
|
// is the server NOT having our latest turn, not the run being done.
|
|
|
|
|
|
if (!serverIsCaughtUp) {
|
2026-04-18 00:00:24 +08:00
|
|
|
|
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) {
|
2026-04-24 22:18:32 +08:00
|
|
|
|
// Run is done on the server. Force-apply server view even if
|
|
|
|
|
|
// our "don't retreat" guard above skipped it — the server is
|
|
|
|
|
|
// now the authoritative source of truth.
|
|
|
|
|
|
target.messages = mapped
|
|
|
|
|
|
if (detail.title) target.title = detail.title
|
2026-04-18 00:00:24 +08:00
|
|
|
|
if (sid === activeSessionId.value) persistActiveMessages()
|
2026-04-24 22:18:32 +08:00
|
|
|
|
clearInFlight(sid)
|
|
|
|
|
|
stopPolling(sid)
|
2026-04-18 00:00:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
pollSignatures.set(sid, { sig, stableTicks: 0 })
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// transient network error — ignore, next tick tries again
|
|
|
|
|
|
}
|
|
|
|
|
|
}, POLL_INTERVAL_MS)
|
|
|
|
|
|
pollTimers.set(sid, timer)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-11 21:33:04 +08:00
|
|
|
|
async function loadSessions() {
|
|
|
|
|
|
isLoadingSessions.value = true
|
|
|
|
|
|
try {
|
2026-04-18 14:32:54 +08:00
|
|
|
|
// 从 profile 对应的缓存中恢复,实现 instant render
|
2026-04-23 07:35:05 +08:00
|
|
|
|
const cachedSessions = loadJsonWithFallback<Session[]>(sessionsCacheKey(), legacySessionsCacheKey())
|
2026-04-18 14:32:54 +08:00
|
|
|
|
if (cachedSessions?.length) {
|
|
|
|
|
|
sessions.value = cachedSessions
|
2026-04-23 07:35:05 +08:00
|
|
|
|
const savedId = localStorage.getItem(storageKey()) || (legacyStorageKey() ? localStorage.getItem(legacyStorageKey()!) : null)
|
2026-04-18 14:32:54 +08:00
|
|
|
|
if (savedId) {
|
|
|
|
|
|
const cachedActive = cachedSessions.find(s => s.id === savedId) || null
|
|
|
|
|
|
if (cachedActive) {
|
2026-04-23 07:35:05 +08:00
|
|
|
|
const cachedMsgs = loadJsonWithFallback<Message[]>(msgsCacheKey(savedId), legacyMsgsCacheKey(savedId))
|
2026-04-25 08:46:50 +08:00
|
|
|
|
if (cachedMsgs) cachedActive.messages = scrubBuggyReasoningInCache(cachedMsgs)
|
2026-04-18 14:32:54 +08:00
|
|
|
|
activeSession.value = cachedActive
|
|
|
|
|
|
activeSessionId.value = savedId
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-12 23:59:18 +08:00
|
|
|
|
const list = await fetchSessions()
|
2026-04-18 00:00:24 +08:00
|
|
|
|
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.
|
2026-04-24 20:41:14 +08:00
|
|
|
|
// 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
|
|
|
|
|
|
})
|
2026-04-18 00:00:24 +08:00
|
|
|
|
sessions.value = [...localOnly, ...fresh]
|
|
|
|
|
|
persistSessionsList()
|
|
|
|
|
|
|
2026-04-15 11:00:47 +08:00
|
|
|
|
// 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)
|
2026-04-11 21:33:04 +08:00
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('Failed to load sessions:', err)
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
isLoadingSessions.value = false
|
2026-04-22 02:09:58 +02:00
|
|
|
|
sessionsLoaded.value = true
|
2026-04-11 21:33:04 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-11 15:59:14 +08:00
|
|
|
|
|
2026-04-24 22:18:32 +08:00
|
|
|
|
// Re-pull active session from server and overwrite local messages. Used on
|
|
|
|
|
|
// SSE drop and on tab-visible events — mobile browsers kill EventSource
|
|
|
|
|
|
// while backgrounded, but the backend run usually completes anyway.
|
2026-04-18 00:00:24 +08:00
|
|
|
|
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
|
2026-04-24 22:18:32 +08:00
|
|
|
|
const mapped = mapHermesMessages(detail.messages || [])
|
|
|
|
|
|
target.messages = mapped
|
|
|
|
|
|
if (detail.title) target.title = detail.title
|
|
|
|
|
|
persistActiveMessages()
|
|
|
|
|
|
return true
|
2026-04-18 00:00:24 +08:00
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('Failed to refresh active session:', err)
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-13 00:52:34 +08:00
|
|
|
|
|
2026-04-11 15:59:14 +08:00
|
|
|
|
function createSession(): Session {
|
|
|
|
|
|
const session: Session = {
|
|
|
|
|
|
id: uid(),
|
2026-04-15 16:36:04 +08:00
|
|
|
|
title: '',
|
2026-04-13 00:52:34 +08:00
|
|
|
|
source: 'api_server',
|
2026-04-11 15:59:14 +08:00
|
|
|
|
messages: [],
|
|
|
|
|
|
createdAt: Date.now(),
|
|
|
|
|
|
updatedAt: Date.now(),
|
|
|
|
|
|
}
|
|
|
|
|
|
sessions.value.unshift(session)
|
2026-04-18 00:00:24 +08:00
|
|
|
|
// 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-25 08:46:50 +08:00
|
|
|
|
clearThinkingObservationFor(sessionId)
|
2026-04-11 15:59:14 +08:00
|
|
|
|
activeSessionId.value = sessionId
|
2026-04-22 14:00:34 +08:00
|
|
|
|
focusMessageId.value = focusId ?? null
|
2026-04-23 07:35:05 +08:00
|
|
|
|
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
|
2026-04-11 21:33:04 +08:00
|
|
|
|
|
2026-04-18 00:00:24 +08:00
|
|
|
|
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) {
|
2026-04-23 07:35:05 +08:00
|
|
|
|
const cachedMsgs = loadJsonWithFallback<Message[]>(msgsCacheKey(sessionId), legacyMsgsCacheKey(sessionId))
|
2026-04-18 00:00:24 +08:00
|
|
|
|
if (cachedMsgs?.length) {
|
2026-04-25 08:46:50 +08:00
|
|
|
|
activeSession.value.messages = scrubBuggyReasoningInCache(cachedMsgs)
|
2026-04-18 00:00:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
2026-04-24 22:18:32 +08:00
|
|
|
|
// Pick whichever view has more information. Simple length comparison
|
|
|
|
|
|
// is wrong because mapHermesMessages folds tool_call-only assistant
|
|
|
|
|
|
// msgs and matches them with tool-result msgs — so post-fold `mapped`
|
|
|
|
|
|
// can be SHORTER than the raw SSE-built local array even when the
|
|
|
|
|
|
// server is strictly ahead. Instead, compare the last assistant
|
|
|
|
|
|
// message content: if the server's is at least as long, the server
|
|
|
|
|
|
// is up-to-date (and has the final complete text); otherwise keep
|
|
|
|
|
|
// local (in-flight window where server hasn't flushed the new turn).
|
|
|
|
|
|
const local = activeSession.value.messages
|
|
|
|
|
|
const localLastAssistant = [...local].reverse().find(m => m.role === 'assistant')
|
|
|
|
|
|
const serverLastAssistant = [...mapped].reverse().find(m => m.role === 'assistant')
|
|
|
|
|
|
const localAssistantLen = localLastAssistant?.content?.length ?? 0
|
|
|
|
|
|
const serverAssistantLen = serverLastAssistant?.content?.length ?? 0
|
|
|
|
|
|
const localUsers = local.filter(m => m.role === 'user').length
|
|
|
|
|
|
const serverUsers = mapped.filter(m => m.role === 'user').length
|
|
|
|
|
|
// Trust server when:
|
|
|
|
|
|
// - it has STRICTLY MORE user turns than we do (new turn landed),
|
|
|
|
|
|
// OR
|
|
|
|
|
|
// - same user-turn count AND server's last assistant is at least
|
|
|
|
|
|
// as long as ours (same turn, server caught up or further)
|
|
|
|
|
|
// Otherwise keep local (protects against the server-not-yet-flushed
|
|
|
|
|
|
// race during in-flight runs). Length comparison alone is wrong
|
|
|
|
|
|
// across different turns because each turn's last assistant is
|
|
|
|
|
|
// unrelated to the previous turn's.
|
|
|
|
|
|
const serverIsAhead =
|
|
|
|
|
|
serverUsers > localUsers
|
|
|
|
|
|
|| (serverUsers === localUsers && serverAssistantLen >= localAssistantLen)
|
|
|
|
|
|
if (serverIsAhead) {
|
2026-04-11 21:33:04 +08:00
|
|
|
|
activeSession.value.messages = mapped
|
2026-04-18 00:00:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
// 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 ? '...' : '')
|
2026-04-11 21:33:04 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-18 00:00:24 +08:00
|
|
|
|
persistActiveMessages()
|
2026-04-11 21:33:04 +08:00
|
|
|
|
}
|
2026-04-18 00:00:24 +08:00
|
|
|
|
} 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)
|
2026-04-11 21:33:04 +08:00
|
|
|
|
}
|
2026-04-22 16:14:50 +08:00
|
|
|
|
|
|
|
|
|
|
// 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()
|
2026-04-12 23:23:50 +08:00
|
|
|
|
// Inherit current global model
|
|
|
|
|
|
const appStore = useAppStore()
|
|
|
|
|
|
session.model = appStore.selectedModel || undefined
|
2026-04-11 15:59:14 +08:00
|
|
|
|
switchSession(session.id)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-12 23:23:50 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-11 21:33:04 +08:00
|
|
|
|
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)
|
2026-04-23 07:35:05 +08:00
|
|
|
|
removeItemWithLegacy(msgsCacheKey(sessionId), legacyMsgsCacheKey(sessionId))
|
2026-04-18 00:00:24 +08:00
|
|
|
|
persistSessionsList()
|
2026-04-11 15:59:14 +08:00
|
|
|
|
if (activeSessionId.value === sessionId) {
|
|
|
|
|
|
if (sessions.value.length > 0) {
|
2026-04-11 21:33:04 +08:00
|
|
|
|
await switchSession(sessions.value[0].id)
|
2026-04-11 15:59:14 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
const session = createSession()
|
|
|
|
|
|
switchSession(session.id)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 11:00:47 +08:00
|
|
|
|
function getSessionMsgs(sessionId: string): Message[] {
|
|
|
|
|
|
const s = sessions.value.find(s => s.id === sessionId)
|
|
|
|
|
|
return s?.messages || []
|
2026-04-15 10:28:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 11:00:47 +08:00
|
|
|
|
function addMessage(sessionId: string, msg: Message) {
|
|
|
|
|
|
const s = sessions.value.find(s => s.id === sessionId)
|
|
|
|
|
|
if (s) s.messages.push(msg)
|
2026-04-11 18:54:46 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 11:00:47 +08:00
|
|
|
|
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)
|
2026-04-11 21:33:04 +08:00
|
|
|
|
if (idx !== -1) {
|
2026-04-15 11:00:47 +08:00
|
|
|
|
s.messages[idx] = { ...s.messages[idx], ...update }
|
2026-04-11 21:33:04 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-11 15:59:14 +08:00
|
|
|
|
|
2026-04-15 11:00:47 +08:00
|
|
|
|
function updateSessionTitle(sessionId: string) {
|
|
|
|
|
|
const target = sessions.value.find(s => s.id === sessionId)
|
2026-04-15 10:28:53 +08:00
|
|
|
|
if (!target) return
|
2026-04-15 16:36:04 +08:00
|
|
|
|
if (!target.title) {
|
2026-04-15 11:00:47 +08:00
|
|
|
|
const firstUser = target.messages.find(m => m.role === 'user')
|
2026-04-11 15:59:14 +08:00
|
|
|
|
if (firstUser) {
|
2026-04-11 18:54:46 +08:00
|
|
|
|
const title = firstUser.attachments?.length
|
|
|
|
|
|
? firstUser.attachments.map(a => a.name).join(', ')
|
|
|
|
|
|
: firstUser.content
|
2026-04-15 10:28:53 +08:00
|
|
|
|
target.title = title.slice(0, 40) + (title.length > 40 ? '...' : '')
|
2026-04-11 15:59:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-15 10:28:53 +08:00
|
|
|
|
target.updatedAt = Date.now()
|
2026-04-11 15:59:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-11 18:54:46 +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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 11:00:47 +08:00
|
|
|
|
// 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(),
|
2026-04-11 18:54:46 +08:00
|
|
|
|
attachments: attachments && attachments.length > 0 ? attachments : undefined,
|
2026-04-11 15:59:14 +08:00
|
|
|
|
}
|
2026-04-15 11:00:47 +08:00
|
|
|
|
addMessage(sid, userMsg)
|
|
|
|
|
|
updateSessionTitle(sid)
|
2026-04-18 00:00:24 +08:00
|
|
|
|
// 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
|
2026-04-15 11:00:47 +08:00
|
|
|
|
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 }))
|
|
|
|
|
|
|
2026-04-11 18:54:46 +08:00
|
|
|
|
// 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')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-12 23:23:50 +08:00
|
|
|
|
const appStore = useAppStore()
|
|
|
|
|
|
const sessionModel = activeSession.value?.model || appStore.selectedModel
|
2026-04-11 15:59:14 +08:00
|
|
|
|
const run = await startRun({
|
2026-04-11 18:54:46 +08:00
|
|
|
|
input: inputText,
|
2026-04-11 15:59:14 +08:00
|
|
|
|
conversation_history: history,
|
2026-04-15 11:00:47 +08:00
|
|
|
|
session_id: sid,
|
2026-04-12 23:23:50 +08:00
|
|
|
|
model: sessionModel || undefined,
|
2026-04-11 15:59:14 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const runId = (run as any).run_id || (run as any).id
|
|
|
|
|
|
if (!runId) {
|
2026-04-15 11:00:47 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-18 00:00:24 +08:00
|
|
|
|
// 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)
|
|
|
|
|
|
|
2026-04-15 11:00:47 +08:00
|
|
|
|
// Helper to clean up this session's stream state
|
|
|
|
|
|
const cleanup = () => {
|
|
|
|
|
|
streamStates.value.delete(sid)
|
2026-04-18 00:00:24 +08:00
|
|
|
|
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)
|
2026-04-15 11:00:47 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
|
|
2026-04-25 08:46:50 +08:00
|
|
|
|
case 'reasoning.delta':
|
|
|
|
|
|
case 'thinking.delta': {
|
|
|
|
|
|
const text = evt.text || evt.delta || ''
|
|
|
|
|
|
if (!text) break
|
|
|
|
|
|
const msgs = getSessionMsgs(sid)
|
|
|
|
|
|
const last = msgs[msgs.length - 1]
|
|
|
|
|
|
if (last?.role === 'assistant' && last.isStreaming) {
|
|
|
|
|
|
last.reasoning = (last.reasoning || '') + text
|
|
|
|
|
|
noteReasoningStart(last.id)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const newId = uid()
|
|
|
|
|
|
addMessage(sid, {
|
|
|
|
|
|
id: newId,
|
|
|
|
|
|
role: 'assistant',
|
|
|
|
|
|
content: '',
|
|
|
|
|
|
timestamp: Date.now(),
|
|
|
|
|
|
isStreaming: true,
|
|
|
|
|
|
reasoning: text,
|
|
|
|
|
|
})
|
|
|
|
|
|
noteReasoningStart(newId)
|
|
|
|
|
|
}
|
|
|
|
|
|
schedulePersist()
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
case 'reasoning.available': {
|
|
|
|
|
|
// Upstream run_agent.py fires reasoning.available with
|
|
|
|
|
|
// `assistant_message.content[:500]` as the preview — i.e.,
|
|
|
|
|
|
// the main answer, not real reasoning. Ignore the payload
|
|
|
|
|
|
// and only use this event as a "thinking ended" signal so
|
|
|
|
|
|
// the duration counter stops.
|
|
|
|
|
|
const msgs = getSessionMsgs(sid)
|
|
|
|
|
|
const last = msgs[msgs.length - 1]
|
|
|
|
|
|
if (last?.role === 'assistant' && last.isStreaming) {
|
|
|
|
|
|
// 只有当 reasoning.delta 事件曾经启动过计时,才标记结束;
|
|
|
|
|
|
// 否则(上游未转发 delta,只发这一次 available)不显示时长。
|
|
|
|
|
|
noteReasoningEnd(last.id)
|
|
|
|
|
|
}
|
|
|
|
|
|
schedulePersist()
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-24 22:18:32 +08:00
|
|
|
|
case 'message.delta': {
|
2026-04-15 11:00:47 +08:00
|
|
|
|
const msgs = getSessionMsgs(sid)
|
|
|
|
|
|
const last = msgs[msgs.length - 1]
|
2026-04-11 15:59:14 +08:00
|
|
|
|
if (last?.role === 'assistant' && last.isStreaming) {
|
2026-04-25 08:46:50 +08:00
|
|
|
|
const prev = last.content
|
|
|
|
|
|
const next = prev + (evt.delta || '')
|
|
|
|
|
|
noteThinkingDelta(last.id, prev, next)
|
|
|
|
|
|
// 若之前有 reasoning 累积,则 content 到达即视为推理结束。
|
|
|
|
|
|
if (last.reasoning) noteReasoningEnd(last.id)
|
|
|
|
|
|
last.content = next
|
2026-04-11 15:59:14 +08:00
|
|
|
|
} else {
|
2026-04-25 08:46:50 +08:00
|
|
|
|
const newId = uid()
|
|
|
|
|
|
const nextContent = evt.delta || ''
|
|
|
|
|
|
noteThinkingDelta(newId, '', nextContent)
|
2026-04-15 11:00:47 +08:00
|
|
|
|
addMessage(sid, {
|
2026-04-25 08:46:50 +08:00
|
|
|
|
id: newId,
|
2026-04-11 15:59:14 +08:00
|
|
|
|
role: 'assistant',
|
2026-04-25 08:46:50 +08:00
|
|
|
|
content: nextContent,
|
2026-04-11 15:59:14 +08:00
|
|
|
|
timestamp: Date.now(),
|
|
|
|
|
|
isStreaming: true,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
2026-04-18 00:00:24 +08:00
|
|
|
|
schedulePersist()
|
2026-04-11 15:59:14 +08:00
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
case 'tool.started': {
|
2026-04-15 11:00:47 +08:00
|
|
|
|
const msgs = getSessionMsgs(sid)
|
|
|
|
|
|
const last = msgs[msgs.length - 1]
|
2026-04-11 15:59:14 +08:00
|
|
|
|
if (last?.isStreaming) {
|
2026-04-15 11:00:47 +08:00
|
|
|
|
updateMessage(sid, last.id, { isStreaming: false })
|
2026-04-11 15:59:14 +08:00
|
|
|
|
}
|
2026-04-15 11:00:47 +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',
|
|
|
|
|
|
})
|
2026-04-18 00:00:24 +08:00
|
|
|
|
schedulePersist()
|
2026-04-11 15:59:14 +08:00
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
case 'tool.completed': {
|
2026-04-15 11:00:47 +08:00
|
|
|
|
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]
|
2026-04-15 11:00:47 +08:00
|
|
|
|
updateMessage(sid, last.id, { toolStatus: 'done' })
|
2026-04-11 15:59:14 +08:00
|
|
|
|
}
|
2026-04-18 00:00:24 +08:00
|
|
|
|
schedulePersist()
|
2026-04-11 15:59:14 +08:00
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 11:00:47 +08:00
|
|
|
|
case 'run.completed': {
|
|
|
|
|
|
const msgs = getSessionMsgs(sid)
|
|
|
|
|
|
const lastMsg = msgs[msgs.length - 1]
|
2026-04-11 15:59:14 +08:00
|
|
|
|
if (lastMsg?.isStreaming) {
|
2026-04-15 11:00:47 +08:00
|
|
|
|
updateMessage(sid, lastMsg.id, { isStreaming: false })
|
2026-04-11 15:59:14 +08:00
|
|
|
|
}
|
2026-04-22 16:14:50 +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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-15 11:00:47 +08:00
|
|
|
|
cleanup()
|
|
|
|
|
|
updateSessionTitle(sid)
|
2026-04-24 22:18:32 +08:00
|
|
|
|
// the in-flight marker. If the browser is reloading right now
|
|
|
|
|
|
// and kills us between the two localStorage writes, we want
|
|
|
|
|
|
// the next page load to still see in-flight === true (so
|
|
|
|
|
|
// polling kicks in and recovers) rather than the other way
|
|
|
|
|
|
// around (cleared in-flight + stale streaming cache = UI stuck).
|
2026-04-18 00:00:24 +08:00
|
|
|
|
if (sid === activeSessionId.value) persistActiveMessages()
|
2026-04-24 22:18:32 +08:00
|
|
|
|
clearInFlight(sid)
|
|
|
|
|
|
stopPolling(sid)
|
2026-04-11 15:59:14 +08:00
|
|
|
|
break
|
2026-04-15 11:00:47 +08:00
|
|
|
|
}
|
2026-04-11 15:59:14 +08:00
|
|
|
|
|
2026-04-15 11:00:47 +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) {
|
2026-04-15 11:00:47 +08:00
|
|
|
|
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 {
|
2026-04-15 11:00:47 +08:00
|
|
|
|
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(),
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
2026-04-15 11:00:47 +08:00
|
|
|
|
msgs.forEach((m, i) => {
|
2026-04-11 15:59:14 +08:00
|
|
|
|
if (m.role === 'tool' && m.toolStatus === 'running') {
|
2026-04-15 11:00:47 +08:00
|
|
|
|
msgs[i] = { ...m, toolStatus: 'error' }
|
2026-04-11 15:59:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
})
|
2026-04-15 11:00:47 +08:00
|
|
|
|
cleanup()
|
2026-04-18 00:00:24 +08:00
|
|
|
|
if (sid === activeSessionId.value) persistActiveMessages()
|
2026-04-24 22:18:32 +08:00
|
|
|
|
clearInFlight(sid)
|
|
|
|
|
|
stopPolling(sid)
|
2026-04-11 15:59:14 +08:00
|
|
|
|
break
|
2026-04-15 11:00:47 +08:00
|
|
|
|
}
|
2026-04-11 15:59:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
// onDone
|
|
|
|
|
|
() => {
|
2026-04-15 11:00:47 +08:00
|
|
|
|
const msgs = getSessionMsgs(sid)
|
|
|
|
|
|
const last = msgs[msgs.length - 1]
|
2026-04-11 15:59:14 +08:00
|
|
|
|
if (last?.isStreaming) {
|
2026-04-15 11:00:47 +08:00
|
|
|
|
updateMessage(sid, last.id, { isStreaming: false })
|
2026-04-11 15:59:14 +08:00
|
|
|
|
}
|
2026-04-15 11:00:47 +08:00
|
|
|
|
cleanup()
|
|
|
|
|
|
updateSessionTitle(sid)
|
2026-04-11 15:59:14 +08:00
|
|
|
|
},
|
|
|
|
|
|
// onError
|
2026-04-18 00:00:24 +08:00
|
|
|
|
// 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) => {
|
2026-04-18 00:00:24 +08:00
|
|
|
|
console.warn('SSE connection dropped, resyncing from server:', err.message)
|
2026-04-15 11:00:47 +08:00
|
|
|
|
const msgs = getSessionMsgs(sid)
|
|
|
|
|
|
const last = msgs[msgs.length - 1]
|
2026-04-11 15:59:14 +08:00
|
|
|
|
if (last?.isStreaming) {
|
2026-04-18 00:00:24 +08:00
|
|
|
|
updateMessage(sid, last.id, { isStreaming: false })
|
2026-04-11 15:59:14 +08:00
|
|
|
|
}
|
2026-04-24 22:18:32 +08:00
|
|
|
|
// Any tool messages still marked 'running' will be replaced by the
|
|
|
|
|
|
// server's view after refresh; clear their spinner state now.
|
|
|
|
|
|
msgs.forEach((m, i) => {
|
|
|
|
|
|
if (m.role === 'tool' && m.toolStatus === 'running') {
|
|
|
|
|
|
msgs[i] = { ...m, toolStatus: 'done' }
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
2026-04-15 11:00:47 +08:00
|
|
|
|
cleanup()
|
2026-04-18 00:00:24 +08:00
|
|
|
|
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
|
|
|
|
},
|
|
|
|
|
|
)
|
2026-04-15 11:00:47 +08:00
|
|
|
|
|
|
|
|
|
|
streamStates.value.set(sid, ctrl)
|
2026-04-11 15:59:14 +08:00
|
|
|
|
} catch (err: any) {
|
2026-04-15 11:00:47 +08:00
|
|
|
|
addMessage(sid, {
|
2026-04-11 15:59:14 +08:00
|
|
|
|
id: uid(),
|
|
|
|
|
|
role: 'system',
|
|
|
|
|
|
content: `Error: ${err.message}`,
|
|
|
|
|
|
timestamp: Date.now(),
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function stopStreaming() {
|
2026-04-15 11:00:47 +08:00
|
|
|
|
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)
|
2026-04-18 00:00:24 +08:00
|
|
|
|
clearInFlight(sid)
|
|
|
|
|
|
stopPolling(sid)
|
2026-04-11 15:59:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-18 14:32:54 +08:00
|
|
|
|
// Tab visibility: re-sync when returning to foreground
|
2026-04-18 00:00:24 +08:00
|
|
|
|
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-25 08:46:50 +08:00
|
|
|
|
// Transient observation of <think> boundaries during active streaming.
|
|
|
|
|
|
// Not persisted; cleared on session switch. See spec §5.3.
|
|
|
|
|
|
const thinkingObservation = new Map<string, { startedAt?: number; endedAt?: number }>()
|
|
|
|
|
|
|
|
|
|
|
|
function getThinkingObservation(messageId: string) {
|
|
|
|
|
|
return thinkingObservation.get(messageId)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function noteThinkingDelta(messageId: string, prevContent: string, nextContent: string) {
|
|
|
|
|
|
const { startedAtBoundary, endedAtBoundary } = detectThinkingBoundary(prevContent, nextContent)
|
|
|
|
|
|
if (!startedAtBoundary && !endedAtBoundary) return
|
|
|
|
|
|
const existing = thinkingObservation.get(messageId) || {}
|
|
|
|
|
|
if (startedAtBoundary && existing.startedAt === undefined) {
|
|
|
|
|
|
existing.startedAt = Date.now()
|
|
|
|
|
|
}
|
|
|
|
|
|
if (endedAtBoundary && existing.endedAt === undefined) {
|
|
|
|
|
|
existing.endedAt = Date.now()
|
|
|
|
|
|
}
|
|
|
|
|
|
thinkingObservation.set(messageId, existing)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 第一次见到某条消息的 reasoning 文本时,标记 startedAt。 */
|
|
|
|
|
|
function noteReasoningStart(messageId: string) {
|
|
|
|
|
|
const existing = thinkingObservation.get(messageId) || {}
|
|
|
|
|
|
if (existing.startedAt === undefined) {
|
|
|
|
|
|
existing.startedAt = Date.now()
|
|
|
|
|
|
thinkingObservation.set(messageId, existing)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 内容首次到达(视为推理结束)或显式收到 reasoning.available 时,标记 endedAt。 */
|
|
|
|
|
|
function noteReasoningEnd(messageId: string) {
|
|
|
|
|
|
const existing = thinkingObservation.get(messageId)
|
|
|
|
|
|
if (!existing || existing.startedAt === undefined) return
|
|
|
|
|
|
if (existing.endedAt === undefined) {
|
|
|
|
|
|
existing.endedAt = Date.now()
|
|
|
|
|
|
thinkingObservation.set(messageId, existing)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function clearThinkingObservationFor(_sessionId: string) {
|
|
|
|
|
|
// messageId 与 sessionId 的关联未单独持有;方案是切会话时一律清空。
|
|
|
|
|
|
// 这符合 spec 定义:observation 是"当前会话范围内"的 transient 状态。
|
|
|
|
|
|
thinkingObservation.clear()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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,
|
2026-04-18 00:00:24 +08:00
|
|
|
|
isRunActive,
|
2026-04-19 21:51:25 +08:00
|
|
|
|
isSessionLive,
|
2026-04-11 21:33:04 +08:00
|
|
|
|
isLoadingSessions,
|
2026-04-22 02:09:58 +02:00
|
|
|
|
sessionsLoaded,
|
2026-04-11 21:33:04 +08:00
|
|
|
|
isLoadingMessages,
|
2026-04-22 02:09:58 +02:00
|
|
|
|
|
2026-04-11 15:59:14 +08:00
|
|
|
|
newChat,
|
|
|
|
|
|
switchSession,
|
2026-04-12 23:23:50 +08:00
|
|
|
|
switchSessionModel,
|
2026-04-11 15:59:14 +08:00
|
|
|
|
deleteSession,
|
|
|
|
|
|
sendMessage,
|
|
|
|
|
|
stopStreaming,
|
2026-04-11 21:33:04 +08:00
|
|
|
|
loadSessions,
|
2026-04-18 00:00:24 +08:00
|
|
|
|
refreshActiveSession,
|
2026-04-25 08:46:50 +08:00
|
|
|
|
getThinkingObservation,
|
|
|
|
|
|
noteThinkingDelta,
|
|
|
|
|
|
noteReasoningStart,
|
|
|
|
|
|
noteReasoningEnd,
|
|
|
|
|
|
clearThinkingObservationFor,
|
2026-04-11 15:59:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
})
|