فهرست منبع

按首版统一 Bot 审批卡片并支持自动允许

Samien 1 ماه پیش
والد
کامیت
8e752b3ad5

+ 46 - 0
design-qa.md

@@ -85,3 +85,49 @@ final result: passed
 - 资料、模型、记忆的未保存更改均受到关闭与切换保护;手机导航支持横向滚动,内容和底部保存操作分别定位。
 - 资料、模型、记忆的未保存更改均受到关闭与切换保护;手机导航支持横向滚动,内容和底部保存操作分别定位。
 - 测试覆盖目标 Bot 保存、普通聊天设置保留、模型 Profile 隔离、远端审批、旧记录默认值、并发独立策略、重复请求、澄清和自动审批失败。
 - 测试覆盖目标 Bot 保存、普通聊天设置保留、模型 Profile 隔离、远端审批、旧记录默认值、并发独立策略、重复请求、澄清和自动审批失败。
 - 最终验证:11 个测试文件共 167 项通过;补充的基础机器人显示名回归测试通过。类型检查与桌面服务构建通过。
 - 最终验证:11 个测试文件共 167 项通过;补充的基础机器人显示名回归测试通过。类型检查与桌面服务构建通过。
+
+## 2026-09-15:聊天审批卡片 · 已选第 1 版
+
+### Findings
+
+- 无未解决的 P0/P1/P2 问题。审批按「允许」「拒绝」「自动允许」横排,明确区分单次答复与 Bot 长期设置。
+- P3:按钮最小高度采用 44px/pt;Android 使用 Material 触控尺寸。图标采用 Tabler / SF Symbols / Material 原生库,细节存在平台差异。
+
+### Source visual truth and evidence
+
+- 用户选中本轮第一张实际展示的生成图:`docs/design/approval-card-reference.png`,853 × 1844,原生成文件 `exec-bf524680-3635-401c-8ab8-74eda9fd1ca4.png`。
+- Web 初查:`docs/design/approval-card-web-before.jpg`;最终:`docs/design/approval-card-web.jpg`,390 × 844。
+- Web 深色长说明:`docs/design/approval-card-web-dark.jpg`,320 × 844。
+- iOS 初查:`docs/design/approval-card-ios-before.jpg`;最终:`docs/design/approval-card-ios.jpg`,440 × 942,来自 iPhone 17 模拟器窗口的 CUA 截图。
+- 预览:`http://127.0.0.1:5173/tests/fixtures/approval-card/`;iOS Debug 场景 `workspace-approval` 使用正式 SwiftUI 组件。预览操作不写入用户数据。
+
+### Viewport, normalization, and comparison
+
+- 对比范围是审批卡片。源图按 390/853 比例对应手机逻辑宽度;Web 为 390px、1x 捕获,卡片外宽 358px。iOS 预览内容最大宽 390pt,卡片宽 358pt;比较时排除模拟器工具栏、设备边框及系统状态栏,并以卡片宽度归一。
+- 白色空白处是隔离验收场景,不作为聊天页面改版内容;真实聊天继续使用已有消息、头像、媒体和输入框。
+- 最终一次比较在同一个工具结果内同时打开源图、Web 最终截图和 iOS 最终截图,直接检查卡片标题、说明、三按钮、两行辅助文字。这个区域内文字与边界清晰,不需要额外放大截图。
+- 320px 深色检查:文档 scrollWidth=320,三按钮各 82px,无文字溢出;长工具说明可滚动,三个操作始终可见。浏览器控制台没有错误。
+
+### Comparison history
+
+1. [P2] 初版 Web 纵向间距偏大,卡片约 204px 高。收紧标题、说明和辅助文字间距,最终约 189px;维持 44px 按钮和 14px 正文。
+2. [P2] 初版 iOS 标题图标偏小、拒绝按钮边界与辅助文字偏淡。改为 24pt 盾形图标,加深边框和文字;重新构建后捕获最终模拟器画面并与源图复核。
+
+### Required fidelity surfaces
+
+- 字体:使用原有 PingFang/SF/系统字体,标题 18、按钮与说明 14、辅助文字 13/12;按钮保持单行,长说明完整滚动显示(Web)。
+- 布局:18px 圆角、14px 内边距、三列等宽按钮、6px 间隔,允许为实色主按钮,拒绝为描边按钮,自动允许为浅灰按钮。
+- 颜色:Web 复用已选设置弹窗语义色,iOS 使用动态系统颜色,Android 使用 Material 前景/背景色;支持深色反转及清楚的禁用状态。
+- 图片:本次卡片没有新栅格资产;盾形及圆形勾选图标来自现有图标库。设计图保留为审核依据。
+- 文案:准确使用「允许」「拒绝」「自动允许」,说明「允许仅本次;自动允许后不再询问」「自动允许会同步此 Bot 的权限设置」。
+
+### Interaction and regression checks
+
+- 浏览器验证单次允许后仍会出现下一次审批;自动允许后,模拟下一次操作直接通过;处理中三按钮禁用。iOS 原生自动允许按钮显示相应完成反馈。
+- 服务端只依据审批记录里的 agentId 更新所属 Bot;发送给 Hermes 的本次答复仍为 once。紧接着出现的请求使用新策略,同 Profile 的其他 Bot 不受影响。
+- 自动允许意图和发送状态在同一事务中保存;上游答复未确认时保留该显式设置并清楚报错,不重复发送不确定请求。已完成请求的重放不会重新覆盖用户后来修改的设置。
+- 审批设置单独变更保留当前有效组队授权,不恢复失效授权。普通聊天审批组件与澄清输入流程保持原有范围。
+- Web 相关测试、类型检查通过;iOS 70 项 WorkspaceChat 测试通过。第一次无签名运行触发了登录测试的 Keychain entitlement 错误,使用项目正常签名后全部通过。
+- Android Compose 编译、模型/网络测试和 Debug APK 构建通过;新增请求测试确认自动允许仅提交审批 ID 和 answer。Android 尚未做设备截图,手机端尚未安装新版。
+
+final result: passed

BIN
docs/design/approval-card-ios-before.jpg


BIN
docs/design/approval-card-ios.jpg


BIN
docs/design/approval-card-reference.png


BIN
docs/design/approval-card-web-before.jpg


BIN
docs/design/approval-card-web-dark.jpg


BIN
docs/design/approval-card-web.jpg


+ 34 - 0
src/client/components/workspace/WorkspaceApprovalCard.vue

@@ -0,0 +1,34 @@
+<script setup lang="ts">
+import { ShieldCheck, CircleCheck } from '@vicons/tabler'
+
+defineProps<{ prompt: string; agentName?: string; busy?: boolean; error?: string }>()
+const emit = defineEmits<{ choose: [choice: 'once' | 'deny' | 'auto'] }>()
+</script>
+
+<template>
+  <section class="workspace-approval" aria-label="工具审批" :aria-busy="busy || undefined">
+    <h3><ShieldCheck aria-hidden="true" />需要你的确认</h3>
+    <p class="workspace-approval__request"><span v-if="agentName">{{ agentName }}: </span>{{ prompt }}</p>
+    <div class="workspace-approval__actions">
+      <button class="workspace-approval__allow" type="button" :disabled="busy" @click="emit('choose', 'once')">允许</button>
+      <button class="workspace-approval__deny" type="button" :disabled="busy" @click="emit('choose', 'deny')">拒绝</button>
+      <button class="workspace-approval__auto" type="button" :disabled="busy" @click="emit('choose', 'auto')"><CircleCheck aria-hidden="true" />自动允许</button>
+    </div>
+    <p class="workspace-approval__hint">允许仅本次;自动允许后不再询问。</p>
+    <p class="workspace-approval__scope">自动允许会同步此 Bot 的权限设置</p>
+    <p v-if="busy" class="workspace-approval__status" role="status">正在处理…</p>
+    <p v-if="error" class="workspace-approval__error" role="alert">{{ error }}</p>
+  </section>
+</template>
+
+<style scoped>
+.workspace-approval{box-sizing:border-box;width:100%;margin:16px 0 6px;padding:14px;border:1px solid var(--line);border-radius:18px;background:var(--settings-panel);color:var(--text-primary)}
+h3{display:flex;align-items:center;gap:10px;margin:0;font-size:18px;line-height:1.3;font-weight:650}h3 svg{width:24px;height:24px;flex-shrink:0}
+.workspace-approval__request{max-height:160px;overflow:auto;margin:8px 0 12px;font-size:14px;line-height:1.45;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--text-secondary)}
+.workspace-approval__actions{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px}
+button{display:flex;align-items:center;justify-content:center;gap:6px;min-width:0;min-height:44px;padding:10px 6px;border-radius:12px;font:600 14px/1.3 var(--font-ui);white-space:nowrap;color:inherit;cursor:pointer;transition:background .15s ease,opacity .15s ease}
+button svg{width:18px;height:18px;flex-shrink:0}.workspace-approval__allow{background:var(--accent);color:var(--text-on-solid);border:1px solid var(--accent)}.workspace-approval__deny{background:var(--surface);border:1px solid var(--line-strong)}.workspace-approval__auto{background:var(--settings-selected);border:1px solid transparent}
+button:hover:not(:disabled){filter:brightness(.94)}button:focus-visible{outline:2px solid var(--accent);outline-offset:3px}button:disabled{opacity:.5;cursor:default}
+.workspace-approval__hint{margin:10px 0 0;color:var(--text-secondary);font-size:13px;line-height:1.4}.workspace-approval__scope{margin:6px 0 0;color:var(--text-secondary);font-size:12px;line-height:1.4}.workspace-approval__status,.workspace-approval__error{margin:10px 0 0;font-size:13px;line-height:1.5}.workspace-approval__error{color:var(--danger)}
+@media(prefers-reduced-motion:reduce){button{transition:none}}
+</style>

+ 9 - 3
src/client/components/workspace/WorkspaceMessageTimeline.vue

@@ -4,6 +4,7 @@ import AppIcon from '@/components/common/AppIcon.vue'
 import AgentAvatar from '@/components/common/AgentAvatar.vue'
 import AgentAvatar from '@/components/common/AgentAvatar.vue'
 import EmptyState from '@/components/common/EmptyState.vue'
 import EmptyState from '@/components/common/EmptyState.vue'
 import InteractionCard from '../messages/InteractionCard.vue'
 import InteractionCard from '../messages/InteractionCard.vue'
+import WorkspaceApprovalCard from './WorkspaceApprovalCard.vue'
 import MarkdownContent from '../messages/MarkdownContent.vue'
 import MarkdownContent from '../messages/MarkdownContent.vue'
 import ToolTrace from '../messages/ToolTrace.vue'
 import ToolTrace from '../messages/ToolTrace.vue'
 import TurnTrace from '../messages/TurnTrace.vue'
 import TurnTrace from '../messages/TurnTrace.vue'
@@ -31,6 +32,9 @@ const props = withDefaults(defineProps<{
   showTools?: boolean
   showTools?: boolean
   showAssistantIdentity?: boolean
   showAssistantIdentity?: boolean
   interaction?: UiInteraction | null
   interaction?: UiInteraction | null
+  interactionBusy?: boolean
+  interactionError?: string
+  approvalAgentName?: string
   mentionNames?: string[]
   mentionNames?: string[]
   agentAvatars?: Record<string, string>
   agentAvatars?: Record<string, string>
   agentStates?: Record<string, 'idle' | 'working' | 'waiting' | 'loading' | 'success' | 'failure' | 'notifying'>
   agentStates?: Record<string, 'idle' | 'working' | 'waiting' | 'loading' | 'success' | 'failure' | 'notifying'>
@@ -67,7 +71,7 @@ const emit = defineEmits<{
   branch: [message: UiMessage]
   branch: [message: UiMessage]
   preview: [attachment: NonNullable<UiMessage['attachments']>[number]]
   preview: [attachment: NonNullable<UiMessage['attachments']>[number]]
   previewFile: [file: UiLocalFileLink]
   previewFile: [file: UiLocalFileLink]
-  approve: [approved: boolean]
+  approvalChoice: [choice: 'once' | 'deny' | 'auto']
   clarify: [text: string]
   clarify: [text: string]
 }>()
 }>()
 
 
@@ -473,12 +477,14 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
           <span><strong>正在思考</strong><small>{{ formatThinkingElapsed(thinkingElapsedMs) }}</small></span>
           <span><strong>正在思考</strong><small>{{ formatThinkingElapsed(thinkingElapsedMs) }}</small></span>
         </div>
         </div>
 
 
+        <WorkspaceApprovalCard v-if="interaction?.kind === 'approval'" :prompt="interaction.prompt" :agent-name="approvalAgentName" :busy="interactionBusy" :error="interactionError" @choose="emit('approvalChoice', $event)" />
         <InteractionCard
         <InteractionCard
-          v-if="interaction"
+          v-else-if="interaction"
           :interaction="interaction"
           :interaction="interaction"
-          @approve="emit('approve', $event)"
+          :busy="interactionBusy"
           @clarify="emit('clarify', $event)"
           @clarify="emit('clarify', $event)"
         />
         />
+        <p v-if="interaction && interaction.kind !== 'approval' && interactionError" role="alert" style="color:var(--danger);font-size:14px">{{ interactionError }}</p>
       </div>
       </div>
     </div>
     </div>
 
 

+ 10 - 3
src/client/views/ConversationsView.vue

@@ -90,6 +90,8 @@ const text = ref(''),
 const identityBusy = ref(false),
 const identityBusy = ref(false),
   identityError = ref(''),
   identityError = ref(''),
   identityResetVersion = ref(0)
   identityResetVersion = ref(0)
+const respondingInteractionId = ref(''), interactionError = ref('')
+watch(() => `${route.params.id}:${interactions.value[0]?.id}`, () => { interactionError.value = '' })
 const agentActivity = computed(() => workspaceAgentActivity(conversations.value))
 const agentActivity = computed(() => workspaceAgentActivity(conversations.value))
 const searchItems = computed<SidebarItem[]>(() => [false, true].map(archived => {
 const searchItems = computed<SidebarItem[]>(() => [false, true].map(archived => {
   const items = conversations.value.filter(c => c.archived === archived).map(c => workspaceConversationItem(c, agents.value, agentActivity.value))
   const items = conversations.value.filter(c => c.archived === archived).map(c => workspaceConversationItem(c, agents.value, agentActivity.value))
@@ -834,12 +836,15 @@ async function control(action: 'stop' | 'reconcile') {
   }
   }
 }
 }
 async function respond(i: Interaction, answer: string) {
 async function respond(i: Interaction, answer: string) {
+  if (respondingInteractionId.value) return
+  respondingInteractionId.value = i.id
+  interactionError.value = ''
   try {
   try {
     await apiRequest(`/api/app/interactions/${i.id}/respond`, { method: 'POST', body: { answer } })
     await apiRequest(`/api/app/interactions/${i.id}/respond`, { method: 'POST', body: { answer } })
     await load(undefined, true)
     await load(undefined, true)
   } catch (e) {
   } catch (e) {
-    error.value = String(e)
-  }
+    interactionError.value = e instanceof Error ? e.message : '审批答复失败,请重试'
+  } finally { respondingInteractionId.value = '' }
 }
 }
 async function loadOlder() {
 async function loadOlder() {
   const c = active.value
   const c = active.value
@@ -962,8 +967,10 @@ onBeforeUnmount(() => {
         :empty-title="active ? '开始一段新对话' : '还没有聊天'"
         :empty-title="active ? '开始一段新对话' : '还没有聊天'"
         :empty-description="active ? '从下方输入框发送消息。' : '创建机器人,或选择成员新建群聊。'"
         :empty-description="active ? '从下方输入框发送消息。' : '创建机器人,或选择成员新建群聊。'"
         :interaction="interactions[0] ? { id: interactions[0].id, kind: interactions[0].kind, prompt: interactions[0].message, options: interactions[0].choices } : null"
         :interaction="interactions[0] ? { id: interactions[0].id, kind: interactions[0].kind, prompt: interactions[0].message, options: interactions[0].choices } : null"
+        :interaction-busy="!!respondingInteractionId" :interaction-error="interactionError"
+        :approval-agent-name="agents.find(agent => agent.id === interactions[0]?.agentId)?.name"
         @load-older="loadOlder" @quote="quoted = $event" @preview="openPreview" @preview-file="openPreview"
         @load-older="loadOlder" @quote="quoted = $event" @preview="openPreview" @preview-file="openPreview"
-        @approve="interactions[0] && respond(interactions[0], $event ? 'once' : 'deny')"
+        @approval-choice="interactions[0] && respond(interactions[0], $event)"
         @clarify="interactions[0] && respond(interactions[0], $event)">
         @clarify="interactions[0] && respond(interactions[0], $event)">
         <template #header-leading><button v-if="active" class="workspace-list-back icon-button" aria-label="返回 Bot 列表" @click="router.push('/conversations')"><AppIcon name="chevron-left" /></button></template>
         <template #header-leading><button v-if="active" class="workspace-list-back icon-button" aria-label="返回 Bot 列表" @click="router.push('/conversations')"><AppIcon name="chevron-left" /></button></template>
         <template #header-actions>
         <template #header-actions>

+ 11 - 3
src/server/workspaceRuntime.ts

@@ -834,13 +834,20 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
     const binding = this.store.require<{ key: string; upstreamId: string; taskId: string }>(owner, 'interaction-binding', id)
     const binding = this.store.require<{ key: string; upstreamId: string; taskId: string }>(owner, 'interaction-binding', id)
     const live = this.live.get(binding.key)
     const live = this.live.get(binding.key)
     if (!live || live.taskId !== binding.taskId || live.runId !== interaction.runId) throw new HttpError(409, '请先恢复此轮连接', 'interaction_offline')
     if (!live || live.taskId !== binding.taskId || live.runId !== interaction.runId) throw new HttpError(409, '请先恢复此轮连接', 'interaction_offline')
-    if (interaction.kind === 'approval' && !['once', 'session', 'always', 'deny', 'allow', 'approve'].includes(answer)) throw new HttpError(400, '审批选项无效', 'invalid_approval')
+    if (interaction.kind === 'approval' && !['once', 'session', 'always', 'deny', 'allow', 'approve', 'auto'].includes(answer)) throw new HttpError(400, '审批选项无效', 'invalid_approval')
+    const automatic = interaction.kind === 'approval' && answer === 'auto'
     interaction.answer = answer; interaction.responseState = 'sending'
     interaction.answer = answer; interaction.responseState = 'sending'
-    this.store.put(owner, 'interaction', id, interaction)
+    this.store.atomic(() => {
+      // A user choosing automatic approval grants a durable Bot preference.
+      // Persist it before replying so immediately following tool requests use it.
+      // Replayed/resolved requests above never reapply an old preference.
+      if (automatic) this.store.updateAgent(owner, interaction.agentId, { approvalPolicy: 'allow' })
+      this.store.put(owner, 'interaction', id, interaction)
+    })
     try {
     try {
       await live.gateway.rpc(interaction.kind === 'approval' ? 'approval.respond' : 'clarify.respond', {
       await live.gateway.rpc(interaction.kind === 'approval' ? 'approval.respond' : 'clarify.respond', {
         session_id: live.runtimeId, request_id: binding.upstreamId,
         session_id: live.runtimeId, request_id: binding.upstreamId,
-        ...(interaction.kind === 'approval' ? { choice: answer } : { answer }),
+        ...(interaction.kind === 'approval' ? { choice: automatic ? 'once' : answer } : { answer }),
       })
       })
     } catch (error) {
     } catch (error) {
       const latest = this.store.require<Reply>(owner, 'interaction', id)
       const latest = this.store.require<Reply>(owner, 'interaction', id)
@@ -848,6 +855,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         latest.responseState = error instanceof HttpError && error.code === 'gateway_rejected' ? undefined : 'uncertain'
         latest.responseState = error instanceof HttpError && error.code === 'gateway_rejected' ? undefined : 'uncertain'
         this.store.put(owner, 'interaction', id, latest)
         this.store.put(owner, 'interaction', id, latest)
       }
       }
+      if (automatic) throw new HttpError(409, '自动允许已为此 Bot 开启,但本次答复未确认,请检查当前任务状态。', 'approval_auto_response_failed')
       throw error
       throw error
     }
     }
     interaction.resolved = true; interaction.responseState = 'sent'
     interaction.resolved = true; interaction.responseState = 'sent'

+ 8 - 0
src/server/workspaceStore.ts

@@ -475,6 +475,14 @@ export class WorkspaceStore {
       if (patch.canManageTeam !== undefined && patch.canManageTeam !== (agent.canManageTeam === true))
       if (patch.canManageTeam !== undefined && patch.canManageTeam !== (agent.canManageTeam === true))
         next.teamAuthorizationVersion = (agent.teamAuthorizationVersion ?? 0) + 1
         next.teamAuthorizationVersion = (agent.teamAuthorizationVersion ?? 0) + 1
       this.put(owner, 'agent', id, next)
       this.put(owner, 'agent', id, next)
+      if (patch.approvalPolicy !== undefined && Object.keys(patch).every(key => key === 'approvalPolicy')) {
+        // Changing how approvals are answered must not revoke an otherwise
+        // valid team's current tools. Never revive a previously invalid grant.
+        for (const work of this.list<{id:string;agentId:string;status:string;teamManagementRevision?:number}>(owner, 'turn')) {
+          if (work.agentId === id && ['running', 'waiting'].includes(work.status) && work.teamManagementRevision === agent.revision)
+            this.put(owner, 'turn', work.id, { ...work, teamManagementRevision: next.revision })
+        }
+      }
       if(sourceChanged)for(const binding of this.list<{id:string}>(owner,'binding').filter(binding=>binding.id.endsWith(`:${id}`))){this.put(owner,'binding-reset',binding.id,{id:binding.id});this.remove(owner,'binding',binding.id)}
       if(sourceChanged)for(const binding of this.list<{id:string}>(owner,'binding').filter(binding=>binding.id.endsWith(`:${id}`))){this.put(owner,'binding-reset',binding.id,{id:binding.id});this.remove(owner,'binding',binding.id)}
       this.event(owner, 'agent.changed', next)
       this.event(owner, 'agent.changed', next)
       for (const c of this.list<Conversation>(owner, 'conversation').filter(
       for (const c of this.list<Conversation>(owner, 'conversation').filter(

+ 20 - 0
tests/client/workspaceApprovalCard.test.ts

@@ -0,0 +1,20 @@
+import { mount } from '@vue/test-utils'
+import { expect, it } from 'vitest'
+import WorkspaceApprovalCard from '@/components/workspace/WorkspaceApprovalCard.vue'
+
+it('sends distinct one-time and persistent choices and labels their Bot scope', async () => {
+  const view = mount(WorkspaceApprovalCard, { props: { prompt: '调用图片生成工具', agentName: '瑶儿' } })
+  expect(view.text()).toContain('瑶儿: 调用图片生成工具')
+  expect(view.text()).toContain('同步此 Bot 的权限设置')
+  const buttons = view.findAll('button')
+  expect(buttons.map(button => button.text())).toEqual(['允许', '拒绝', '自动允许'])
+  for (const button of buttons) await button.trigger('click')
+  expect(view.emitted('choose')).toEqual([['once'], ['deny'], ['auto']])
+  await view.setProps({ busy: true, error: '本次答复未确认' })
+  expect(view.get('[role="status"]').text()).toBe('正在处理…')
+  expect(view.get('[role="alert"]').text()).toBe('本次答复未确认')
+  expect(buttons.every(button => button.attributes('disabled') !== undefined)).toBe(true)
+  await buttons[2]!.trigger('click')
+  expect(view.emitted('choose')).toHaveLength(3)
+  view.unmount()
+})

+ 2 - 0
tests/fixtures/approval-card/index.html

@@ -0,0 +1,2 @@
+<!doctype html>
+<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Bot 审批卡片预览</title></head><body><div id="app"></div><script type="module" src="./main.ts"></script></body></html>

+ 25 - 0
tests/fixtures/approval-card/main.ts

@@ -0,0 +1,25 @@
+// Isolated visual fixture: no application API requests or user data writes.
+import { createApp, defineComponent, h, ref } from 'vue'
+import WorkspaceApprovalCard from '@/components/workspace/WorkspaceApprovalCard.vue'
+import '@/styles/tokens.css'
+import '@/styles/global.css'
+
+const query = new URLSearchParams(location.search)
+document.documentElement.classList.toggle('dark', query.has('dark'))
+createApp(defineComponent({ setup() {
+  const busy = ref(false), result = ref(''), automatic = ref(false)
+  async function choose(choice: 'once'|'deny'|'auto') {
+    busy.value = true
+    await new Promise(resolve => setTimeout(resolve, 600))
+    automatic.value = choice === 'auto'
+    result.value = choice === 'auto' ? '自动允许已开启,后续不再询问' : choice === 'once' ? '已允许本次操作' : '已拒绝本次操作'
+    busy.value = false
+  }
+  return () => h('main', { style: 'min-height:100dvh;display:grid;place-items:center;background:var(--surface);color:var(--text-primary)' }, [
+    h('div', { style: 'width:100%;max-width:390px;padding:16px;box-sizing:border-box' }, result.value ? [
+      h('p', { role: 'status' }, result.value),
+      h('button', { onClick: () => { result.value = automatic.value ? '已自动允许下一次操作' : '' } }, '模拟下一次审批'),
+      h('button', { onClick: () => { result.value = ''; automatic.value = false } }, '重新预览'),
+    ] : h(WorkspaceApprovalCard, { prompt: query.has('long') ? '运行工具:' + '请核对下面的工具参数与文件路径。'.repeat(80) : '瑶儿想调用图片生成工具', busy: busy.value, onChoose: choose })),
+  ])
+} })).mount('#app')

+ 55 - 0
tests/server/workspace.test.ts

@@ -1313,6 +1313,61 @@ it('applies independent Bot approval choices on the same Profile and does not re
   await finished(runs[0]!.id)
   await finished(runs[0]!.id)
 })
 })
 
 
+it('automatically allows this request and the next one while persisting only the owning Bot preference', async () => {
+  const bot = agent('选择自动允许'), peer = agent('同一基础机器人')
+  reply = (socket, params) => socket.send(JSON.stringify({ method: 'event', params: { type: 'approval.request', session_id: params.session_id, payload: { request_id: 'first-approval', message: '生成图片' } } }))
+  server.on('connection', socket => socket.on('message', raw => {
+    const frame = JSON.parse(String(raw))
+    if (frame.method !== 'approval.respond') return
+    if (frame.params.request_id === 'first-approval') socket.send(JSON.stringify({ method: 'event', params: { type: 'approval.request', session_id: frame.params.session_id, payload: { request_id: 'next-approval', message: '保存图片' } } }))
+    else completeReply(socket, frame.params, '自动完成')
+  }))
+  const run = runtime.send(owner, direct(bot.id).id, { requestId: randomUUID(), content: '生成图片' })
+  await vi.waitFor(() => expect(store.list<WorkspaceInteraction>(owner, 'interaction')).toHaveLength(1))
+  const request = store.list<WorkspaceInteraction>(owner, 'interaction')[0]!
+  await expect(runtime.respond(other, request.id, 'auto')).rejects.toThrow('记录不存在')
+  expect(store.require<any>(owner, 'agent', bot.id).approvalPolicy).toBe('ask')
+  await runtime.respond(owner, request.id, 'auto')
+  await finished(run.id)
+  expect(requests.filter(frame => frame.method === 'approval.respond').map(frame => frame.params.choice)).toEqual(['once', 'once'])
+  expect(store.require<any>(owner, 'agent', bot.id).approvalPolicy).toBe('allow')
+  expect(store.require<any>(owner, 'agent', peer.id).approvalPolicy).toBe('ask')
+  store.updateAgent(owner, bot.id, { approvalPolicy: 'ask' })
+  await runtime.respond(owner, request.id, 'auto')
+  expect(store.require<any>(owner, 'agent', bot.id).approvalPolicy).toBe('ask')
+  expect(requests.filter(frame => frame.method === 'approval.respond')).toHaveLength(2)
+})
+
+it('preserves current team grants for approval-only edits without reviving revoked grants', () => {
+  const bot = agent('有效工具授权')
+  store.put(owner, 'turn', 'valid-grant', { id: 'valid-grant', agentId: bot.id, status: 'waiting', teamManagementRevision: bot.revision })
+  store.put(owner, 'turn', 'revoked-grant', { id: 'revoked-grant', agentId: bot.id, status: 'running', teamManagementRevision: bot.revision - 1 })
+  const updated = store.updateAgent(owner, bot.id, { approvalPolicy: 'allow' })
+  expect(store.require<any>(owner, 'turn', 'valid-grant').teamManagementRevision).toBe(updated.revision)
+  expect(store.require<any>(owner, 'turn', 'revoked-grant').teamManagementRevision).toBe(bot.revision - 1)
+  store.updateAgent(owner, bot.id, { instructions: '新规则' })
+  expect(store.require<any>(owner, 'turn', 'valid-grant').teamManagementRevision).toBe(updated.revision)
+})
+
+it('keeps an explicit automatic preference but reports an unconfirmed reply without retrying it', async () => {
+  const bot = agent('自动设置与答复状态')
+  reply = (socket, params) => socket.send(JSON.stringify({ method: 'event', params: { type: 'approval.request', session_id: params.session_id, payload: { request_id: 'uncertain-auto', message: '需要确认' } } }))
+  runtime.send(owner, direct(bot.id).id, { requestId: randomUUID(), content: '继续' })
+  await vi.waitFor(() => expect(store.list<WorkspaceInteraction>(owner, 'interaction')).toHaveLength(1))
+  const interaction = store.list<WorkspaceInteraction>(owner, 'interaction')[0]!
+  const rpc = WorkspaceGateway.prototype.rpc
+  const spy = vi.spyOn(WorkspaceGateway.prototype, 'rpc').mockImplementation(function (this: WorkspaceGateway, method, params) {
+    return method === 'approval.respond' ? Promise.reject(new Error('connection lost')) : rpc.call(this, method, params)
+  })
+  try {
+    await expect(runtime.respond(owner, interaction.id, 'auto')).rejects.toMatchObject({ code: 'approval_auto_response_failed' })
+    expect(store.require<any>(owner, 'agent', bot.id).approvalPolicy).toBe('allow')
+    expect(store.require<any>(owner, 'interaction', interaction.id)).toMatchObject({ resolved: false, responseState: 'uncertain', answer: 'auto' })
+    await expect(runtime.respond(owner, interaction.id, 'auto')).rejects.toMatchObject({ code: 'interaction_uncertain' })
+    expect(spy.mock.calls.filter(call => call[0] === 'approval.respond')).toHaveLength(1)
+  } finally { spy.mockRestore() }
+})
+
 it('leaves clarifications and already pending approvals for the user after a policy change', async () => {
 it('leaves clarifications and already pending approvals for the user after a policy change', async () => {
   const bot = agent('更改审批')
   const bot = agent('更改审批')
   let kind = 'approval'
   let kind = 'approval'