chat.ts 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818
  1. import { computed, reactive, ref, watch } from 'vue'
  2. import { defineStore } from 'pinia'
  3. import type {
  4. ChatAttachment, ChatMessage, ChatRouteState, JsonValue, ModelOption, RealtimeConnectionState, SessionSummary,
  5. } from '@shared/types'
  6. import {
  7. deleteSession as deleteSessionApi, getMessages, getSessions, getSessionUnread, markSessionRead, updateSession,
  8. } from '@/api/sessions'
  9. import { getModels } from '@/api/profiles'
  10. import { ChatRpcSocket, RpcError } from '@/api/realtime'
  11. import { ApiError } from '@/api/client'
  12. import { encodeAttachment } from '@/utils/attachments'
  13. import { ScopedCache } from '@/utils/cache'
  14. import { createId, routeKey } from '@/utils/id'
  15. import { applyChatEvent, mergeChatMessages } from '@/utils/messageReducer'
  16. import { bool, normalizeChatMessage, number, record, string, values } from '@/utils/normalize'
  17. import { appendSessionPage, pinnedSessionsFirst } from '@/utils/sessionOrder'
  18. import { modelForSession } from '@/utils/sessionModel'
  19. import { moveSessionFastMode, readSessionFastMode, writeSessionFastMode } from '@/utils/sessionPreferences'
  20. import { useAuthStore } from './auth'
  21. interface CachedHistory { messages: ChatMessage[]; total: number; savedAt: number }
  22. const historyCache = new ScopedCache<CachedHistory>('chat-history-v1')
  23. function emptyRoute(profile: string, sessionId: string): ChatRouteState {
  24. return {
  25. route: { profile, sessionId }, messages: [], historySynced: false, hasMoreBefore: false,
  26. loadedMessageCount: 0, messageTotal: 0, isLoadingHistory: false, isStreaming: false,
  27. isQueued: false, generation: 0,
  28. }
  29. }
  30. function errorMessage(cause: unknown): string {
  31. return cause instanceof Error ? cause.message : '聊天请求失败'
  32. }
  33. function resultRecord(value: JsonValue): Record<string, unknown> { return record(value) }
  34. function optionalBoolean(value: unknown): boolean | undefined {
  35. if (typeof value === 'boolean') return value
  36. if (value === 'true' || value === 'fast') return true
  37. if (value === 'false' || value === 'normal') return false
  38. return undefined
  39. }
  40. export const useChatStore = defineStore('chat', () => {
  41. const auth = useAuthStore()
  42. const sessions = ref<SessionSummary[]>([])
  43. const activeSessionId = ref<string>()
  44. const activeProfileName = ref<string>()
  45. const routes = reactive<Record<string, ChatRouteState>>({})
  46. const connectionState = ref<RealtimeConnectionState>('idle')
  47. const historySynced = computed(() => activeRouteState.value?.historySynced ?? false)
  48. const isLoading = ref(false)
  49. const isLoadingMoreSessions = ref(false)
  50. const hasMoreSessions = ref(false)
  51. const isSending = ref(false)
  52. const error = ref<string>()
  53. const models = ref<ModelOption[]>([])
  54. const unreadCounts = ref<Record<string, number>>({})
  55. const selectedModel = ref<ModelOption>()
  56. const reasoningEffort = ref<string>()
  57. const socket = new ChatRpcSocket()
  58. const runtimeRoutes = new Map<string, string>()
  59. const desiredModelRoutes = new Set<string>()
  60. const pendingModelConfirmations = new Map<string, { routeKey: string; model: ModelOption }>()
  61. let connectPromise: Promise<void> | undefined
  62. let reconnectTimer: number | undefined
  63. let reconnectAttempt = 0
  64. let sessionLoadGeneration = 0
  65. let nextSessionCursor: string | undefined
  66. let modelLoadGeneration = 0
  67. let unreadLoadGeneration = 0
  68. let reconnectResumePromise: Promise<unknown> | undefined
  69. let hasConnectedOnce = false
  70. const runtimePromises = new Map<string, Promise<ChatRouteState>>()
  71. const activeRouteState = computed(() => {
  72. if (!activeSessionId.value || !activeProfileName.value) return undefined
  73. return routes[routeKey(activeProfileName.value, activeSessionId.value)]
  74. })
  75. const fastMode = computed(() => activeRouteState.value?.fastMode ?? false)
  76. const activeSession = computed(() => sessions.value.find(session => session.id === activeSessionId.value
  77. && (!activeProfileName.value || session.profile === activeProfileName.value)))
  78. const messages = computed(() => activeRouteState.value?.messages ?? [])
  79. const hasMoreBefore = computed(() => activeRouteState.value?.hasMoreBefore ?? false)
  80. const isStreaming = computed(() => activeRouteState.value?.isStreaming ?? false)
  81. const isQueued = computed(() => activeRouteState.value?.isQueued ?? false)
  82. const contextUsage = computed(() => activeRouteState.value?.usage)
  83. const pendingApproval = computed(() => activeRouteState.value?.pendingApproval)
  84. const pendingClarification = computed(() => activeRouteState.value?.pendingClarification)
  85. function ensureRoute(profile: string, sessionId: string): ChatRouteState {
  86. const key = routeKey(profile, sessionId)
  87. if (!routes[key]) {
  88. routes[key] = emptyRoute(profile, sessionId)
  89. routes[key].fastMode = readSessionFastMode(auth.user?.id ?? 'local', profile, sessionId)
  90. }
  91. return routes[key]
  92. }
  93. function persistFastMode(state: ChatRouteState): void {
  94. writeSessionFastMode(auth.user?.id ?? 'local', state.route.profile, state.route.sessionId, state.fastMode ?? false)
  95. }
  96. function syncSelectedModel(model?: string, provider?: string): void {
  97. const resolved = modelForSession(models.value, model, provider)
  98. if (resolved) selectedModel.value = resolved
  99. }
  100. function cacheScope(profile: string): string {
  101. return `${auth.user?.id ?? 'anonymous'}:${profile}`
  102. }
  103. function scheduleReconnect(): void {
  104. if (!auth.isAuthenticated || reconnectTimer !== undefined) return
  105. const delay = Math.min(15_000, 500 * 2 ** Math.min(reconnectAttempt, 5))
  106. reconnectAttempt += 1
  107. connectionState.value = 'reconnecting'
  108. reconnectTimer = window.setTimeout(() => {
  109. reconnectTimer = undefined
  110. void connect().catch(() => scheduleReconnect())
  111. }, delay)
  112. }
  113. socket.onState((state, reason) => {
  114. connectionState.value = state
  115. if (state === 'ready') {
  116. reconnectAttempt = 0
  117. const shouldResume = hasConnectedOnce
  118. hasConnectedOnce = true
  119. const active = activeRouteState.value
  120. if (shouldResume && active?.isStreaming && !active.runtimeSessionId && !reconnectResumePromise) {
  121. reconnectResumePromise = ensureRuntime(active)
  122. .catch(cause => { active.error = errorMessage(cause) })
  123. .finally(() => { reconnectResumePromise = undefined })
  124. }
  125. }
  126. if (state === 'failed') {
  127. runtimeRoutes.clear()
  128. for (const route of Object.values(routes)) {
  129. route.runtimeSessionId = undefined
  130. route.serverFastMode = undefined
  131. }
  132. error.value = reason || '聊天实时连接已断开'
  133. scheduleReconnect()
  134. }
  135. })
  136. function migrateRoute(oldKey: string, storedSessionId: string, runtimeSessionId: string): ChatRouteState {
  137. const oldState = routes[oldKey]
  138. if (!oldState) throw new Error('会话状态已经失效')
  139. const newKey = routeKey(oldState.route.profile, storedSessionId)
  140. const target = routes[newKey]
  141. const migrated: ChatRouteState = {
  142. ...(target ?? oldState),
  143. route: { profile: oldState.route.profile, sessionId: storedSessionId },
  144. messages: target ? mergeChatMessages(target.messages, oldState.messages) : oldState.messages,
  145. runtimeSessionId,
  146. }
  147. routes[newKey] = migrated
  148. if (newKey !== oldKey) delete routes[oldKey]
  149. if (desiredModelRoutes.delete(oldKey)) desiredModelRoutes.add(newKey)
  150. runtimeRoutes.set(runtimeSessionId, newKey)
  151. if (activeSessionId.value === oldState.route.sessionId && activeProfileName.value === oldState.route.profile) {
  152. activeSessionId.value = storedSessionId
  153. }
  154. if (newKey !== oldKey) {
  155. moveSessionFastMode(auth.user?.id ?? 'local', oldState.route.profile, oldState.route.sessionId, storedSessionId, migrated.fastMode ?? false)
  156. }
  157. const draftIndex = sessions.value.findIndex(session => session.id === oldState.route.sessionId && session.profile === oldState.route.profile)
  158. const now = Date.now() / 1000
  159. const summary: SessionSummary = draftIndex >= 0
  160. ? { ...sessions.value[draftIndex], id: storedSessionId, updatedAt: now }
  161. : { id: storedSessionId, profile: oldState.route.profile, source: 'web', title: '新会话', messageCount: 0, toolCallCount: 0, startedAt: now, updatedAt: now }
  162. if (draftIndex >= 0) sessions.value.splice(draftIndex, 1, summary)
  163. else sessions.value.unshift(summary)
  164. return routes[newKey]
  165. }
  166. socket.onEvent(event => {
  167. if (event.type === 'gateway.ready') return
  168. const payload = record(event.payload)
  169. const runtimeId = string(event.session_id ?? payload.session_id ?? payload.sessionId)
  170. let key = runtimeRoutes.get(runtimeId)
  171. if (!key) key = Object.keys(routes).find(candidate => routes[candidate].runtimeSessionId === runtimeId
  172. || routes[candidate].route.sessionId === runtimeId)
  173. if (!key) return
  174. let state = routes[key]
  175. if (event.type === 'session.info') {
  176. const storedId = string(payload.stored_session_id ?? payload.storedSessionId ?? payload.session_key)
  177. if (storedId && storedId !== state.route.sessionId) {
  178. state = migrateRoute(key, storedId, runtimeId)
  179. key = routeKey(state.route.profile, storedId)
  180. }
  181. }
  182. const info = record(payload.info)
  183. const liveFastMode = optionalBoolean(payload.fast ?? info.fast)
  184. if (liveFastMode !== undefined && (event.type === 'session.info' || (event.type === 'session.command' && payload.action === 'fast'))) {
  185. state.serverFastMode = liveFastMode
  186. if (!state.fastModeDirty) state.fastMode = liveFastMode
  187. persistFastMode(state)
  188. }
  189. const attributedPayload = { ...payload, session_id: state.route.sessionId } as JsonValue
  190. routes[key] = applyChatEvent(state, {
  191. ...event, session_id: state.route.sessionId, profile: state.route.profile, payload: attributedPayload,
  192. })
  193. })
  194. async function connect(): Promise<void> {
  195. if (['connected', 'ready'].includes(connectionState.value)) return
  196. if (connectPromise) return connectPromise
  197. if (reconnectTimer !== undefined) {
  198. window.clearTimeout(reconnectTimer)
  199. reconnectTimer = undefined
  200. }
  201. connectPromise = socket.connect().catch(cause => {
  202. connectionState.value = 'failed'
  203. error.value = errorMessage(cause)
  204. throw cause
  205. }).finally(() => { connectPromise = undefined })
  206. return connectPromise
  207. }
  208. function disconnect(): void {
  209. if (reconnectTimer !== undefined) window.clearTimeout(reconnectTimer)
  210. reconnectTimer = undefined
  211. hasConnectedOnce = false
  212. socket.close()
  213. runtimeRoutes.clear()
  214. runtimePromises.clear()
  215. desiredModelRoutes.clear()
  216. pendingModelConfirmations.clear()
  217. for (const state of Object.values(routes)) {
  218. state.runtimeSessionId = undefined
  219. state.serverFastMode = undefined
  220. }
  221. }
  222. function switchProfile(profile: string): void {
  223. sessionLoadGeneration += 1
  224. modelLoadGeneration += 1
  225. unreadLoadGeneration += 1
  226. disconnect()
  227. activeSessionId.value = undefined
  228. activeProfileName.value = profile
  229. nextSessionCursor = undefined
  230. hasMoreSessions.value = false
  231. isLoadingMoreSessions.value = false
  232. error.value = undefined
  233. }
  234. function clearAccountState(): void {
  235. sessions.value = []
  236. nextSessionCursor = undefined
  237. hasMoreSessions.value = false
  238. isLoadingMoreSessions.value = false
  239. activeSessionId.value = undefined
  240. activeProfileName.value = undefined
  241. runtimeRoutes.clear()
  242. for (const key of Object.keys(routes)) delete routes[key]
  243. unreadCounts.value = {}
  244. error.value = undefined
  245. }
  246. watch(() => auth.status, status => {
  247. if (status !== 'authenticated') {
  248. disconnect()
  249. clearAccountState()
  250. }
  251. })
  252. async function loadSessions(profile?: string): Promise<void> {
  253. const requestedProfile = profile || undefined
  254. const loadGeneration = ++sessionLoadGeneration
  255. isLoading.value = true
  256. error.value = undefined
  257. try {
  258. const page = await getSessions(requestedProfile, undefined, 100)
  259. if (loadGeneration !== sessionLoadGeneration
  260. || (requestedProfile && auth.activeProfile?.name !== requestedProfile)) return
  261. // 9119 owns recency within each bucket. Pinning is an explicit user
  262. // command, so pinned sessions always form the visible top partition.
  263. sessions.value = pinnedSessionsFirst(page.items)
  264. nextSessionCursor = page.nextCursor ?? undefined
  265. hasMoreSessions.value = Boolean(nextSessionCursor)
  266. } catch (cause) {
  267. if (loadGeneration === sessionLoadGeneration) error.value = errorMessage(cause)
  268. throw cause
  269. } finally {
  270. if (loadGeneration === sessionLoadGeneration) isLoading.value = false
  271. }
  272. }
  273. async function loadMoreSessions(profile = auth.activeProfile?.name): Promise<void> {
  274. const cursor = nextSessionCursor
  275. const requestedProfile = profile || undefined
  276. if (!cursor || isLoadingMoreSessions.value) return
  277. const loadGeneration = sessionLoadGeneration
  278. isLoadingMoreSessions.value = true
  279. try {
  280. const page = await getSessions(requestedProfile, cursor, 100)
  281. if (loadGeneration !== sessionLoadGeneration
  282. || (requestedProfile && auth.activeProfile?.name !== requestedProfile)) return
  283. sessions.value = appendSessionPage(sessions.value, page.items)
  284. nextSessionCursor = page.nextCursor ?? undefined
  285. hasMoreSessions.value = Boolean(nextSessionCursor)
  286. } catch (cause) {
  287. if (loadGeneration === sessionLoadGeneration) error.value = errorMessage(cause)
  288. throw cause
  289. } finally {
  290. if (loadGeneration === sessionLoadGeneration) isLoadingMoreSessions.value = false
  291. }
  292. }
  293. async function loadUnread(profile?: string): Promise<void> {
  294. const loadGeneration = ++unreadLoadGeneration
  295. const loaded = await getSessionUnread(profile)
  296. if (loadGeneration === unreadLoadGeneration && (!profile || auth.activeProfile?.name === profile)) {
  297. unreadCounts.value = loaded
  298. }
  299. }
  300. async function markRead(sessionId = activeSessionId.value, profile = activeProfileName.value): Promise<void> {
  301. if (!sessionId) return
  302. const state = profile ? routes[routeKey(profile, sessionId)] : undefined
  303. const summaryCount = sessions.value.find(session => session.id === sessionId)?.messageCount ?? 0
  304. const count = Math.max(state?.messageTotal ?? 0, summaryCount)
  305. await markSessionRead(sessionId, count, profile)
  306. unreadCounts.value = { ...unreadCounts.value, [sessionId]: 0 }
  307. }
  308. async function loadHistory(state: ChatRouteState): Promise<void> {
  309. const generation = ++state.generation
  310. state.isLoadingHistory = true
  311. state.error = undefined
  312. const scope = cacheScope(state.route.profile)
  313. const cached = await historyCache.get(scope, state.route.sessionId)
  314. if (generation !== state.generation) return
  315. if (cached?.messages.length) {
  316. state.messages = mergeChatMessages(state.messages, cached.messages, 'snapshot')
  317. state.messageTotal = cached.total
  318. state.loadedMessageCount = cached.messages.length
  319. }
  320. try {
  321. const page = await getMessages(state.route.sessionId, 0, 150, state.route.profile)
  322. if (generation !== state.generation) return
  323. state.messages = mergeChatMessages(state.messages, page.messages, 'snapshot')
  324. state.messageTotal = page.total
  325. state.loadedMessageCount = page.returned
  326. state.hasMoreBefore = page.hasMore
  327. state.historySynced = true
  328. if (page.session) {
  329. const index = sessions.value.findIndex(session => session.id === page.session!.id && session.profile === page.session!.profile)
  330. if (index >= 0) sessions.value.splice(index, 1, page.session)
  331. }
  332. await historyCache.set(scope, state.route.sessionId, { messages: state.messages, total: page.total, savedAt: Date.now() })
  333. } catch (cause) {
  334. if (generation === state.generation) state.error = errorMessage(cause)
  335. throw cause
  336. } finally {
  337. if (generation === state.generation) state.isLoadingHistory = false
  338. }
  339. }
  340. async function selectSession(sessionId: string, profile?: string): Promise<void> {
  341. const session = sessions.value.find(item => item.id === sessionId && (!profile || item.profile === profile))
  342. const selectedProfile = profile ?? session?.profile ?? auth.activeProfile?.name ?? 'default'
  343. activeSessionId.value = sessionId
  344. activeProfileName.value = selectedProfile
  345. const state = ensureRoute(selectedProfile, sessionId)
  346. syncSelectedModel(session?.model, session?.provider)
  347. // Reading an old session must not rewrite the server's last_active field.
  348. // The runtime is attached lazily by send/interrupt/usage, or when a known
  349. // in-flight turn reconnects. REST history remains authoritative here.
  350. if (!state.historySynced) await loadHistory(state)
  351. const refreshed = sessions.value.find(item => item.id === sessionId && item.profile === selectedProfile)
  352. syncSelectedModel(refreshed?.model, refreshed?.provider)
  353. }
  354. async function loadOlder(): Promise<void> {
  355. const state = activeRouteState.value
  356. if (!state || state.isLoadingHistory || !state.hasMoreBefore) return
  357. const generation = state.generation
  358. state.isLoadingHistory = true
  359. try {
  360. const page = await getMessages(state.route.sessionId, state.loadedMessageCount, 150, state.route.profile)
  361. if (generation !== state.generation) return
  362. state.messages = mergeChatMessages(state.messages, page.messages, 'prepend')
  363. state.loadedMessageCount += page.returned
  364. state.messageTotal = Math.max(state.messageTotal, page.total)
  365. state.hasMoreBefore = page.hasMore
  366. await historyCache.set(cacheScope(state.route.profile), state.route.sessionId, {
  367. messages: state.messages, total: state.messageTotal, savedAt: Date.now(),
  368. })
  369. } catch (cause) { state.error = errorMessage(cause) }
  370. finally { if (generation === state.generation) state.isLoadingHistory = false }
  371. }
  372. function createSession(profile = auth.activeProfile?.name ?? 'default'): string {
  373. const id = `draft-${createId('session')}`
  374. const now = Date.now() / 1000
  375. sessions.value.unshift({ id, profile, source: 'web', title: '新会话', messageCount: 0, toolCallCount: 0, startedAt: now, updatedAt: now })
  376. ensureRoute(profile, id)
  377. activeSessionId.value = id
  378. activeProfileName.value = profile
  379. return id
  380. }
  381. async function performEnsureRuntime(initialState: ChatRouteState): Promise<ChatRouteState> {
  382. if (initialState.runtimeSessionId) return initialState
  383. await connect()
  384. const initialKey = routeKey(initialState.route.profile, initialState.route.sessionId)
  385. if (initialState.route.sessionId.startsWith('draft-')) {
  386. const params: Record<string, JsonValue> = {
  387. profile: initialState.route.profile, source: 'web', close_on_disconnect: false,
  388. }
  389. if (reasoningEffort.value) params.reasoning_effort = reasoningEffort.value
  390. params.fast = initialState.fastMode ?? false
  391. const result = resultRecord(await socket.request('session.create', params))
  392. const runtimeId = string(result.session_id ?? result.sessionId)
  393. const storedId = string(result.stored_session_id ?? result.storedSessionId ?? result.session_key)
  394. if (!runtimeId || !storedId) throw new Error('Hermes 未返回新会话标识')
  395. const migrated = migrateRoute(initialKey, storedId, runtimeId)
  396. const info = record(result.info)
  397. if (!desiredModelRoutes.has(routeKey(migrated.route.profile, migrated.route.sessionId))) {
  398. syncSelectedModel(string(result.model ?? info.model), string(result.provider ?? info.provider))
  399. }
  400. const liveFastMode = optionalBoolean(result.fast ?? info.fast)
  401. migrated.serverFastMode = liveFastMode ?? migrated.fastMode ?? false
  402. if (!migrated.fastModeDirty && liveFastMode !== undefined) migrated.fastMode = liveFastMode
  403. migrated.fastModeDirty = false
  404. persistFastMode(migrated)
  405. return migrated
  406. }
  407. const result = resultRecord(await socket.request('session.resume', {
  408. session_id: initialState.route.sessionId,
  409. profile: initialState.route.profile,
  410. source: 'web',
  411. omit_messages: true,
  412. }))
  413. const runtimeId = string(result.session_id ?? result.sessionId)
  414. if (!runtimeId) throw new Error('Hermes 未返回运行会话标识')
  415. const storedId = string(result.stored_session_id ?? result.storedSessionId ?? result.session_key, initialState.route.sessionId)
  416. const migrated = migrateRoute(initialKey, storedId, runtimeId)
  417. const info = record(result.info)
  418. if (!desiredModelRoutes.has(routeKey(migrated.route.profile, migrated.route.sessionId))) {
  419. syncSelectedModel(string(result.model ?? info.model), string(result.provider ?? info.provider))
  420. }
  421. const liveFastMode = optionalBoolean(result.fast ?? info.fast)
  422. migrated.serverFastMode = liveFastMode
  423. if (!migrated.fastModeDirty && liveFastMode !== undefined) migrated.fastMode = liveFastMode
  424. persistFastMode(migrated)
  425. const resumedMessages = values(result.messages)
  426. .map(value => normalizeChatMessage(value, storedId, migrated.route.profile))
  427. if (resumedMessages.length) migrated.messages = mergeChatMessages(migrated.messages, resumedMessages, 'snapshot')
  428. const inflight = record(result.inflight ?? result.active_run)
  429. const assistant = string(inflight.assistant)
  430. const inflightError = string(inflight.error)
  431. migrated.isStreaming = bool(result.running) || bool(inflight.streaming)
  432. const queued = record(result.queued)
  433. const queuedUser = string(queued.user)
  434. migrated.isQueued = Boolean(queuedUser)
  435. if (assistant || inflightError) {
  436. migrated.messages = mergeChatMessages(migrated.messages, [{
  437. id: `resume:${runtimeId}`,
  438. sessionId: storedId,
  439. profile: migrated.route.profile,
  440. role: 'assistant',
  441. content: assistant,
  442. timestamp: number(result.turn_started_at, Date.now() / 1000),
  443. stage: inflightError ? 'failed' : migrated.isStreaming ? 'streaming' : 'settled',
  444. isStreaming: migrated.isStreaming,
  445. error: inflightError || undefined,
  446. }])
  447. }
  448. values(inflight.corrections).forEach((correction, index) => {
  449. const content = string(correction)
  450. if (!content) return
  451. migrated.messages = mergeChatMessages(migrated.messages, [{
  452. id: `resume-correction:${runtimeId}:${index}`,
  453. sessionId: storedId,
  454. profile: migrated.route.profile,
  455. role: 'user',
  456. content,
  457. timestamp: number(result.turn_started_at, Date.now() / 1000) + (index + 1) / 1000,
  458. stage: 'accepted',
  459. }])
  460. })
  461. if (queuedUser) {
  462. migrated.messages = mergeChatMessages(migrated.messages, [{
  463. id: `resume-queued:${runtimeId}`,
  464. sessionId: storedId,
  465. profile: migrated.route.profile,
  466. role: 'user',
  467. content: queuedUser,
  468. timestamp: Date.now() / 1000,
  469. stage: 'accepted',
  470. }])
  471. }
  472. const approval = record(result.pending_approval)
  473. const approvalID = string(approval.request_id ?? approval.requestId ?? approval.id)
  474. if (approvalID) migrated.pendingApproval = {
  475. id: approvalID,
  476. sessionId: storedId,
  477. message: string(approval.message ?? approval.prompt) || undefined,
  478. toolName: string(approval.tool_name ?? approval.tool) || undefined,
  479. choices: Array.isArray(approval.choices) ? approval.choices.map(String) : undefined,
  480. payload: approval as Record<string, JsonValue>,
  481. }
  482. const clarification = record(result.pending_clarify)
  483. const clarificationID = string(clarification.request_id ?? clarification.requestId ?? clarification.id)
  484. if (clarificationID) migrated.pendingClarification = {
  485. id: clarificationID,
  486. sessionId: storedId,
  487. question: string(clarification.question ?? clarification.message ?? clarification.prompt, '需要补充信息'),
  488. choices: Array.isArray(clarification.choices) ? clarification.choices.map(String) : undefined,
  489. payload: clarification as Record<string, JsonValue>,
  490. }
  491. return migrated
  492. }
  493. async function ensureRuntime(initialState: ChatRouteState): Promise<ChatRouteState> {
  494. if (initialState.runtimeSessionId) return initialState
  495. const key = routeKey(initialState.route.profile, initialState.route.sessionId)
  496. const existing = runtimePromises.get(key)
  497. if (existing) return existing
  498. let operation: Promise<ChatRouteState>
  499. operation = performEnsureRuntime(initialState).finally(() => {
  500. if (runtimePromises.get(key) === operation) runtimePromises.delete(key)
  501. })
  502. runtimePromises.set(key, operation)
  503. return operation
  504. }
  505. function updateDelivery(state: ChatRouteState, clientMessageId: string, patch: Partial<ChatMessage>): void {
  506. state.messages = state.messages.map(message => message.clientMessageId === clientMessageId || message.id === clientMessageId
  507. ? { ...message, ...patch }
  508. : message)
  509. }
  510. async function send(
  511. text: string,
  512. files: File[] = [],
  513. mode: 'submit' | 'queue' | 'steer' = 'submit',
  514. ): Promise<void> {
  515. let state = activeRouteState.value
  516. if (!state) {
  517. createSession()
  518. state = activeRouteState.value!
  519. }
  520. const trimmed = text.trim()
  521. if (!trimmed && !files.length) return
  522. const clientMessageId = createId('message')
  523. const attachments: ChatAttachment[] = files.map((file, index) => ({
  524. id: `${clientMessageId}:${index}`, name: file.name, mimeType: file.type || 'application/octet-stream',
  525. size: file.size, kind: file.type.startsWith('image/') ? 'image' : file.type === 'application/pdf' ? 'pdf' : 'file',
  526. }))
  527. const optimistic: ChatMessage = {
  528. id: clientMessageId, clientMessageId, sessionId: state.route.sessionId, profile: state.route.profile,
  529. role: 'user', content: trimmed, timestamp: Date.now() / 1000, stage: 'preparing', attachments,
  530. }
  531. state.messages = mergeChatMessages(state.messages, [optimistic])
  532. isSending.value = true
  533. error.value = undefined
  534. let submitted = false
  535. try {
  536. state = await ensureRuntime(state)
  537. const runtimeId = state.runtimeSessionId!
  538. if (!await applyDesiredModel(state)) throw new Error('请先确认模型切换,再重新发送消息')
  539. if (reasoningEffort.value) {
  540. await socket.request('config.set', {
  541. session_id: runtimeId, key: 'reasoning', value: reasoningEffort.value, scope: 'session',
  542. })
  543. }
  544. const desiredFastMode = state.fastMode ?? false
  545. if (state.serverFastMode !== desiredFastMode) {
  546. await socket.request('config.set', {
  547. session_id: runtimeId, key: 'fast', value: desiredFastMode ? 'fast' : 'normal', scope: 'session',
  548. })
  549. state.serverFastMode = desiredFastMode
  550. state.fastModeDirty = false
  551. persistFastMode(state)
  552. }
  553. const parts = trimmed ? [trimmed] : []
  554. for (const file of files) {
  555. const encoded = await encodeAttachment(file)
  556. if (encoded.kind === 'image') {
  557. await socket.request('image.attach_bytes', {
  558. session_id: runtimeId, content_base64: encoded.base64, filename: encoded.name, ext: encoded.extension,
  559. })
  560. parts.push(`[用户附加图片:${encoded.name}]`)
  561. } else if (encoded.kind === 'pdf') {
  562. await socket.request('pdf.attach', { session_id: runtimeId, content_base64: encoded.base64, filename: encoded.name })
  563. parts.push(`[用户附加 PDF:${encoded.name}]`)
  564. } else {
  565. const result = resultRecord(await socket.request('file.attach', {
  566. session_id: runtimeId, name: encoded.name, data_url: encoded.dataUrl,
  567. }))
  568. parts.push(`[用户附加文件:${encoded.name}]${string(result.ref_text) ? `\n${string(result.ref_text)}` : ''}`)
  569. }
  570. }
  571. updateDelivery(state, clientMessageId, { stage: files.length ? 'attached' : 'pending', sessionId: state.route.sessionId })
  572. updateDelivery(state, clientMessageId, { stage: 'pending' })
  573. submitted = true
  574. const effectiveMode = mode === 'steer' && files.length ? 'queue' : mode
  575. const method = effectiveMode === 'steer' ? 'session.steer' : 'prompt.submit'
  576. const result = resultRecord(await socket.request(method, {
  577. session_id: runtimeId,
  578. text: parts.join('\n\n'),
  579. ...(effectiveMode === 'queue' ? { queued: true } : {}),
  580. }))
  581. const status = string(result.status, 'accepted').toLowerCase()
  582. if (effectiveMode === 'steer' && !['accepted', 'queued', 'steered', 'streaming'].includes(status)) {
  583. throw new RpcError(string(result.error ?? result.message, 'Hermes 拒绝了 Steer 请求'), status)
  584. }
  585. updateDelivery(state, clientMessageId, { stage: 'accepted' })
  586. const running = ['running', 'started', 'streaming'].includes(status)
  587. if (effectiveMode === 'steer') {
  588. // A steer receipt acknowledges injection into the current run. It is
  589. // not evidence of a new run, including Hermes's `status: queued`.
  590. state.isQueued = false
  591. if (running) state.isStreaming = true
  592. } else {
  593. state.isQueued = status === 'queued'
  594. if (running || status === 'accepted') {
  595. state.isQueued = false
  596. state.isStreaming = true
  597. }
  598. }
  599. // The optimistic row remains local, but the sidebar is refreshed only
  600. // after a 9119 receipt and keeps the order returned by the server.
  601. void loadSessions(state.route.profile).catch(() => undefined)
  602. } catch (cause) {
  603. const explicitRejection = cause instanceof RpcError
  604. const stage = submitted && !explicitRejection ? 'unknown-receipt' : 'failed'
  605. updateDelivery(state, clientMessageId, { stage, error: errorMessage(cause) })
  606. state.error = errorMessage(cause)
  607. throw cause
  608. } finally { isSending.value = false }
  609. }
  610. async function interrupt(): Promise<void> {
  611. const state = activeRouteState.value
  612. if (!state) return
  613. const current = await ensureRuntime(state)
  614. await socket.request('session.interrupt', { session_id: current.runtimeSessionId! }, 15_000)
  615. current.isStreaming = false
  616. current.isQueued = false
  617. }
  618. async function respondToApproval(requestId: string, approved: boolean | 'once' | 'session' | 'always' | 'deny'): Promise<void> {
  619. const state = activeRouteState.value
  620. if (!state) return
  621. const choice = typeof approved === 'boolean' ? approved ? 'once' : 'deny' : approved
  622. const modelConfirmation = pendingModelConfirmations.get(requestId)
  623. if (modelConfirmation) {
  624. pendingModelConfirmations.delete(requestId)
  625. const target = routes[modelConfirmation.routeKey]
  626. if (!target) throw new Error('模型确认请求已失效')
  627. const modelState = await ensureRuntime(target)
  628. if (choice !== 'deny') {
  629. await configureModel(modelState, modelConfirmation.model, true)
  630. }
  631. modelState.pendingApproval = undefined
  632. return
  633. }
  634. const current = await ensureRuntime(state)
  635. await socket.request('approval.respond', { session_id: current.runtimeSessionId!, choice })
  636. if (current.pendingApproval?.id === requestId) current.pendingApproval = undefined
  637. }
  638. async function respondToClarification(requestId: string, response: string): Promise<void> {
  639. const state = activeRouteState.value
  640. if (!state) return
  641. const current = await ensureRuntime(state)
  642. await socket.request('clarify.respond', { session_id: current.runtimeSessionId!, request_id: requestId, answer: response })
  643. if (current.pendingClarification?.id === requestId) current.pendingClarification = undefined
  644. }
  645. async function branchSession(): Promise<string | undefined> {
  646. const state = activeRouteState.value
  647. if (!state) return
  648. const current = await ensureRuntime(state)
  649. const result = resultRecord(await socket.request('session.branch', { session_id: current.runtimeSessionId! }))
  650. const runtimeId = string(result.session_id ?? result.sessionId)
  651. const storedId = string(result.stored_session_id ?? result.storedSessionId)
  652. if (!runtimeId || !storedId) throw new Error('Hermes 未返回分支会话标识')
  653. const next = emptyRoute(current.route.profile, storedId)
  654. next.runtimeSessionId = runtimeId
  655. routes[routeKey(current.route.profile, storedId)] = next
  656. runtimeRoutes.set(runtimeId, routeKey(current.route.profile, storedId))
  657. activeSessionId.value = storedId
  658. activeProfileName.value = current.route.profile
  659. // The branch was created by 9119, but its position remains server-owned.
  660. void loadSessions(current.route.profile).catch(() => undefined)
  661. return storedId
  662. }
  663. async function renameSession(id: string, title: string, profile?: string): Promise<void> {
  664. await updateSession(id, { title }, profile)
  665. await loadSessions(profile)
  666. }
  667. async function setSessionPinned(id: string, pinned: boolean, profile?: string): Promise<void> {
  668. await updateSession(id, { pinned }, profile)
  669. await loadSessions(profile)
  670. }
  671. async function removeSession(id: string, profile?: string): Promise<void> {
  672. if (!id.startsWith('draft-')) await deleteSessionApi(id, profile)
  673. sessions.value = sessions.value.filter(session => session.id !== id || (profile && session.profile !== profile))
  674. if (activeSessionId.value === id && (!profile || activeProfileName.value === profile)) {
  675. activeSessionId.value = undefined
  676. activeProfileName.value = undefined
  677. }
  678. }
  679. async function loadModels(profile = auth.activeProfile?.name): Promise<void> {
  680. const loadGeneration = ++modelLoadGeneration
  681. const loaded = await getModels(profile)
  682. if (loadGeneration !== modelLoadGeneration || (profile && auth.activeProfile?.name !== profile)) return
  683. models.value = loaded
  684. selectedModel.value = modelForSession(models.value, activeSession.value?.model, activeSession.value?.provider)
  685. ?? models.value.find(model => model.isDefault) ?? models.value[0]
  686. }
  687. async function configureModel(state: ChatRouteState, model: ModelOption, confirmsExpensiveModel: boolean): Promise<boolean> {
  688. const result = resultRecord(await socket.request('config.set', {
  689. session_id: state.runtimeSessionId!,
  690. key: 'model',
  691. value: `${model.id} --provider ${model.provider} --session`,
  692. ...(confirmsExpensiveModel ? { confirm_expensive_model: true } : {}),
  693. }))
  694. if (bool(result.confirm_required ?? result.confirmRequired)) {
  695. const requestId = createId('expensive-model')
  696. pendingModelConfirmations.set(requestId, { routeKey: routeKey(state.route.profile, state.route.sessionId), model })
  697. state.pendingApproval = {
  698. id: requestId,
  699. sessionId: state.route.sessionId,
  700. message: string(result.confirm_message ?? result.confirmMessage ?? result.warning, '此模型可能产生较高费用,请确认后使用。'),
  701. choices: ['once', 'deny'],
  702. payload: result as Record<string, JsonValue>,
  703. }
  704. return false
  705. }
  706. // Match iOS: switching a model invalidates the cached fast-mode server
  707. // state, so the desired mode is re-applied before the next prompt.
  708. state.serverFastMode = undefined
  709. return true
  710. }
  711. async function applyDesiredModel(state: ChatRouteState): Promise<boolean> {
  712. const key = routeKey(state.route.profile, state.route.sessionId)
  713. if (!desiredModelRoutes.has(key) || !selectedModel.value || !state.runtimeSessionId) return true
  714. const applied = await configureModel(state, selectedModel.value, false)
  715. if (applied) desiredModelRoutes.delete(key)
  716. return applied
  717. }
  718. async function setModel(model: ModelOption): Promise<void> {
  719. selectedModel.value = model
  720. const state = activeRouteState.value
  721. if (!state) return
  722. const key = routeKey(state.route.profile, state.route.sessionId)
  723. desiredModelRoutes.add(key)
  724. if (!state.runtimeSessionId) return
  725. if (await configureModel(state, model, false)) desiredModelRoutes.delete(key)
  726. }
  727. async function setFastMode(enabled: boolean): Promise<void> {
  728. const state = activeRouteState.value
  729. if (!state) return
  730. const previous = state.fastMode ?? false
  731. state.fastMode = enabled
  732. state.fastModeDirty = true
  733. persistFastMode(state)
  734. if (!state.runtimeSessionId || state.isStreaming || !['connected', 'ready'].includes(connectionState.value)) return
  735. try {
  736. await socket.request('config.set', {
  737. session_id: state.runtimeSessionId, key: 'fast', value: enabled ? 'fast' : 'normal', scope: 'session',
  738. })
  739. state.serverFastMode = enabled
  740. state.fastModeDirty = false
  741. } catch (cause) {
  742. state.fastMode = previous
  743. state.fastModeDirty = false
  744. persistFastMode(state)
  745. error.value = `快速模式未同步:${errorMessage(cause)}`
  746. throw cause
  747. }
  748. }
  749. async function refreshContextUsage(): Promise<void> {
  750. const state = activeRouteState.value
  751. if (!state) return
  752. const current = await ensureRuntime(state)
  753. const usage = resultRecord(await socket.request('session.usage', { session_id: current.runtimeSessionId! }))
  754. current.usage = {
  755. inputTokens: number(usage.input_tokens ?? usage.input) || undefined,
  756. outputTokens: number(usage.output_tokens ?? usage.output) || undefined,
  757. totalTokens: number(usage.total_tokens ?? usage.total) || undefined,
  758. contextTokens: number(usage.context_tokens ?? usage.context_used ?? usage.used) || undefined,
  759. contextLimit: number(usage.context_limit ?? usage.context_max ?? usage.limit) || undefined,
  760. percentUsed: number(usage.percent_used ?? usage.context_percent) || undefined,
  761. raw: usage as JsonValue,
  762. }
  763. }
  764. return {
  765. sessions, activeSessionId, activeProfileName, activeSession, routes, activeRouteState, messages,
  766. connectionState, historySynced, hasMoreBefore, isLoading, isLoadingMoreSessions, hasMoreSessions, isSending, isStreaming, isQueued,
  767. error, models, selectedModel, reasoningEffort, fastMode, contextUsage, pendingApproval, pendingClarification, unreadCounts,
  768. loadSessions, loadMoreSessions, selectSession, loadOlder, createSession, connect, disconnect, send, interrupt,
  769. respondToApproval, respondToClarification, branchSession, renameSession, setSessionPinned, removeSession,
  770. loadModels, setModel, setFastMode, refreshContextUsage, loadUnread, markRead, switchProfile,
  771. }
  772. })