Przeglądaj źródła

Merge remote-tracking branch 'origin/main'

# Conflicts:
#	tests/client/chatCrossClientSync.test.ts
Samien 1 miesiąc temu
rodzic
commit
74bdb399f2

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

@@ -0,0 +1,47 @@
+# 普通聊天模型切换确认与 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 在缺少上下文值时会回退到累计用量。此次未改动该显示逻辑。
+
+首次排查完成源码修复与提交后,用户继续反馈卡片自动消失、点击允许无效,随后完成以下部署与验收。
+
+## 实际部署与浏览器验收
+
+- 运行服务原来是提交 `6396efc`,尚未包含此次修复。
+- 执行 `npm run desktop:build`,通过 `bin/desktop-service-sync.mjs` 更新现有 15300 服务。同版本构建先后关系无法自动判断,核对目标后使用更新器的 `force: true` 覆盖流程,保留更新前数据备份。
+- 当前服务构建:`af061893a4e6471f06258af10d918b48dd92f722`,资源摘要 `d6bfeae0acc817a99917b09ab5b0845bf6733d969e5de0693a7fa9480687d3c1`。`/healthz`、`/readyz` 均成功。
+- 在新服务的真实认证浏览器页面中打开原会话并重新加载,选择器与服务器均返回 `grok-4.7`。
+- Hermes 日志确认:用户 08:53:26 发送的“测试”已使用 `grok-4.7`,08:53:47 完成回复;此次验收没有额外向该会话发送聊天消息。
+- 新增 `tests/e2e/model-confirmation.spec.ts`:在隔离测试服务中渲染完整聊天页面,模拟供应商模型确认响应,经过两次真实 transcript SSE 重连后卡片仍可见;实际点击“允许”发送 `confirm_expensive_model: true`,随后聊天提交成功。该测试使用假 Gateway,不计入真实供应商调用证据。
+- 同一浏览器测试分别加载旧部署 UI 和新 UI:旧 UI 在重连后丢失卡片、测试失败;新 UI 全流程通过。
+- 远端已打开的页面需要重新加载才能取得新资源;没有直接操作或验收用户另一台电脑的桌面进程。没有重启 Hermes。

+ 21 - 9
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
@@ -145,7 +147,9 @@ export const useChatStore = defineStore('chat', () => {
         })
         const ids=new Set(canonical.flatMap(m=>[m.id,m.clientMessageId].filter(Boolean)))
         const pending=initial.messages.filter(m=>m.role==='user'&&m.stage!=='settled'&&!ids.has(m.id)&&!ids.has(m.clientMessageId))
-        const messages=[...canonical,...pending]
+        // Unsent local inputs keep their original time among server messages;
+        // appending the outbox would move old failures below every new reply.
+        const messages=mergeChatMessages(canonical,pending)
         const {messages:_,...checkpoint}=value
         await historyCache.set(scope,id,{messages,total:value.total,savedAt:Date.now(),transcript:checkpoint},true)
         const beforeOutbox=current();if(!beforeOutbox)return
@@ -155,7 +159,7 @@ export const useChatStore = defineStore('chat', () => {
         // the current route after both writes, preserving any newer local send.
         const latest=current();if(!latest)return
         const latestPending=latest.messages.filter(m=>m.role==='user'&&m.stage!=='settled'&&!ids.has(m.id)&&!ids.has(m.clientMessageId))
-        latest.messages=[...canonical,...latestPending];latest.messageTotal=value.total;latest.loadedMessageCount=canonical.length
+        latest.messages=mergeChatMessages(canonical,latestPending);latest.messageTotal=value.total;latest.loadedMessageCount=canonical.length
         const approval=value.pendingApproval,clarification=value.pendingClarification
         latest.pendingApproval=approval?{id:string(approval.request_id??approval.id),sessionId:id,message:string(approval.message??approval.prompt),toolName:string(approval.tool_name),choices:values(approval.choices).map(String),payload:approval as Record<string,JsonValue>}:undefined
         latest.pendingClarification=clarification?{id:string(clarification.request_id??clarification.id),sessionId:id,question:string(clarification.question??clarification.prompt),payload:clarification as Record<string,JsonValue>}:undefined
@@ -188,7 +192,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 {
@@ -894,13 +903,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
@@ -980,14 +988,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
@@ -999,6 +1007,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
@@ -1009,6 +1018,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 {

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

@@ -177,6 +177,84 @@ describe('ordinary v2 cross-client store', () => {
     expect(submission.text).toBe('[用户附加图片:photo.png]\n[screenshot]')
     expect(chat.messages[0]!.attachments?.[0]?.url).toBeUndefined()
   })
+  it.each(['failed', 'unknown-receipt'] as const)('keeps an older %s input before newer messages through sync and cache restoration', async stage => {
+    const client = await open()
+    const oldAnswer = answer('之前的回复', { timestamp: 10 })
+    await client.changed(snapshot([oldAnswer]))
+    chat.activeRouteState!.messages.push({
+      id: 'local-failed', clientMessageId: 'local-failed', sessionId: 's', profile: 'p',
+      role: 'user', content: '再试试', timestamp: 15, stage,
+    })
+    const newerUser = { id: 'new-user', seq: 3, revision: 1, role: 'user', content: '再试试', timestamp: 20, status: 'complete' }
+    const newerAnswer = answer('新的回复', { id: 'new-answer', seq: 4, timestamp: 21 })
+    const expected = ['canonical-answer', 'local-failed', 'new-user', 'new-answer']
+    await client.changed(snapshot([oldAnswer, newerUser, newerAnswer], 's', { cursor: 2 }))
+    expect(chat.messages.map(m => m.id)).toEqual(expected)
+    await client.changed(snapshot([oldAnswer, newerUser, { ...newerAnswer, revision: 2 }], 's', { cursor: 3 }))
+    expect(chat.messages.map(m => m.id)).toEqual(expected)
+    const cached = await new ScopedCache<{ messages: { id: string }[] }>('chat-history-v1').get(`v2-viewer-${wire.owner}:p`, 's')
+    expect(cached?.messages.map(m => m.id)).toEqual(expected)
+
+    chat.disconnect()
+    setActivePinia(createPinia())
+    chat = useChatStore()
+    chat.sessions = [session()]
+    wire.clients = []
+    const restored = await open()
+    expect(chat.messages.map(m => m.id)).toEqual(expected)
+    await restored.changed(snapshot([oldAnswer, newerUser, newerAnswer], 's', { cursor: 4 }))
+    expect(chat.messages.map(m => m.id)).toEqual(expected)
+    expect(chat.messages.find(m => m.id === 'local-failed')?.stage).toBe(stage)
+  })
+  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 }))

+ 62 - 0
tests/e2e/model-confirmation.spec.ts

@@ -0,0 +1,62 @@
+import { test, expect } from '@playwright/test'
+
+test('model confirmation survives reconnects and failed inputs retain their position after Allow, new replies and reload', async ({ page, browser }) => {
+  const modelCommands: Record<string, unknown>[] = []
+  let nativeApprovals = 0
+  await page.route('**/api/realtime/channels/*/commands', async route => {
+    const command = route.request().postDataJSON()
+    if (command.method === 'approval.respond') nativeApprovals++
+    if (command.method !== 'config.set' || command.params.key !== 'model') return route.continue()
+    modelCommands.push(command.params)
+    const result = command.params.confirm_expensive_model === true
+      ? { key: 'model', value: 'gpt-5.5', scope: 'session' }
+      : { confirm_required: true, confirm_message: '长上下文切换验收:请确认重新读取缓存。' }
+    await route.fulfill({ json: { state: 'confirmed', response: { result } } })
+  })
+  await page.goto('/chat/session-demo?profile=yaoyao')
+  if (await page.getByRole('heading', { name: '登录夭夭' }).isVisible()) {
+    await page.getByLabel('账号').fill('admin')
+    await page.getByLabel('密码').fill('e2e-password')
+    await page.getByRole('button', { name: '登录', exact: true }).click()
+  }
+  await page.goto('/chat/session-demo?profile=yaoyao')
+  await expect(page.locator('.composer-tool--model')).toContainText('gpt-5.6')
+  await page.locator('.composer-tool--model').click()
+  await page.locator('.model-dialog__item').filter({ hasText: 'gpt-5.5' }).click()
+  // History can be opened without resuming Hermes; sending applies the pending pick.
+  await page.locator('.composer-textarea').fill('等待模型确认')
+  await page.getByRole('button', { name: '发送消息', exact: true }).click()
+  const confirmation = page.locator('.interaction-card--approval')
+  await expect(confirmation).toContainText('长上下文切换验收')
+  for (let i = 0; i < 2; i++) {
+    const resumed = page.waitForResponse(response => response.url().includes('/api/app/chat/sessions/session-demo/events'))
+    await page.evaluate(() => document.dispatchEvent(new Event('visibilitychange')))
+    await resumed
+    await expect(confirmation).toBeVisible()
+  }
+  await confirmation.getByRole('button', { name: '允许', exact: true }).click()
+  await expect(confirmation).toBeHidden()
+  expect(modelCommands).toHaveLength(2)
+  expect(modelCommands[1]).toMatchObject({ confirm_expensive_model: true, value: 'gpt-5.5 --provider openai --session' })
+  expect(nativeApprovals).toBe(0)
+  await page.locator('.composer-textarea').fill('模型确认后发送')
+  await page.getByRole('button', { name: '发送消息', exact: true }).click()
+  await expect(page.locator('.message__content').filter({ hasText: '这是来自假 Gateway 的流式回复。' })).toHaveCount(1)
+  expect(modelCommands).toHaveLength(2)
+  const relevantMessages = ['等待模型确认', '模型确认后发送', '这是来自假 Gateway 的流式回复。']
+  const visibleOrder = async () => (await page.locator('.message__content').allTextContents())
+    .map(text => text.trim()).filter(text => relevantMessages.includes(text))
+  await expect.poll(visibleOrder).toEqual(relevantMessages)
+  await page.reload()
+  await expect.poll(visibleOrder).toEqual(relevantMessages)
+  await expect(page.locator('.message--failed').filter({ hasText: '等待模型确认' })).toHaveCount(1)
+
+  // A fresh device gets the accepted transcript, not this browser's unsent outbox.
+  const other = await browser.newContext({ storageState: await page.context().storageState() })
+  try {
+    const otherPage = await other.newPage()
+    await otherPage.goto(page.url())
+    await expect(otherPage.locator('.message__content').filter({ hasText: '模型确认后发送' })).toHaveCount(1)
+    await expect(otherPage.locator('.message__content').filter({ hasText: '等待模型确认' })).toHaveCount(0)
+  } finally { await other.close() }
+})