Преглед на файлове

实现会话模型跨端同步

LSamien преди 2 месеца
родител
ревизия
f933597a7d

+ 59 - 6
src/client/stores/chat.ts

@@ -4,7 +4,7 @@ import type {
   ChatAttachment, ChatMessage, ChatRouteState, JsonValue, ModelOption, RealtimeConnectionState, SessionSummary,
   ChatAttachment, ChatMessage, ChatRouteState, JsonValue, ModelOption, RealtimeConnectionState, SessionSummary,
 } from '@shared/types'
 } from '@shared/types'
 import {
 import {
-  deleteSession as deleteSessionApi, getMessages, getSessions, getSessionUnread, markSessionRead, updateSession,
+  deleteSession as deleteSessionApi, getMessages, getSession, getSessions, getSessionUnread, markSessionRead, updateSession,
 } from '@/api/sessions'
 } from '@/api/sessions'
 import { getModels } from '@/api/profiles'
 import { getModels } from '@/api/profiles'
 import { ChatRpcSocket, RpcError } from '@/api/realtime'
 import { ChatRpcSocket, RpcError } from '@/api/realtime'
@@ -15,7 +15,7 @@ import { createId, routeKey } from '@/utils/id'
 import { applyChatEvent, mergeChatMessages } from '@/utils/messageReducer'
 import { applyChatEvent, mergeChatMessages } from '@/utils/messageReducer'
 import { bool, normalizeChatMessage, number, record, string, values } from '@/utils/normalize'
 import { bool, normalizeChatMessage, number, record, string, values } from '@/utils/normalize'
 import { appendSessionPage, pinnedSessionsFirst } from '@/utils/sessionOrder'
 import { appendSessionPage, pinnedSessionsFirst } from '@/utils/sessionOrder'
-import { modelForSession } from '@/utils/sessionModel'
+import { modelForSession, modelSelectionFromSessionInfo } from '@/utils/sessionModel'
 import { moveSessionFastMode, readSessionFastMode, writeSessionFastMode } from '@/utils/sessionPreferences'
 import { moveSessionFastMode, readSessionFastMode, writeSessionFastMode } from '@/utils/sessionPreferences'
 import { useAuthStore } from './auth'
 import { useAuthStore } from './auth'
 
 
@@ -145,6 +145,27 @@ export const useChatStore = defineStore('chat', () => {
     if (resolved) selectedModel.value = resolved
     if (resolved) selectedModel.value = resolved
   }
   }
 
 
+  function restoreSelectedSessionModel(key: string): void {
+    const activeKey = activeSessionId.value && activeProfileName.value
+      ? routeKey(activeProfileName.value, activeSessionId.value)
+      : undefined
+    if (key === activeKey) syncSelectedModel(activeSession.value?.model, activeSession.value?.provider)
+  }
+
+  function reconcileSessionModel(profile: string, sessionId: string, model: string, provider?: string): void {
+    const key = routeKey(profile, sessionId)
+    const sessionIndex = sessions.value.findIndex(session => session.id === sessionId && session.profile === profile)
+    if (sessionIndex >= 0) {
+      sessions.value.splice(sessionIndex, 1, {
+        ...sessions.value[sessionIndex], model, provider,
+      })
+    }
+    const activeKey = activeSessionId.value && activeProfileName.value
+      ? routeKey(activeProfileName.value, activeSessionId.value)
+      : undefined
+    if (key === activeKey && !desiredModelRoutes.has(key)) syncSelectedModel(model, provider)
+  }
+
   function cacheScope(profile: string): string {
   function cacheScope(profile: string): string {
     return `${auth.user?.id ?? 'anonymous'}:${profile}`
     return `${auth.user?.id ?? 'anonymous'}:${profile}`
   }
   }
@@ -267,6 +288,10 @@ export const useChatStore = defineStore('chat', () => {
         state = migrateRoute(key, storedId, runtimeId)
         state = migrateRoute(key, storedId, runtimeId)
         key = routeKey(state.route.profile, storedId)
         key = routeKey(state.route.profile, storedId)
       }
       }
+      const liveSelection = modelSelectionFromSessionInfo(payload)
+      if (liveSelection) {
+        reconcileSessionModel(state.route.profile, state.route.sessionId, liveSelection.model, liveSelection.provider)
+      }
     }
     }
     const info = record(payload.info)
     const info = record(payload.info)
     const liveFastMode = optionalBoolean(payload.fast ?? info.fast)
     const liveFastMode = optionalBoolean(payload.fast ?? info.fast)
@@ -749,8 +774,20 @@ export const useChatStore = defineStore('chat', () => {
       const target = routes[modelConfirmation.routeKey]
       const target = routes[modelConfirmation.routeKey]
       if (!target) throw new Error('模型确认请求已失效')
       if (!target) throw new Error('模型确认请求已失效')
       const modelState = await ensureRuntime(target)
       const modelState = await ensureRuntime(target)
-      if (choice !== 'deny') {
-        await configureModel(modelState, modelConfirmation.model, true)
+      if (choice === 'deny') {
+        desiredModelRoutes.delete(modelConfirmation.routeKey)
+        restoreSelectedSessionModel(modelConfirmation.routeKey)
+      } else {
+        try {
+          if (await configureModel(modelState, modelConfirmation.model, true)) {
+            desiredModelRoutes.delete(modelConfirmation.routeKey)
+          }
+        } catch (cause) {
+          desiredModelRoutes.delete(modelConfirmation.routeKey)
+          restoreSelectedSessionModel(modelConfirmation.routeKey)
+          modelState.pendingApproval = undefined
+          throw cause
+        }
       }
       }
       modelState.pendingApproval = undefined
       modelState.pendingApproval = undefined
       return
       return
@@ -815,6 +852,16 @@ export const useChatStore = defineStore('chat', () => {
       ?? models.value.find(model => model.isDefault) ?? models.value[0]
       ?? models.value.find(model => model.isDefault) ?? models.value[0]
   }
   }
 
 
+  async function refreshActiveSessionModel(): Promise<void> {
+    const state = activeRouteState.value
+    if (!state || state.route.sessionId.startsWith('draft-')) return
+    const expectedKey = routeKey(state.route.profile, state.route.sessionId)
+    const refreshed = await getSession(state.route.sessionId, state.route.profile)
+    const current = activeRouteState.value
+    if (!current || routeKey(current.route.profile, current.route.sessionId) !== expectedKey || !refreshed.model) return
+    reconcileSessionModel(current.route.profile, current.route.sessionId, refreshed.model, refreshed.provider)
+  }
+
   async function configureModel(state: ChatRouteState, model: ModelOption, confirmsExpensiveModel: boolean): Promise<boolean> {
   async function configureModel(state: ChatRouteState, model: ModelOption, confirmsExpensiveModel: boolean): Promise<boolean> {
     const result = resultRecord(await socket.request('config.set', {
     const result = resultRecord(await socket.request('config.set', {
       session_id: state.runtimeSessionId!,
       session_id: state.runtimeSessionId!,
@@ -855,7 +902,13 @@ export const useChatStore = defineStore('chat', () => {
     const key = routeKey(state.route.profile, state.route.sessionId)
     const key = routeKey(state.route.profile, state.route.sessionId)
     desiredModelRoutes.add(key)
     desiredModelRoutes.add(key)
     if (!state.runtimeSessionId) return
     if (!state.runtimeSessionId) return
-    if (await configureModel(state, model, false)) desiredModelRoutes.delete(key)
+    try {
+      if (await configureModel(state, model, false)) desiredModelRoutes.delete(key)
+    } catch (cause) {
+      desiredModelRoutes.delete(key)
+      restoreSelectedSessionModel(key)
+      throw cause
+    }
   }
   }
 
 
   async function setFastMode(enabled: boolean): Promise<void> {
   async function setFastMode(enabled: boolean): Promise<void> {
@@ -902,6 +955,6 @@ export const useChatStore = defineStore('chat', () => {
     error, models, selectedModel, reasoningEffort, fastMode, contextUsage, pendingApproval, pendingClarification, unreadCounts,
     error, models, selectedModel, reasoningEffort, fastMode, contextUsage, pendingApproval, pendingClarification, unreadCounts,
     loadSessions, loadMoreSessions, selectSession, loadOlder, createSession, connect, disconnect, send, interrupt,
     loadSessions, loadMoreSessions, selectSession, loadOlder, createSession, connect, disconnect, send, interrupt,
     respondToApproval, respondToClarification, branchSession, renameSession, setSessionPinned, removeSession,
     respondToApproval, respondToClarification, branchSession, renameSession, setSessionPinned, removeSession,
-    loadModels, setModel, setFastMode, refreshContextUsage, loadUnread, markRead, switchProfile,
+    loadModels, setModel, setFastMode, refreshActiveSessionModel, refreshContextUsage, loadUnread, markRead, switchProfile,
   }
   }
 })
 })

+ 9 - 2
src/client/utils/normalize.ts

@@ -53,6 +53,12 @@ function pick(source: UnknownRecord, ...keys: string[]): unknown {
   return undefined
   return undefined
 }
 }
 
 
+function jsonRecord(value: unknown): UnknownRecord {
+  const direct = record(value)
+  if (Object.keys(direct).length || typeof value !== 'string') return direct
+  try { return record(JSON.parse(value)) } catch { return {} }
+}
+
 export function normalizeUser(value: unknown): CurrentUser {
 export function normalizeUser(value: unknown): CurrentUser {
   const source = record(value)
   const source = record(value)
   const id = string(pick(source, 'id', 'user_id', 'userId')) || string(pick(source, 'email', 'username')) || 'local'
   const id = string(pick(source, 'id', 'user_id', 'userId')) || string(pick(source, 'email', 'username')) || 'local'
@@ -103,6 +109,7 @@ export function normalizeModel(value: unknown): ModelOption {
 
 
 export function normalizeSession(value: unknown, fallbackProfile?: string): SessionSummary {
 export function normalizeSession(value: unknown, fallbackProfile?: string): SessionSummary {
   const source = record(value)
   const source = record(value)
+  const modelConfig = jsonRecord(pick(source, 'model_config', 'modelConfig'))
   const startedAt = number(pick(source, 'started_at', 'startedAt', 'created_at', 'createdAt'))
   const startedAt = number(pick(source, 'started_at', 'startedAt', 'created_at', 'createdAt'))
   const updatedAt = number(pick(source, 'last_active', 'lastActive', 'updated_at', 'updatedAt'), startedAt)
   const updatedAt = number(pick(source, 'last_active', 'lastActive', 'updated_at', 'updatedAt'), startedAt)
   const id = string(pick(source, 'id', 'session_id', 'sessionId', 'stored_session_id', 'storedSessionId'))
   const id = string(pick(source, 'id', 'session_id', 'sessionId', 'stored_session_id', 'storedSessionId'))
@@ -112,8 +119,8 @@ export function normalizeSession(value: unknown, fallbackProfile?: string): Sess
     source: string(source.source, 'cli'),
     source: string(source.source, 'cli'),
     title: string(source.title, '未命名会话'),
     title: string(source.title, '未命名会话'),
     preview: string(source.preview) || undefined,
     preview: string(source.preview) || undefined,
-    model: string(source.model) || undefined,
-    provider: string(source.provider) || undefined,
+    model: string(source.model ?? modelConfig.model) || undefined,
+    provider: string(source.provider ?? modelConfig.provider) || undefined,
     agent: string(source.agent) || undefined,
     agent: string(source.agent) || undefined,
     messageCount: number(pick(source, 'message_count', 'messageCount')),
     messageCount: number(pick(source, 'message_count', 'messageCount')),
     toolCallCount: number(pick(source, 'tool_call_count', 'toolCallCount')),
     toolCallCount: number(pick(source, 'tool_call_count', 'toolCallCount')),

+ 15 - 0
src/client/utils/sessionModel.ts

@@ -1,4 +1,10 @@
 import type { ModelOption } from '@shared/types'
 import type { ModelOption } from '@shared/types'
+import { record, string } from '@/utils/normalize'
+
+export interface SessionModelSelection {
+  model: string
+  provider?: string
+}
 
 
 export function modelForSession(options: ModelOption[], model?: string, provider?: string): ModelOption | undefined {
 export function modelForSession(options: ModelOption[], model?: string, provider?: string): ModelOption | undefined {
   const id = model?.trim()
   const id = model?.trim()
@@ -12,3 +18,12 @@ export function modelForSession(options: ModelOption[], model?: string, provider
   if (byId) return byId
   if (byId) return byId
   return { id, name: id, provider: provider?.trim() || '' }
   return { id, name: id, provider: provider?.trim() || '' }
 }
 }
+
+export function modelSelectionFromSessionInfo(payload: unknown): SessionModelSelection | undefined {
+  const source = record(payload)
+  const info = record(source.info)
+  const model = string(source.model ?? info.model).trim()
+  if (!model) return undefined
+  const provider = string(source.provider ?? info.provider).trim()
+  return { model, provider: provider || undefined }
+}

+ 7 - 0
src/client/views/ChatView.vue

@@ -48,6 +48,7 @@ const composer = ref<InstanceType<typeof ComposerShell> | null>(null)
 const timeline = ref<InstanceType<typeof MessageTimeline> | null>(null)
 const timeline = ref<InstanceType<typeof MessageTimeline> | null>(null)
 const headerSessionMenuButton = ref<HTMLButtonElement | null>(null)
 const headerSessionMenuButton = ref<HTMLButtonElement | null>(null)
 const restoringRouteProfile = ref('')
 const restoringRouteProfile = ref('')
+let modelSyncTimer: number | undefined
 
 
 const sessions = computed(() => [...chat.sessions]
 const sessions = computed(() => [...chat.sessions]
   .filter(session => !['cron', 'ios_group'].includes(session.source)))
   .filter(session => !['cron', 'ios_group'].includes(session.source)))
@@ -333,6 +334,10 @@ onMounted(async () => {
   document.addEventListener('keydown', handleSessionActionKey)
   document.addEventListener('keydown', handleSessionActionKey)
   await refreshSessions()
   await refreshSessions()
   await Promise.allSettled([chat.loadModels(auth.activeProfile?.name), chat.connect()])
   await Promise.allSettled([chat.loadModels(auth.activeProfile?.name), chat.connect()])
+  void chat.refreshActiveSessionModel().catch(() => undefined)
+  modelSyncTimer = window.setInterval(() => {
+    if (document.visibilityState === 'visible') void chat.refreshActiveSessionModel().catch(() => undefined)
+  }, 3_000)
   await revealSourceMessage()
   await revealSourceMessage()
   const pendingFile = await consumeLibraryItemForComposer()
   const pendingFile = await consumeLibraryItemForComposer()
   if (pendingFile) composer.value?.attachFiles([pendingFile])
   if (pendingFile) composer.value?.attachFiles([pendingFile])
@@ -341,6 +346,7 @@ onMounted(async () => {
 onBeforeUnmount(() => {
 onBeforeUnmount(() => {
   document.removeEventListener('pointerdown', handleSessionActionPointer)
   document.removeEventListener('pointerdown', handleSessionActionPointer)
   document.removeEventListener('keydown', handleSessionActionKey)
   document.removeEventListener('keydown', handleSessionActionKey)
+  if (modelSyncTimer !== undefined) window.clearInterval(modelSyncTimer)
 })
 })
 
 
 watch(() => auth.activeProfile?.name, async (profile, previous) => {
 watch(() => auth.activeProfile?.name, async (profile, previous) => {
@@ -365,6 +371,7 @@ watch(() => auth.activeProfile?.name, async (profile, previous) => {
 
 
 watch(() => chat.activeSessionId, async id => {
 watch(() => chat.activeSessionId, async id => {
   const profile = chat.activeProfileName || auth.activeProfile?.name || ''
   const profile = chat.activeProfileName || auth.activeProfile?.name || ''
+  if (id) void chat.refreshActiveSessionModel().catch(() => undefined)
   if (!id || (route.params.sessionId === id && routeProfile() === profile)) return
   if (!id || (route.params.sessionId === id && routeProfile() === profile)) return
   await router.replace(sessionLocation(id, profile))
   await router.replace(sessionLocation(id, profile))
 })
 })

+ 137 - 0
tests/client/chatModelSync.test.ts

@@ -0,0 +1,137 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { createPinia, setActivePinia } from 'pinia'
+import { routeKey } from '@/utils/id'
+
+const realtime = vi.hoisted(() => ({
+  eventHandler: undefined as ((event: unknown) => void) | undefined,
+  request: vi.fn(),
+}))
+const sessionsApi = vi.hoisted(() => ({ getSession: vi.fn() }))
+
+vi.mock('@/api/realtime', () => {
+  class RpcError extends Error {}
+  class ChatRpcSocket {
+    onState(): void {}
+    onEvent(handler: (event: unknown) => void): void { realtime.eventHandler = handler }
+    async connect(): Promise<void> {}
+    close(): void {}
+    async request(...args: unknown[]): Promise<unknown> { return realtime.request(...args) }
+  }
+  return { ChatRpcSocket, RpcError }
+})
+
+vi.mock('@/api/sessions', () => ({
+  deleteSession: vi.fn(),
+  getMessages: vi.fn(),
+  getSession: sessionsApi.getSession,
+  getSessions: vi.fn(),
+  getSessionUnread: vi.fn(),
+  markSessionRead: vi.fn(),
+  updateSession: vi.fn(),
+}))
+vi.mock('@/api/profiles', () => ({ getModels: vi.fn() }))
+vi.mock('@/stores/auth', () => ({
+  useAuthStore: () => ({
+    user: { id: 'account-1' },
+    activeProfile: { name: 'alpha' },
+    status: 'authenticated',
+  }),
+}))
+
+import { useChatStore } from '@/stores/chat'
+
+describe('chat model realtime synchronization', () => {
+  beforeEach(() => {
+    realtime.eventHandler = undefined
+    realtime.request.mockReset().mockResolvedValue({})
+    sessionsApi.getSession.mockReset()
+    setActivePinia(createPinia())
+  })
+
+  it('applies another viewer model switch to the active selector and session summary', () => {
+    const chat = useChatStore()
+    const oldModel = { id: 'model-a', name: 'Model A', provider: 'provider-a' }
+    const newModel = { id: 'model-b', name: 'Model B', provider: 'provider-b' }
+    chat.models = [oldModel, newModel]
+    chat.sessions = [{
+      id: 'session-1', profile: 'alpha', source: 'web', title: '会话',
+      model: oldModel.id, provider: oldModel.provider, messageCount: 0,
+      toolCallCount: 0, startedAt: 1, updatedAt: 1,
+    }]
+    chat.activeSessionId = 'session-1'
+    chat.activeProfileName = 'alpha'
+    chat.routes[routeKey('alpha', 'session-1')] = {
+      route: { profile: 'alpha', sessionId: 'session-1' },
+      runtimeSessionId: 'runtime-1', messages: [], historySynced: true,
+      hasMoreBefore: false, loadedMessageCount: 0, messageTotal: 0,
+      isLoadingHistory: false, isStreaming: false, isQueued: false, generation: 1,
+    }
+    chat.selectedModel = oldModel
+
+    realtime.eventHandler?.({
+      type: 'session.info', session_id: 'runtime-1',
+      payload: { info: { model: newModel.id, provider: newModel.provider } },
+    })
+
+    expect(chat.selectedModel).toEqual(newModel)
+    expect(chat.sessions[0]).toMatchObject({ model: newModel.id, provider: newModel.provider })
+  })
+
+  it('reconciles the active model through REST when another client owns realtime events', async () => {
+    const chat = useChatStore()
+    const oldModel = { id: 'model-a', name: 'Model A', provider: 'provider-a' }
+    const newModel = { id: 'model-b', name: 'Model B', provider: 'provider-b' }
+    chat.models = [oldModel, newModel]
+    chat.sessions = [{
+      id: 'session-1', profile: 'alpha', source: 'web', title: '会话',
+      model: oldModel.id, provider: oldModel.provider, messageCount: 0,
+      toolCallCount: 0, startedAt: 1, updatedAt: 1,
+    }]
+    chat.activeSessionId = 'session-1'
+    chat.activeProfileName = 'alpha'
+    chat.routes[routeKey('alpha', 'session-1')] = {
+      route: { profile: 'alpha', sessionId: 'session-1' }, messages: [], historySynced: true,
+      hasMoreBefore: false, loadedMessageCount: 0, messageTotal: 0,
+      isLoadingHistory: false, isStreaming: false, isQueued: false, generation: 1,
+    }
+    chat.selectedModel = oldModel
+    sessionsApi.getSession.mockResolvedValue({
+      ...chat.sessions[0], model: newModel.id, provider: newModel.provider,
+    })
+
+    await chat.refreshActiveSessionModel()
+
+    expect(sessionsApi.getSession).toHaveBeenCalledWith('session-1', 'alpha')
+    expect(chat.selectedModel).toEqual(newModel)
+  })
+
+  it('releases the local model guard after a failed switch', async () => {
+    const chat = useChatStore()
+    const oldModel = { id: 'model-a', name: 'Model A', provider: 'provider-a' }
+    const newModel = { id: 'model-b', name: 'Model B', provider: 'provider-b' }
+    chat.models = [oldModel, newModel]
+    chat.sessions = [{
+      id: 'session-1', profile: 'alpha', source: 'web', title: '会话',
+      model: oldModel.id, provider: oldModel.provider, messageCount: 0,
+      toolCallCount: 0, startedAt: 1, updatedAt: 1,
+    }]
+    chat.activeSessionId = 'session-1'
+    chat.activeProfileName = 'alpha'
+    chat.routes[routeKey('alpha', 'session-1')] = {
+      route: { profile: 'alpha', sessionId: 'session-1' }, runtimeSessionId: 'runtime-1',
+      messages: [], historySynced: true, hasMoreBefore: false, loadedMessageCount: 0,
+      messageTotal: 0, isLoadingHistory: false, isStreaming: false, isQueued: false, generation: 1,
+    }
+    chat.selectedModel = oldModel
+    realtime.request.mockRejectedValueOnce(new Error('switch failed'))
+
+    await expect(chat.setModel(newModel)).rejects.toThrow('switch failed')
+    expect(chat.selectedModel).toEqual(oldModel)
+
+    realtime.eventHandler?.({
+      type: 'session.info', session_id: 'runtime-1',
+      payload: { model: newModel.id, provider: newModel.provider },
+    })
+    expect(chat.selectedModel).toEqual(newModel)
+  })
+})

+ 19 - 1
tests/client/sessionModel.test.ts

@@ -1,5 +1,6 @@
 import { describe, expect, it } from 'vitest'
 import { describe, expect, it } from 'vitest'
-import { modelForSession } from '@/utils/sessionModel'
+import { modelForSession, modelSelectionFromSessionInfo } from '@/utils/sessionModel'
+import { normalizeSession } from '@/utils/normalize'
 
 
 const models = [
 const models = [
   { id: 'gpt-5.6-terra', name: 'GPT 5.6 Terra', provider: 'custom' },
   { id: 'gpt-5.6-terra', name: 'GPT 5.6 Terra', provider: 'custom' },
@@ -14,4 +15,21 @@ describe('session model selection', () => {
   it('keeps an unavailable stored model visible until the user chooses another', () => {
   it('keeps an unavailable stored model visible until the user chooses another', () => {
     expect(modelForSession(models, 'archived-model', 'legacy')).toEqual({ id: 'archived-model', name: 'archived-model', provider: 'legacy' })
     expect(modelForSession(models, 'archived-model', 'legacy')).toEqual({ id: 'archived-model', name: 'archived-model', provider: 'legacy' })
   })
   })
+
+  it('reads model changes from both session.info payload shapes', () => {
+    expect(modelSelectionFromSessionInfo({ model: 'gpt-5.6-sol', provider: 'openai-codex' })).toEqual({
+      model: 'gpt-5.6-sol', provider: 'openai-codex',
+    })
+    expect(modelSelectionFromSessionInfo({ info: { model: 'MiniMax-M3', provider: 'minimax' } })).toEqual({
+      model: 'MiniMax-M3', provider: 'minimax',
+    })
+    expect(modelSelectionFromSessionInfo({ info: { provider: 'minimax' } })).toBeUndefined()
+  })
+
+  it('restores the provider from Hermes model_config session details', () => {
+    expect(normalizeSession({
+      id: 'session-1', model: 'omni',
+      model_config: JSON.stringify({ model: 'omni', provider: 'custom:tingly' }),
+    })).toMatchObject({ model: 'omni', provider: 'custom:tingly' })
+  })
 })
 })