feat(question): DSH ask_user_question 桥接 + 前端问答面板 + 待办字段全路径透出
问题(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 全量 全绿。
This commit is contained in:
@ -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<string[]>([]);
|
||||
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 (
|
||||
<div className="mt-3 pt-2.5 border-t border-orange-200 text-xs text-gray-600">
|
||||
已处理:<strong className="text-gray-800">{decided}</strong>
|
||||
已处理:
|
||||
<strong className="text-gray-800 whitespace-pre-wrap">{decided}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 主动提问:勾选 + 自由文本 ───
|
||||
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 (
|
||||
<div className="mt-3 pt-3 border-t border-orange-200">
|
||||
<div className="text-[11px] text-gray-500 mb-2">
|
||||
{options.length === 0
|
||||
? '这题没有预设选项,请直接填写回答:'
|
||||
: multi ? '可多选,也可补充说明:' : '请选择一项,也可补充说明:'}
|
||||
</div>
|
||||
|
||||
{options.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{options.map(opt => {
|
||||
const on = picked.includes(opt);
|
||||
return (
|
||||
<button
|
||||
key={opt}
|
||||
onClick={() => toggle(opt)}
|
||||
disabled={busy}
|
||||
aria-pressed={on}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md border transition-colors disabled:opacity-40 ${
|
||||
on
|
||||
? 'bg-blue-700 text-white border-blue-700 hover:bg-blue-800'
|
||||
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{on && <CheckIcon className="w-3.5 h-3.5" />}
|
||||
{opt}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
value={note}
|
||||
onChange={e => setNote(e.target.value)}
|
||||
rows={options.length === 0 ? 3 : 2}
|
||||
placeholder={options.length === 0 ? '你的回答(必填)' : '补充说明(可选)'}
|
||||
className="mt-2 w-full text-xs border border-gray-300 rounded-md px-2.5 py-1.5 resize-y focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||||
/>
|
||||
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => submit(picked.join('\n'), note.trim())}
|
||||
disabled={busy || blank}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md bg-blue-700 text-white hover:bg-blue-800 disabled:opacity-40"
|
||||
>
|
||||
提交回答
|
||||
</button>
|
||||
{blank && (
|
||||
<span className="text-[11px] text-gray-400">请先选择或填写回答</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 审批型:批准 / 拒绝 ───
|
||||
const options = mail.permission_options?.length ? mail.permission_options : ['同意', '拒绝'];
|
||||
const isApprove = (s: string) => /同意|允许|批准|approve|yes/i.test(s);
|
||||
|
||||
return (
|
||||
<div className="mt-3 pt-3 border-t border-orange-200">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{options.map(opt => (
|
||||
<button
|
||||
key={opt}
|
||||
onClick={() => decide(opt)}
|
||||
onClick={() => submit(opt, note)}
|
||||
disabled={busy}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md transition-colors disabled:opacity-40 ${
|
||||
isApprove(opt)
|
||||
|
||||
Reference in New Issue
Block a user