瀏覽代碼

fix: 历史会话同步自身模型

samien 2 月之前
父節點
當前提交
7ebf1133da

+ 17 - 1
src/client/stores/chat.ts

@@ -15,6 +15,7 @@ import { createId, routeKey } from '@/utils/id'
 import { applyChatEvent, mergeChatMessages } from '@/utils/messageReducer'
 import { bool, normalizeChatMessage, number, record, string, values } from '@/utils/normalize'
 import { appendSessionPage, pinnedSessionsFirst } from '@/utils/sessionOrder'
+import { modelForSession } from '@/utils/sessionModel'
 import { moveSessionFastMode, readSessionFastMode, writeSessionFastMode } from '@/utils/sessionPreferences'
 import { useAuthStore } from './auth'
 
@@ -102,6 +103,11 @@ export const useChatStore = defineStore('chat', () => {
     writeSessionFastMode(auth.user?.id ?? 'local', state.route.profile, state.route.sessionId, state.fastMode ?? false)
   }
 
+  function syncSelectedModel(model?: string, provider?: string): void {
+    const resolved = modelForSession(models.value, model, provider)
+    if (resolved) selectedModel.value = resolved
+  }
+
   function cacheScope(profile: string): string {
     return `${auth.user?.id ?? 'anonymous'}:${profile}`
   }
@@ -363,10 +369,13 @@ export const useChatStore = defineStore('chat', () => {
     activeSessionId.value = sessionId
     activeProfileName.value = selectedProfile
     const state = ensureRoute(selectedProfile, sessionId)
+    syncSelectedModel(session?.model, session?.provider)
     // Reading an old session must not rewrite the server's last_active field.
     // The runtime is attached lazily by send/interrupt/usage, or when a known
     // in-flight turn reconnects. REST history remains authoritative here.
     if (!state.historySynced) await loadHistory(state)
+    const refreshed = sessions.value.find(item => item.id === sessionId && item.profile === selectedProfile)
+    syncSelectedModel(refreshed?.model, refreshed?.provider)
   }
 
   async function loadOlder(): Promise<void> {
@@ -414,6 +423,9 @@ export const useChatStore = defineStore('chat', () => {
       if (!runtimeId || !storedId) throw new Error('Hermes 未返回新会话标识')
       const migrated = migrateRoute(initialKey, storedId, runtimeId)
       const info = record(result.info)
+      if (!desiredModelRoutes.has(routeKey(migrated.route.profile, migrated.route.sessionId))) {
+        syncSelectedModel(string(result.model ?? info.model), string(result.provider ?? info.provider))
+      }
       const liveFastMode = optionalBoolean(result.fast ?? info.fast)
       migrated.serverFastMode = liveFastMode ?? migrated.fastMode ?? false
       if (!migrated.fastModeDirty && liveFastMode !== undefined) migrated.fastMode = liveFastMode
@@ -432,6 +444,9 @@ export const useChatStore = defineStore('chat', () => {
     const storedId = string(result.stored_session_id ?? result.storedSessionId ?? result.session_key, initialState.route.sessionId)
     const migrated = migrateRoute(initialKey, storedId, runtimeId)
     const info = record(result.info)
+    if (!desiredModelRoutes.has(routeKey(migrated.route.profile, migrated.route.sessionId))) {
+      syncSelectedModel(string(result.model ?? info.model), string(result.provider ?? info.provider))
+    }
     const liveFastMode = optionalBoolean(result.fast ?? info.fast)
     migrated.serverFastMode = liveFastMode
     if (!migrated.fastModeDirty && liveFastMode !== undefined) migrated.fastMode = liveFastMode
@@ -706,7 +721,8 @@ export const useChatStore = defineStore('chat', () => {
     const loaded = await getModels(profile)
     if (loadGeneration !== modelLoadGeneration || (profile && auth.activeProfile?.name !== profile)) return
     models.value = loaded
-    selectedModel.value = models.value.find(model => model.isDefault) ?? models.value[0]
+    selectedModel.value = modelForSession(models.value, activeSession.value?.model, activeSession.value?.provider)
+      ?? models.value.find(model => model.isDefault) ?? models.value[0]
   }
 
   async function configureModel(state: ChatRouteState, model: ModelOption, confirmsExpensiveModel: boolean): Promise<boolean> {

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

@@ -0,0 +1,14 @@
+import type { ModelOption } from '@shared/types'
+
+export function modelForSession(options: ModelOption[], model?: string, provider?: string): ModelOption | undefined {
+  const id = model?.trim()
+  if (!id) return undefined
+  const normalizedId = id.toLocaleLowerCase()
+  const normalizedProvider = provider?.trim().toLocaleLowerCase() || ''
+  const exact = options.find(option => option.id.toLocaleLowerCase() === normalizedId
+    && (!normalizedProvider || option.provider.toLocaleLowerCase() === normalizedProvider))
+  if (exact) return exact
+  const byId = options.find(option => option.id.toLocaleLowerCase() === normalizedId)
+  if (byId) return byId
+  return { id, name: id, provider: provider?.trim() || '' }
+}

+ 17 - 0
tests/client/sessionModel.test.ts

@@ -0,0 +1,17 @@
+import { describe, expect, it } from 'vitest'
+import { modelForSession } from '@/utils/sessionModel'
+
+const models = [
+  { id: 'gpt-5.6-terra', name: 'GPT 5.6 Terra', provider: 'custom' },
+  { id: 'glm-5.3', name: 'GLM 5.3', provider: 'custom', isDefault: true },
+]
+
+describe('session model selection', () => {
+  it('restores the saved model case-insensitively instead of the profile default', () => {
+    expect(modelForSession(models, 'GPT-5.6-terra', 'CUSTOM')).toEqual(models[0])
+  })
+
+  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' })
+  })
+})

+ 7 - 0
tests/e2e/workspace.spec.ts

@@ -100,6 +100,13 @@ test('keeps pins first and loads the next session page at the list bottom', asyn
   await expect(sidebar.getByRole('button', { name: '继续加载会话' })).toHaveCount(0)
 })
 
+test('switches the composer model to the selected historical session model', async ({ page }) => {
+  await page.goto('/chat/session-demo?profile=yaoyao')
+  await expect(page.locator('.composer-tool--model')).toContainText('gpt-5.6')
+  await page.getByRole('option', { name: /第二个会话/ }).click()
+  await expect(page.locator('.composer-tool--model')).toContainText('gpt-5.5')
+})
+
 test('restores a routed conversation even when unread-count refresh fails', async ({ page }) => {
   await page.route('**/api/app/sessions/unread*', async route => {
     await route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'unread unavailable' }) })

+ 1 - 1
tests/fixtures/fake-hermes.mjs

@@ -35,7 +35,7 @@ const profiles = [
 // This intentionally differs from local timestamp/pin ranking. Browser tests
 // verify that the WebUI preserves the ordering returned by 9119.
 const sessions = [
-  { id: 'session-second', profile: 'yaoyao', source: 'web', title: '第二个会话', preview: '用于验证列表和切换', message_count: 2, tool_call_count: 0, started_at: now() - 7200, last_active_at: now() - 600 },
+  { id: 'session-second', profile: 'yaoyao', source: 'web', title: '第二个会话', preview: '用于验证列表和切换', message_count: 2, tool_call_count: 0, started_at: now() - 7200, last_active_at: now() - 600, model: 'gpt-5.5', provider: 'openai' },
   { id: 'session-demo', profile: 'yaoyao', source: 'web', title: '夭夭 Web 验收会话', preview: '文件库与群聊已经就绪', message_count: 4, tool_call_count: 1, started_at: now() - 3600, last_active_at: now(), pinned: true, model: 'gpt-5.6', provider: 'openai' },
   { id: 'session-yaoer', profile: 'yaoer', source: 'web', title: '瑶儿专属会话', message_count: 1, tool_call_count: 0, started_at: now() - 3500, last_active_at: now() - 20, model: 'gpt-5.6', provider: 'openai' },
   ...Array.from({ length: 101 }, (_, index) => ({ id: `session-page-${index + 1}`, profile: 'yaoyao', source: 'web', title: `分页会话 ${index + 1}`, message_count: 0, tool_call_count: 0, started_at: now() - 10_000 - index, last_active_at: now() - 10_000 - index })),