From f91efd2d8d9d27b6c233e09e141d77fbaf362e1d Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 11 Sep 2026 10:44:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(question):=20DSH=20ask=5Fuser=5Fquestion?= =?UTF-8?q?=20=E6=A1=A5=E6=8E=A5=20+=20=E5=89=8D=E7=AB=AF=E9=97=AE?= =?UTF-8?q?=E7=AD=94=E9=9D=A2=E6=9D=BF=20+=20=E5=BE=85=E5=8A=9E=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E5=85=A8=E8=B7=AF=E5=BE=84=E9=80=8F=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题(P0):DSH 有两个独立的人机交互 seam —— approval/request(危险工具审批) 与 ask_user_question → ctx.userQuestions(模型主动提问)。原来只桥接了前者。 邮件驱动的会话没有本地 UI,而 ask() 的 provider 是 DSH host 注册的本地 UI 实现, 于是在那里等人点选永久等不到,那一轮工具调用**静默挂死**。 修法(不抢注全局 provider —— registerProvider 只允许一个活动实例,抢注会让 平台自己的界面失效):在 tools/execute around-dispatch 里只对**邮件驱动**的 会话接管 ask_user_question,其余原样 next()。失败一律当场报错而不是 next(): 下一个 answerer 是本地 UI,邮件会话没有兜底 UI,放过去就是挂死。 - lib/user-question.js(三桥逐字节同源,14 例测试):DSH questions[] ↔ AgentMail 单问题询问邮件的双向映射。多问题时把选项并集摊平、按 label 归属分配回各问题 (label 认不出来就不猜测放行);无选项题走自由文本 custom。 - Gateway:kind=question 且无选项时**不再**回落「同意/拒绝」(那会让自由文本 问题变成两个毫无意义的按钮);主题按类型区分「权限请求 / 需要回答」; 推送 payload 带上 permission_kind / multi_select / options。 - mails.permission_kind / permission_multi_select 此前只存在于结构体与写入路径, 五个读路径的 SELECT/Scan 都没带 —— 前端永远拿到空串,把提问渲染成批准/拒绝。 container 修正五处并加 repo 测试(含反向验证:删掉任一处字段,测试即失败)。 - 前端 PermissionPanel:question 走「勾选 + 自由文本」,多选/单选、空回答禁止提交; approval 路径不变(回归测试覆盖)。 测试:opencode 316 / dsh 349 / pi 405 / 前端 185 / Go 全量 全绿。 --- client/electron/src/components/MailView.tsx | 100 ++++++++- client/electron/src/types/index.ts | 10 + .../test/components/PermissionPanel.test.tsx | 123 +++++++++++ deploy/check-shared-libs.sh | 4 +- .../dsh-mail-bridge/lib/user-question.d.ts | 13 ++ plugins/dsh-mail-bridge/lib/user-question.js | 176 ++++++++++++++++ plugins/dsh-mail-bridge/src/index.ts | 131 +++++++++++- .../test/user-question.test.mjs | 136 +++++++++++++ plugins/dsh-mail-bridge/tsconfig.json | 6 +- .../opencode-mail-bridge/lib/user-question.js | 176 ++++++++++++++++ .../test/user-question.test.mjs | 136 +++++++++++++ plugins/pi-mail-bridge/lib/user-question.js | 176 ++++++++++++++++ .../test/user-question.test.mjs | 136 +++++++++++++ server/internal/handler/permission.go | 30 ++- .../repo/permission_visibility_test.go | 191 ++++++++++++++++++ server/internal/repo/repo.go | 23 ++- 16 files changed, 1548 insertions(+), 19 deletions(-) create mode 100644 plugins/dsh-mail-bridge/lib/user-question.d.ts create mode 100644 plugins/dsh-mail-bridge/lib/user-question.js create mode 100644 plugins/dsh-mail-bridge/test/user-question.test.mjs create mode 100644 plugins/opencode-mail-bridge/lib/user-question.js create mode 100644 plugins/opencode-mail-bridge/test/user-question.test.mjs create mode 100644 plugins/pi-mail-bridge/lib/user-question.js create mode 100644 plugins/pi-mail-bridge/test/user-question.test.mjs create mode 100644 server/internal/repo/permission_visibility_test.go diff --git a/client/electron/src/components/MailView.tsx b/client/electron/src/components/MailView.tsx index 98e962d..6f9ec6e 100644 --- a/client/electron/src/components/MailView.tsx +++ b/client/electron/src/components/MailView.tsx @@ -587,27 +587,34 @@ function ThreadCard({ mail, onForward }: { mail: Mail; onForward?: () => void }) } /** - * 权限请求的决策面板。 + * 待办面板:审批型(permission)与主动提问(question)共用入口。 + * + * 两类待办的渲染与提交语义完全不同: + * - permission:点「同意/拒绝」当场放行或拦下一个危险操作 + * - question:模型缺信息,人**回答问题**(勾选预设选项 + 自由文本) + * + * 混用一套 UI 的后果很具体:一个问「配置文件叫什么」的问题会被渲染成 + * 「同意 / 拒绝」,人只能点个毫无意义的按钮,模型拿到「同意」当答案。 * * 导出供测试单独渲染:通过整个 MailView 渲染它需要先把 mailStore 与 * sessionStore 摆到「当前正看着一封 permission_request 邮件」的状态, * 那些铺垫与这个组件本身的行为无关。 */ export function PermissionPanel({ mail }: { mail: Mail }) { + const isQuestion = mail.permission_kind === 'question'; const [note, setNote] = useState(''); const [busy, setBusy] = useState(false); const [decided, setDecided] = useState(mail.permission_result || ''); + // 问题模式下已勾选的选项(多选时是多个)。 + const [picked, setPicked] = useState([]); const fetchInbox = useMailStore(s => s.fetchInbox); const selectSession = useSessionStore(s => s.selectSession); - const options = mail.permission_options?.length ? mail.permission_options : ['同意', '拒绝']; - const isApprove = (s: string) => /同意|允许|批准|approve|yes/i.test(s); - - const decide = async (choice: string) => { + const submit = async (decision: string, noteText: string) => { setBusy(true); try { - await api.decidePermission(mail.mail_id, choice, note || undefined); - setDecided(choice); + await api.decidePermission(mail.mail_id, decision, noteText || undefined); + setDecided(decision || '(自由文本回答)'); await fetchInbox('all'); if (mail.session_id) selectSession(mail.session_id); } catch (err) { @@ -620,18 +627,93 @@ export function PermissionPanel({ mail }: { mail: Mail }) { if (decided) { return (
- 已处理:{decided} + 已处理: + {decided}
); } + // ─── 主动提问:勾选 + 自由文本 ─── + if (isQuestion) { + const options = mail.permission_options ?? []; + const multi = mail.permission_multi_select === true; + const toggle = (opt: string) => { + setPicked(prev => { + if (multi) return prev.includes(opt) ? prev.filter(p => p !== opt) : [...prev, opt]; + // 单选:再点同一项则取消,否则替换 + return prev.includes(opt) ? [] : [opt]; + }); + }; + // 回答必须非空:空提交会让模型拿到一个什么都没说的结果继续跑。 + const blank = picked.length === 0 && note.trim().length === 0; + + return ( +
+
+ {options.length === 0 + ? '这题没有预设选项,请直接填写回答:' + : multi ? '可多选,也可补充说明:' : '请选择一项,也可补充说明:'} +
+ + {options.length > 0 && ( +
+ {options.map(opt => { + const on = picked.includes(opt); + return ( + + ); + })} +
+ )} + +