Browse Source

修复聊天同步覆盖模型切换确认并验证 Grok 4.7

Samien 1 tháng trước cách đây
mục cha
commit
af061893a4

+ 36 - 0
docs/verification/2026-09-29-model-confirmation.md

@@ -0,0 +1,36 @@
+# 普通聊天模型切换确认与 Grok 4.7 验证
+
+## 问题与修复
+
+普通聊天把 `config.set` 返回的本地模型确认写入 `ChatRouteState.pendingApproval`。随后普通聊天 transcript 快照中的 `pendingApproval: null` 会清除此卡片,但待切换模型仍在本地保留。下一次发送再次要求确认,表现为模型名称已经改变、消息却发送失败。
+
+模型确认现在单独保存为响应式状态,按会话显示,不再被服务端工具审批快照覆盖。确认后正常提交模型设置;取消或重新选择会清理旧确认,过期确认不会误发为工具审批。等待确认期间重复发送不重复请求模型切换。
+
+## 验证
+
+- 新增回归测试在修复前复现:模型确认经过 transcript 更新后变成 `undefined`。
+- 修复后验证确认保留、确认后发送、无重复模型切换、重新选择清理旧确认,以及与工具审批互不覆盖。
+- 65 项聊天、模型、同步和消息失败相关测试通过。
+- `npm run typecheck` 与 `npm run build` 通过;构建仍有既有的大资源包提示。
+
+## 模型实际请求
+
+2026-09-29 08:51 左右,通过瑶儿 Profile 使用的 Hermes xAI OAuth 凭据解析器,对同一供应商 Responses 接口发送独立的最小请求,未修改聊天内容或会话模型。
+
+- 请求模型:`grok-4.7`
+- 提示:`Reply with exactly OK.`
+- HTTP 状态:200
+- 返回模型:`grok-4.7`
+- 返回状态:`completed`
+- 输出:`OK`
+- 耗时:3.89 秒
+
+本机运行日志也显示问题会话当天成功调用过 Grok 4.7,后续会话实际模型是 Grok 4.6。最小请求证明该账号通道当时可用,不代表已在用户另一台电脑上完成客户端验收。
+
+## 范围说明
+
+Hermes 的长上下文切换确认默认阈值为 100,000 token,该会话最近一次实际输入约 181,833 token。确认是为了提示切换模型后的缓存失效,并非 Grok 4.7 不可用。费用及长上下文确认规则保持原样。
+
+截图的 1015.9k 不能直接解释为当前上下文超限:当前 UI 在缺少上下文值时会回退到累计用量。此次未改动该显示逻辑。
+
+此次仅完成源码修复、本地构建与提交,未发布客户端、替换运行服务或重启 Hermes。

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

@@ -1,7 +1,7 @@
 import { computed, reactive, ref, watch } from 'vue'
 import { defineStore } from 'pinia'
 import type {
-  ChatAttachment, ChatMessage, ChatRouteState, JsonValue, ModelOption, RealtimeConnectionState, SessionSummary,
+  ApprovalRequest, ChatAttachment, ChatMessage, ChatRouteState, JsonValue, ModelOption, RealtimeConnectionState, SessionSummary,
 } from '@shared/types'
 import {
   deleteSession as deleteSessionApi, getMessages, getSession, getSessions, getSessionUnread, markSessionRead, updateSession, requestHistorySync,
@@ -103,7 +103,9 @@ export const useChatStore = defineStore('chat', () => {
   const socket = new ChatRpcSocket()
   const runtimeRoutes = new Map<string, string>()
   const desiredModelRoutes = new Set<string>()
-  const pendingModelConfirmations = new Map<string, { routeKey: string; model: ModelOption }>()
+  // Model selection warnings are local config.set replies, not transcript approvals.
+  // Keep them separate so another viewer's transcript cannot dismiss them.
+  const pendingModelConfirmations = reactive(new Map<string, { routeKey: string; model: ModelOption; approval: ApprovalRequest }>())
   let connectPromise: Promise<void> | undefined
   let reconnectTimer: number | undefined
   let reconnectAttempt = 0
@@ -188,7 +190,12 @@ export const useChatStore = defineStore('chat', () => {
   const isStreaming = computed(() => activeRouteState.value?.isStreaming ?? false)
   const isQueued = computed(() => activeRouteState.value?.isQueued ?? false)
   const contextUsage = computed(() => activeRouteState.value?.usage)
-  const pendingApproval = computed(() => activeRouteState.value?.pendingApproval)
+  const pendingApproval = computed(() => {
+    const state = activeRouteState.value
+    if (!state) return undefined
+    const key = routeKey(state.route.profile, state.route.sessionId)
+    return [...pendingModelConfirmations.values()].find(value => value.routeKey === key)?.approval ?? state.pendingApproval
+  })
   const pendingClarification = computed(() => activeRouteState.value?.pendingClarification)
 
   function ensureRoute(profile: string, sessionId: string): ChatRouteState {
@@ -888,13 +895,12 @@ export const useChatStore = defineStore('chat', () => {
         } catch (cause) {
           desiredModelRoutes.delete(modelConfirmation.routeKey)
           restoreSelectedSessionModel(modelConfirmation.routeKey)
-          modelState.pendingApproval = undefined
           throw cause
         }
       }
-      modelState.pendingApproval = undefined
       return
     }
+    if (state.pendingApproval?.id !== requestId) return
     const current = await ensureRuntime(state)
     await socket.request('approval.respond', { session_id: current.runtimeSessionId!, choice })
     if (current.pendingApproval?.id === requestId) current.pendingApproval = undefined
@@ -974,14 +980,14 @@ export const useChatStore = defineStore('chat', () => {
     }))
     if (bool(result.confirm_required ?? result.confirmRequired)) {
       const requestId = createId('expensive-model')
-      pendingModelConfirmations.set(requestId, { routeKey: routeKey(state.route.profile, state.route.sessionId), model })
-      state.pendingApproval = {
+      const approval: ApprovalRequest = {
         id: requestId,
         sessionId: state.route.sessionId,
         message: string(result.confirm_message ?? result.confirmMessage ?? result.warning, '此模型可能产生较高费用,请确认后使用。'),
         choices: ['once', 'deny'],
         payload: result as Record<string, JsonValue>,
       }
+      pendingModelConfirmations.set(requestId, { routeKey: routeKey(state.route.profile, state.route.sessionId), model, approval })
       return false
     }
     // Match iOS: switching a model invalidates the cached fast-mode server
@@ -993,6 +999,7 @@ export const useChatStore = defineStore('chat', () => {
   async function applyDesiredModel(state: ChatRouteState): Promise<boolean> {
     const key = routeKey(state.route.profile, state.route.sessionId)
     if (!desiredModelRoutes.has(key) || !selectedModel.value || !state.runtimeSessionId) return true
+    if ([...pendingModelConfirmations.values()].some(value => value.routeKey === key)) return false
     const applied = await configureModel(state, selectedModel.value, false)
     if (applied) desiredModelRoutes.delete(key)
     return applied
@@ -1003,6 +1010,9 @@ export const useChatStore = defineStore('chat', () => {
     const state = activeRouteState.value
     if (!state) return
     const key = routeKey(state.route.profile, state.route.sessionId)
+    for (const [id, pending] of pendingModelConfirmations) {
+      if (pending.routeKey === key) pendingModelConfirmations.delete(id)
+    }
     desiredModelRoutes.add(key)
     if (!state.runtimeSessionId) return
     try {

+ 49 - 0
tests/client/chatCrossClientSync.test.ts

@@ -137,6 +137,55 @@ describe('ordinary v2 cross-client store', () => {
     await vi.waitFor(() => expect(wire.clients.some((c) => c.id === id)).toBe(true))
     return wire.clients.filter((c) => c.id === id).at(-1)!
   }
+  it('keeps a model-switch confirmation through transcript updates and sends after confirmation', async () => {
+    const client = await open()
+    const model = { id: 'model-b', name: 'Model B', provider: 'provider-b' }
+    chat.models = [model]
+    wire.request.mockImplementation(async (method, params) => {
+      if (method === 'session.resume') return { session_id: 'runtime', stored_session_id: 's', running: false }
+      if (method === 'config.set' && params.key === 'model') return params.confirm_expensive_model
+        ? { value: model.id, scope: 'session' }
+        : { confirm_required: true, confirm_message: 'Switching this long conversation loses its cached input.' }
+      return {}
+    })
+    await chat.setModel(model)
+    await expect(chat.send('待发送')).rejects.toThrow('请先确认模型切换')
+    const confirmation = chat.pendingApproval
+    expect(confirmation?.message).toContain('cached input')
+    await client.changed(snapshot([answer()], 's', { pendingApproval: null }))
+    expect(chat.pendingApproval).toEqual(confirmation)
+
+    await chat.respondToApproval(confirmation!.id, 'once')
+    expect(wire.request).toHaveBeenCalledWith('config.set', expect.objectContaining({
+      key: 'model', confirm_expensive_model: true,
+    }))
+    expect(chat.pendingApproval).toBeUndefined()
+    wire.request.mockClear()
+    await chat.send('确认后继续')
+    expect(wire.request).toHaveBeenCalledWith('prompt.submit', expect.objectContaining({ text: '确认后继续' }), expect.anything(), expect.anything())
+    expect(wire.request.mock.calls.some(([method, params]) => method === 'config.set' && params.key === 'model')).toBe(false)
+  })
+  it('keeps native approvals separate and dismisses a superseded model confirmation', async () => {
+    const client = await open()
+    const first = { id: 'model-b', provider: 'provider', name: 'B' }
+    const second = { id: 'model-c', provider: 'provider', name: 'C' }
+    wire.request.mockImplementation(async (method, params) => method === 'config.set' && params.key === 'model'
+      ? { confirm_required: true, confirm_message: `Confirm ${params.value}` } : {})
+    await chat.setModel(first)
+    const staleId = chat.pendingApproval!.id
+    await chat.setModel(second)
+    const confirmation = chat.pendingApproval!
+    expect(confirmation.id).not.toBe(staleId)
+    await client.changed(snapshot([], 's', { pendingApproval: { request_id: 'tool-approval', message: '允许工具' } }))
+    expect(chat.pendingApproval?.id).toBe(confirmation.id)
+    wire.request.mockClear()
+    await chat.respondToApproval(staleId, 'once')
+    expect(wire.request).not.toHaveBeenCalled()
+    await chat.respondToApproval(confirmation.id, 'deny')
+    expect(chat.pendingApproval?.id).toBe('tool-approval')
+    await chat.respondToApproval('tool-approval', 'once')
+    expect(wire.request).toHaveBeenCalledWith('approval.respond', expect.objectContaining({ choice: 'once' }))
+  })
   it('renders only canonical messages and ignores raw upstream bodies and completions', async () => {
     const client = await open()
     await client.changed(snapshot([answer('前半', { status: 'streaming' })], 's', { running: true }))