Files
MailUI4Agents/client/electron/src/components/MailView.tsx
JianFeeeee bbddee26b9 feat(permission): 待办带上失效时刻;越窗的决策不再假装成功
# 起因:一次端到端验证暴露的静默缺口

建了示例工程让 pi 通过邮件干活(plan 档拦截、workspace 档审批、多 agent 指派)。
plan 档与多 agent 都通过,workspace 档却卡住:**人在界面上批准了一条待办,
接口回 200,但那件事什么都没发生。**

追下去是三件事叠在一起:

1. **桥**等不到决策时(pi 的回合超时 TURN_TIMEOUT_MS,默认 10 分钟)会拆掉 worker
   与它的决策路由表;此后再来的决策只会作为**通知**投给 Agent,不恢复当时那次
   工具调用 —— 该轮已经结束了。
2. **服务端**只有 `permission_requests.result IS NULL`,没有「失效」概念。
   迟到决策照样回 `{"status":"decided"}`。
3. **前端**只看 `permission_result` 判待决/已决,没有任何时间或失效提示。

于是那条待办永远挂在授权页上显示「等待你决策」,人点了也白点。这是 I-5
(失败必须当场可见)要消灭的那类静默成功,而且**跨所有客户端**成立 ——
WebUI 不显示,Electron / Harmony 同样无从显示。

# 设计:邮件上给「时刻」,不给「是否失效」的布尔值

服务端不知道插件此刻是否还在等(那是它进程内的状态),所以只标出「这封待办已经
放了很久」,不替插件宣布裁决。

关键取舍:对外只发**截止时刻**(`permission_expires_at`),不发 `stale` 布尔值。
布尔值是「发出那一刻」的快照 —— 经 SSE 推送并被客户端缓存后会永久停在旧值,
界面就会一直显示「等待你决策」。时刻是持久事实,任何客户端在任何时候都能自己
比出现在过没过期。这也是为什么推导而非落库:它是 created_at 的函数,存下来会失真。

`DecidePermission` 的响应里则用布尔值(`expired`)—— 响应本身就是「此刻」的
一次性快照,不会像邮件那样被缓存反复展示。

# 改动

- `models.PermissionWaitWindow`(10 分钟,与 pi 桥的回合超时同量级)+
  `PermissionDeadline(createdAt)`;两端共用这一处算式,避免「界面说已过期、
  决策说没过期」。
- `Mail.PermissionExpiresAt` / `PermissionRequest.ExpiresAt`:由读路径推导填充。
  5 个读路径各插一行(`AttachPermissionDeadline*`)—— 与审计修复① 加
  permission_kind 时同一套路数,漏掉任一路径只会静默变成 nil。
  只给**仍未决策**的待办填,已决策的不再是待办。
- `decideResponse`(抽出纯函数以便测试):越窗时加 `expired` + `warning`,
  讲清「决策已记录、但不会恢复原调用」。**不改 HTTP 状态码**:决策仍是人的真实
  意愿、仍然有效(桥会当通知投递,Agent 重起一轮),所以不能拒掉,但必须说清。
- 前端:列表里失效项不再与「还能立刻生效」的长得一样(灰底 + 「可能已失效」);
  批准面板在决策**前**(人正要按下去)与决策**后**(人以为事情办了)都显示提示。

# 验证

- Go:models/repo/handler 三处新增测试全绿;全量 `go test ./...` 通过;vet 通过
- 前端:typecheck 通过;200 项测试全绿(含新增 4 条失效态)
- 真机(用现成的过期待办,未造合成数据):
  - `/permission/pending` 返回 `expires_at` = 创建 + 10 分钟,服务端判定已过窗
  - 邮件载荷带上 `permission_expires_at`(前端列表的数据源)
  - 对过期待办提交批准 → `{"expired":true, "expires_at":…, "warning":"该请求已超过
    等待窗口(10 分钟)…不会恢复当时那次工具调用…"}`
- 已用 redeploy-gateway.sh 部署,服务 active、四 agent 心跳正常、日志无 panic
2026-09-11 22:02:25 +08:00

977 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState } from 'react';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useMailStore } from '../stores/mailStore';
import { useSessionStore } from '../stores/sessionStore';
import { useContactStore } from '../stores/contactStore';
import { useAuthStore } from '../stores/authStore';
import {
sessionReplyTarget,
mailReplyTarget,
mailCounterpart,
replyAllCC,
participantAddress
} from '../lib/replyTarget';
import * as api from '../api/client';
import type { Mail } from '../types';
import { MailIcon, ShieldIcon, PersonIcon, BotIcon, CheckIcon, CloseIcon, ForwardIcon, TreeIcon, TagIcon, GaugeIcon } from './icons';
import AddressInput from './AddressInput';
import { AttachmentList, AttachmentPicker, type PendingAttachment } from './Attachments';
import ThreadView from './ThreadView';
import PermissionChip, { permissionModeHint } from './PermissionChip';
import BackButton from './BackButton';
export default function MailView() {
const currentMail = useMailStore(s => s.currentMail);
const markRead = useMailStore(s => s.markRead);
const currentSession = useSessionStore(s => s.currentSession);
const currentSessionMails = useSessionStore(s => s.currentSessionMails);
// 当前登录用户名:判定「哪封是我发的」的唯一基准。
// 曾经写死成 'human'(单用户时代的遗留),多用户下登录名可能是 jianf
// 判据恒为假 —— 于是回复自己发的信时对端取成了自己。
const me = useAuthStore(s => s.user?.username || '');
// 转发面板作用于哪封邮件null = 未打开
const [forwarding, setForwarding] = useState<Mail | null>(null);
// 正在看哪封邮件的对话树null = 看正常的邮件视图
const [threadOf, setThreadOf] = useState<string | null>(null);
// 切换邮件时关掉树视图:树是针对某封邮件的,留着会显示上一封的线索
const currentMailID = currentMail?.mail_id;
useEffect(() => {
setThreadOf(null);
}, [currentMailID]);
if (currentSession && currentSessionMails.length > 0) {
const last = currentSessionMails[currentSessionMails.length - 1];
// 会话视图的对端是**会话的属性**,不能由「最后一封是谁发的」决定:
// 人在这里打字就是「给这次任务的对方追加一句」,而最后一封很可能是自己刚发的,
// 那时取对端会取成自己 —— 信就发给了自己(生产已发生)。
const sessionTarget = sessionReplyTarget(currentSessionMails, currentSession, me);
return (
<div className="flex-1 min-w-0 flex flex-col bg-gray-50">
<div className="px-4 md:px-6 py-3 border-b border-gray-200 bg-white">
<div className="flex items-center gap-2 flex-wrap">
<BackButton label="会话" />
<span className="text-sm font-semibold text-gray-900 font-mono">
{currentSession.session_alias
? `.${currentSession.session_alias}`
: '(未命名会话)'}
</span>
<StatusBadge status={currentSession.status} />
<span className="text-xs text-gray-400">
{currentSessionMails.length}
</span>
<div className="flex-1" />
<PermissionEditor />
<BudgetEditor />
</div>
<p className="text-xs text-gray-500 mt-0.5">{currentSession.subject}</p>
</div>
<RenameProposalBar />
<div className="flex-1 overflow-y-auto px-4 md:px-6 py-4 space-y-3">
{currentSessionMails.map(m => (
<ThreadCard key={m.mail_id} mail={m} onForward={() => setForwarding(m)} />
))}
</div>
{/* 会话视图原先只有回复,转发入口只存在于单封邮件视图 ——
而人多数时间待在会话视图里,等于转发功能在 UI 上找不到 */}
{forwarding ? (
<ForwardBar mail={forwarding} onClose={() => setForwarding(null)} />
) : (
<ReplyBar replyTo={last} overrideTarget={sessionTarget} />
)}
</div>
);
}
if (!currentMail) {
return (
<div className="flex-1 min-w-0 flex items-center justify-center bg-gray-50 text-gray-400">
<div className="text-center">
<MailIcon className="w-10 h-10 mx-auto text-gray-300" />
<p className="text-sm mt-3 text-gray-500"></p>
</div>
</div>
);
}
if (threadOf) {
return <ThreadView mailID={threadOf} onClose={() => setThreadOf(null)} />;
}
return (
<div className="flex-1 min-w-0 flex flex-col bg-white">
<Header
mail={currentMail}
onRead={() => markRead(currentMail.mail_id)}
onForward={() => setForwarding(currentMail)}
onThread={() => setThreadOf(currentMail.mail_id)}
/>
<div className="flex-1 overflow-y-auto px-4 md:px-6 py-4">
<div className="markdown text-sm">
<Markdown remarkPlugins={[remarkGfm]}>{currentMail.body}</Markdown>
</div>
<AttachmentList items={currentMail.attachments ?? []} />
{currentMail.mail_type === 'permission_request' && (
<PermissionPanel mail={currentMail} />
)}
</div>
{forwarding ? (
<ForwardBar mail={forwarding} onClose={() => setForwarding(null)} />
) : (
<ReplyBar replyTo={currentMail} />
)}
</div>
);
}
/**
* 权限档位编辑器(会话头部)。
*
* 人在派活时声明「这件事允许 Agent 动手到什么程度」。
* 只有新会话时在 ComposePage 里设;对话页里随时可改(规划档位)。
* 三档plan只读/ workspace目录内越界问人/ full全权
*/
function PermissionEditor() {
const session = useSessionStore(s => s.currentSession);
const setPermissionMode = useSessionStore(s => s.setPermissionMode);
const [editing, setEditing] = useState(false);
const [busy, setBusy] = useState(false);
if (!session) return null;
const mode = session.permission_mode || 'workspace';
const enforcement = session.permission_enforcement || 'advisory';
if (!editing) {
return (
<button
onClick={() => setEditing(true)}
title={permissionModeHint(mode, enforcement)}
className="inline-flex items-center"
>
<PermissionChip mode={mode} enforcement={enforcement} />
</button>
);
}
const modes = [
{ value: 'plan', label: '只读', desc: '不许写/改/执行' },
{ value: 'workspace', label: '目录内', desc: '越界问人' },
{ value: 'full', label: '全权', desc: '自动放行' },
];
return (
<div className="flex items-center gap-1">
{modes.map(o => (
<button
key={o.value}
type="button"
disabled={busy}
onClick={async () => {
setBusy(true);
await setPermissionMode(o.value);
setBusy(false);
setEditing(false);
}}
title={o.desc}
className={`px-1.5 py-0.5 rounded text-[10px] font-medium border transition-colors disabled:opacity-40 ${
mode === o.value
? 'border-blue-500 bg-blue-50 text-blue-700'
: 'border-gray-200 text-gray-500 hover:bg-gray-50'
}`}
>
{o.label}
</button>
))}
<button onClick={() => setEditing(false)} className="text-[10px] text-gray-400 hover:text-gray-700 ml-0.5">
×
</button>
</div>
);
}
/**
* 本任务的往返预算编辑器(会话头部)。
*
* 配额最该被编辑的地方就是这里:人看着往来内容才知道这件事还值不值得再来几个回合。
* 放在管理员页面调某个 Agent 的全局配额是另一回事 —— 那管的是「这个 Agent 总共能发多少」,
* 而不是「这件事值得多少个来回」。
*/
function BudgetEditor() {
const budget = useSessionStore(s => s.budget);
const setBudget = useSessionStore(s => s.setBudget);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState('');
const [busy, setBusy] = useState(false);
if (!budget) return null;
const exhausted = !budget.unlimited && budget.remaining === 0;
const open = () => {
setDraft(budget.unlimited ? '' : String(budget.max_rounds));
setEditing(true);
};
const commit = async (patch: { max_rounds?: number; reset?: boolean }) => {
setBusy(true);
await setBudget(patch);
setBusy(false);
setEditing(false);
};
if (!editing) {
return (
<button
onClick={open}
title="本任务的往返预算Agent 主动发信的次数上限(自动转发的总结与权限询问不占用)"
className={`inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded border transition-colors ${
exhausted
? 'border-red-200 bg-red-50 text-red-700'
: 'border-gray-200 text-gray-500 hover:border-blue-300 hover:text-blue-600'
}`}
>
<GaugeIcon className="w-3 h-3" />
{budget.unlimited
? '预算不限'
: `${budget.used_rounds}/${budget.max_rounds} 来回${exhausted ? ' · 已用尽' : ''}`}
</button>
);
}
const invalid = draft.trim() !== '' && !/^\d+$/.test(draft.trim());
return (
<div className="flex items-center gap-1.5">
<span className="text-[11px] text-gray-500"></span>
<input
value={draft}
onChange={e => setDraft(e.target.value)}
inputMode="numeric"
placeholder="不限"
autoFocus
className={`w-16 text-xs border rounded px-1.5 py-1 focus:outline-none focus:ring-2 focus:ring-blue-100 ${
invalid ? 'border-red-300' : 'border-gray-300'
}`}
/>
<button
disabled={busy || invalid}
onClick={() => commit({ max_rounds: draft.trim() === '' ? 0 : Number(draft.trim()) })}
className="text-[11px] px-2 py-1 rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40"
>
</button>
<button
disabled={busy || budget.used_rounds === 0}
onClick={() => commit({ reset: true })}
title="已用次数归零,上限不变"
className="text-[11px] text-gray-500 hover:text-gray-900 disabled:opacity-30"
>
</button>
<button
onClick={() => setEditing(false)}
className="text-[11px] text-gray-400 hover:text-gray-700"
>
</button>
</div>
);
}
/**
* Agent 提议改会话别名的提示条。
*
* 为什么要人点头而不是让 Agent 直接改:别名是**人**的寻址入口name@path.别名)。
* Agent 干到一半自己改掉,人上一秒记住的地址下一秒就失效。
* 提议 + 人确认,既让 Agent 表达意图,又保证寻址稳定性由人掌握。
*/
function RenameProposalBar() {
const proposal = useSessionStore(s => s.renameProposal);
const current = useSessionStore(s => s.currentSession);
const accept = useSessionStore(s => s.acceptRename);
const dismiss = useSessionStore(s => s.dismissRename);
const [busy, setBusy] = useState(false);
if (!proposal) return null;
const from = current?.session_alias ? `.${current.session_alias}` : '(未命名)';
return (
<div className="px-4 md:px-6 py-2.5 bg-blue-50 border-b border-blue-100">
<div className="flex items-start gap-2">
<TagIcon className="w-3.5 h-3.5 text-blue-500 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-xs text-blue-900">
Agent <span className="font-mono">{from}</span> {' '}
<span className="font-mono font-semibold">.{proposal.alias}</span>
</p>
{proposal.reason && (
<p className="text-[11px] text-blue-700 mt-0.5">{proposal.reason}</p>
)}
<p className="text-[10px] text-blue-500 mt-0.5">
name@path.{proposal.alias}
Agent
</p>
</div>
<button
disabled={busy}
onClick={async () => {
setBusy(true);
await accept();
setBusy(false);
}}
className="px-2.5 py-1 rounded bg-blue-600 text-white text-xs hover:bg-blue-600 disabled:opacity-50 shrink-0"
>
{busy ? '改名中' : '接受'}
</button>
<button
onClick={dismiss}
className="px-2.5 py-1 rounded border border-blue-200 text-blue-700 text-xs hover:bg-blue-100 shrink-0"
>
</button>
</div>
</div>
);
}
/**
* 转发面板。与回复并列,二者互斥显示 —— 同时开两个输入框会让人不知道自己在写哪个。
* 收件人用与写信页一致的三段式补全,正文引用由服务端生成(保证格式统一)。
*/
function ForwardBar({ mail, onClose }: { mail: Mail; onClose: () => void }) {
const [to, setTo] = useState('');
const [cc, setCc] = useState('');
const [ccOpen, setCcOpen] = useState(false);
const [comment, setComment] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchInbox = useMailStore(s => s.fetchInbox);
const fetchSent = useMailStore(s => s.fetchSent);
const fetchSessions = useSessionStore(s => s.fetchSessions);
const fetchContacts = useContactStore(s => s.fetchContacts);
const submit = async () => {
if (!to.trim() || busy) return;
setBusy(true);
setError(null);
try {
await api.forwardMail(mail.mail_id, {
to: to.trim(),
cc: cc.trim(),
comment: comment.trim()
});
await Promise.all([fetchInbox('all'), fetchSent(), fetchSessions(), fetchContacts()]);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
return (
<div className="shrink-0 max-h-[min(65vh,calc(var(--app-height)-3rem))] overflow-y-auto overscroll-contain border-t border-gray-200 bg-white px-4 md:px-6 py-3 space-y-2">
<div className="flex items-center gap-2">
<ForwardIcon className="w-3.5 h-3.5 text-gray-500" />
<span className="text-[11px] font-medium text-gray-600">
{mail.subject}
</span>
<div className="flex-1" />
<button
onClick={() => setCcOpen(o => !o)}
className={`tap text-[10px] ${ccOpen ? 'text-blue-600' : 'text-gray-500 hover:text-blue-600'}`}
>
{ccOpen ? '收起抄送' : '抄送'}
</button>
</div>
<AddressInput value={to} onChange={setTo} placeholder="新收件人pi@root.new" />
{ccOpen && (
<AddressInput
value={cc}
onChange={setCc}
allowMultiple
placeholder="抄送:逗号分隔,可多个"
/>
)}
<textarea
value={comment}
onChange={e => setComment(e.target.value)}
placeholder="转发说明(可选,置于引用原文之前;原文将以引用块附在下方)"
className="w-full h-16 text-sm border border-gray-300 rounded-md p-2.5 resize-none focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
/>
<div className="flex items-center gap-2 flex-wrap">
{error && <span className="text-xs text-red-600 min-w-0 break-words">{error}</span>}
<div className="flex-1" />
<button onClick={onClose} className="tap px-3 py-1.5 text-xs text-gray-500 hover:text-gray-800">
</button>
<button
onClick={submit}
disabled={busy || !to.trim()}
className="tap px-4 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed"
>
{busy ? '转发中' : '转发'}
</button>
</div>
</div>
);
}
function Header({
mail,
onRead,
onForward,
onThread
}: {
mail: Mail;
onRead: () => void;
onForward: () => void;
onThread: () => void;
}) {
const time = new Date(mail.created_at).toLocaleString('zh-CN');
// 发件/收件行显示**各方在这条会话里的完整地址**,人与 Agent 带的段数不同:
//
// Agent → `pi@/home/program/agentmail.<会话别名>` 三段才唯一确定
// 人 → `jianf` 人没有目录也不需要会话位
//
// 此前这里有两处错:别名被拼给了**发件人**`jianf.<别名>` —— 既指错归属,
// 又因为人没有工作目录而拼出 path 位为空的非法地址),以及收件人**没有**
// 别名(`pi@/home/program/agentmail` 指向默认会话,不是人指定的那条)。
//
// workspace 取 `session_workspace` 而不是 from/to_workspace后者对 Agent
// 存的是 Agent 名而非路径(历史遗留),拿它拼会得到 `dsh@dsh`。
const ws = mail.session_workspace || '';
const from = participantAddress(
mail.from_name,
mail.from_human,
mail.from_human ? '' : ws,
mail.session_alias
);
const to = participantAddress(
mail.to_name,
mail.to_human,
mail.to_human ? '' : ws,
mail.session_alias
);
return (
<div className="px-4 md:px-6 py-3 md:py-4 border-b border-gray-200">
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
<BackButton />
<h2 className="text-sm font-semibold text-gray-900 min-w-0 break-words">{mail.subject}</h2>
{mail.status === 'unread' && (
<span className="px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 text-[10px] font-medium">
</span>
)}
{mail.mail_type === 'permission_request' && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-orange-100 text-orange-700 text-[10px] font-medium">
<ShieldIcon className="w-3 h-3" />
</span>
)}
<div className="flex-1" />
{mail.status === 'unread' && (
<button onClick={onRead} className="tap text-xs text-blue-500 hover:underline">
</button>
)}
<button
onClick={onThread}
className="tap inline-flex items-center gap-1 text-xs text-gray-500 hover:text-gray-900"
title="沿回复与转发关系展开整条线索"
>
<TreeIcon className="w-3.5 h-3.5" />
</button>
<button
onClick={onForward}
className="tap inline-flex items-center gap-1 text-xs text-gray-500 hover:text-gray-900"
>
<ForwardIcon className="w-3.5 h-3.5" />
</button>
</div>
<dl className="text-xs text-gray-500 space-y-0.5">
<Row label="发件">{from}</Row>
<Row label="收件">{to}</Row>
{mail.cc_list?.length > 0 && (
<Row label="抄送">
{mail.cc_list.map(a => a.raw || `${a.name}@${a.path || ''}`).join('、')}
</Row>
)}
<Row label="时间">{time}</Row>
</dl>
</div>
);
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex gap-2">
<dt className="w-8 shrink-0 text-gray-400">{label}</dt>
{/* min-w-0 + break-all会话别名可达 128 字节,不给收缩权会把整行撑出容器 */}
<dd className="min-w-0 font-mono text-gray-600 break-all">{children}</dd>
</div>
);
}
function ThreadCard({ mail, onForward }: { mail: Mail; onForward?: () => void }) {
// 「这封是我发的吗」而不是「发件人叫 human 吗」:多用户下登录名可能是
// jianf写死 'human' 会让自己发的信显示成机器人图标。
const me = useAuthStore(s => s.user?.username || '');
const isMine = !!me && mail.from_name === me;
const isPermission = mail.mail_type === 'permission_request';
const time = new Date(mail.created_at).toLocaleString('zh-CN');
return (
<div
className={`rounded-lg border p-4 ${
isPermission
? 'border-orange-200 bg-orange-50'
: isMine
? 'border-blue-200 bg-blue-50/60'
: 'border-gray-200 bg-white'
}`}
>
<div className="flex items-center gap-1.5 mb-2 text-xs">
{isMine ? (
<PersonIcon className="w-3.5 h-3.5 text-blue-600" />
) : (
<BotIcon className="w-3.5 h-3.5 text-slate-600" />
)}
{/* 显示真实发件人名而不是 'human':会话里可能有多个人类参与方,
都渲染成 human 就分不清谁说的话 */}
<span className="font-semibold text-gray-800 font-mono">{mail.from_name}</span>
{isPermission && (
<span className="px-1 py-0.5 rounded bg-orange-200 text-orange-800 text-[9px] font-medium">
</span>
)}
{mail.cc_list?.length > 0 && (
<span
className="text-[10px] text-gray-400"
title={mail.cc_list.map(c => c.raw || `${c.name}@${c.path || ''}`).join(', ')}
>
{mail.cc_list.length}
</span>
)}
<div className="flex-1" />
<span className="text-[10px] text-gray-400">{time}</span>
{onForward && (
<button
onClick={onForward}
title="转发这封"
className="text-gray-400 hover:text-blue-600"
>
<ForwardIcon className="w-3 h-3" />
</button>
)}
</div>
<div className="markdown text-sm">
<Markdown remarkPlugins={[remarkGfm]}>{mail.body}</Markdown>
</div>
<AttachmentList items={mail.attachments ?? []} />
{isPermission && <PermissionPanel mail={mail} />}
</div>
);
}
/**
* 待办面板审批型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 [staleWarning, setStaleWarning] = useState('');
const fetchInbox = useMailStore(s => s.fetchInbox);
const selectSession = useSessionStore(s => s.selectSession);
// 本地先算一遍「可能已失效」:服务端只给失效**时刻**,当前时间本地就有,
// 不必等一次往返。判定与后端 decideResponse 同一口径(严格晚于)。
const expiredBeforeDecide =
!mail.permission_result &&
!!mail.permission_expires_at &&
Date.now() > Date.parse(mail.permission_expires_at);
/**
* 越窗提示。
*
* 两边都要显:决策**前**(人即将点下去)与决策**后**(人以为事情已经办了)。
* 只在决策后显示等于让人先做错一次;只在决策前显示则补不上服务端在两次渲染
* 之间越窗的情形。
*/
const staleBanner =
staleWarning || expiredBeforeDecide ? (
<p className="mt-2 text-[11px] leading-relaxed text-amber-800 bg-amber-50 border border-amber-200 rounded-md px-2.5 py-1.5">
{staleWarning ||
'已超过等待窗口,发起它的 Agent 很可能已不再阻塞等待。现在批准不会恢复当时那次工具调用 —— 决策会作为一条通知投给它,让它重起一轮。'}
</p>
) : null;
const submit = async (decision: string, noteText: string) => {
setBusy(true);
try {
const res = await api.decidePermission(mail.mail_id, decision, noteText || undefined);
// 服务端在请求已越过等待窗口时会回 expired + warning这次批准不会恢复
// 当时那次工具调用。必须显示出来 —— 否则人看到「已处理」就以为事情办了。
if (res?.warning) setStaleWarning(res.warning);
setDecided(decision || '(自由文本回答)');
await fetchInbox('all');
if (mail.session_id) selectSession(mail.session_id);
} catch (err) {
console.error(err);
} finally {
setBusy(false);
}
};
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 whitespace-pre-wrap">{decided}</strong>
{staleBanner}
</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">
{staleBanner}
<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">
{staleBanner}
<div className="flex flex-wrap gap-2">
{options.map(opt => (
<button
key={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)
? 'bg-green-700 text-white hover:bg-green-800'
: 'bg-red-50 text-red-700 border border-red-200 hover:bg-red-100'
}`}
>
{isApprove(opt) ? (
<CheckIcon className="w-3.5 h-3.5" />
) : (
<CloseIcon className="w-3.5 h-3.5" />
)}
{opt}
</button>
))}
</div>
<input
value={note}
onChange={e => setNote(e.target.value)}
placeholder="备注(可选)"
className="mt-2 w-full text-xs border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
/>
</div>
);
}
function ReplyBar({
replyTo,
/**
* 会话视图传进来的目标地址,覆盖「按锚点邮件推断」。
*
* 会话视图的语义是「跟这个 Agent 的一次任务」,对端是会话的属性;
* 单封邮件视图没有这层语境,才回落到按那封邮件推断。
*/
overrideTarget
}: {
replyTo?: Mail;
overrideTarget?: string;
}) {
const [body, setBody] = useState('');
const [cc, setCc] = useState('');
// 抄送默认收起:多数回复不需要它,常驻一行输入框只会挤掉正文空间。
// 原邮件带抄送时自动展开并预填 —— 「回复全部」是人在这种场景下的默认预期
const [ccOpen, setCcOpen] = useState(false);
const [budgetEditing, setBudgetEditing] = useState(false);
const [budgetBusy, setBudgetBusy] = useState(false);
const [maxRoundsDraft, setMaxRoundsDraft] = useState('');
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchInbox = useMailStore(s => s.fetchInbox);
const fetchSent = useMailStore(s => s.fetchSent);
const fetchSessions = useSessionStore(s => s.fetchSessions);
const fetchContacts = useContactStore(s => s.fetchContacts);
const selectSession = useSessionStore(s => s.selectSession);
const budget = useSessionStore(s => s.budget);
const setBudget = useSessionStore(s => s.setBudget);
const me = useAuthStore(s => s.user?.username || '');
if (!replyTo) return null;
// 判据是「当前登录用户名」而不是字面量 'human':后者是单用户时代的遗留,
// 多用户下登录名可能是 jianf判据恒为假 → 对端取成自己 → 信发给自己。
const peer = mailCounterpart(replyTo, me);
const target = overrideTarget || mailReplyTarget(replyTo, me);
const send = async () => {
if (!body.trim()) return;
setBusy(true);
setError(null);
try {
await api.sendMail(target, `Re: ${replyTo.subject}`, body, {
reply_to: replyTo.mail_id,
cc: cc.trim(),
attachment_ids: attachments.map(a => a.id)
});
setBody('');
setCc('');
setCcOpen(false);
setAttachments([]);
await Promise.all([fetchInbox('all'), fetchSent(), fetchSessions(), fetchContacts()]);
if (replyTo.session_id) selectSession(replyTo.session_id);
} catch (err) {
// 必须显示出来:预算耗尽、地址不存在、速率限制都会走到这里,
// 原先只 console.error用户点了发送什么反应都没有
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
/**
* 「回复全部」:把原邮件的其他参与方填进抄送。
*
* cc_list 是结构化的 Address后端解析过三维寻址。传当前会话别名进去
* 让 `.new` 被换成真实别名 —— 原样回填 `.new` 会让这封回复给抄送方
* **另开一条新会话**,于是同一件事裂成两条线索。
*/
const replyAll = () => {
// 去重与「去掉自己」都在 replyAllCC 里:原先用 !a.startsWith('human')
// 去自己,同一个遗留判据 —— 去不掉 jianf点「回复全部」会把自己抄送进去。
setCc(replyAllCC(replyTo, me, peer.name).join(', '));
setCcOpen(true);
};
return (
<div className="shrink-0 max-h-[min(65vh,calc(var(--app-height)-3rem))] overflow-y-auto overscroll-contain border-t border-gray-200 bg-white px-4 md:px-6 py-3">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<p className="text-[10px] text-gray-400 font-mono min-w-0 truncate"> {target}</p>
<div className="flex-1" />
{(replyTo.cc_list?.length ?? 0) > 0 && (
<button
onClick={replyAll}
className="tap text-[10px] text-gray-500 hover:text-blue-600"
>
</button>
)}
<button
onClick={() => setCcOpen(o => !o)}
className={`tap text-[10px] ${ccOpen ? 'text-blue-600' : 'text-gray-500 hover:text-blue-600'}`}
>
{ccOpen ? '收起抄送' : '抄送'}
</button>
</div>
{ccOpen && (
<div className="mb-2">
<AddressInput
value={cc}
onChange={setCc}
allowMultiple
placeholder="抄送:逗号分隔,可多个(如 pi@root, ops@root"
/>
</div>
)}
<textarea
value={body}
onChange={e => setBody(e.target.value)}
placeholder="回复内容Markdown"
className="w-full h-20 text-sm font-mono border border-gray-300 rounded-md p-3 resize-none focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
/>
<div className="mt-2">
<AttachmentPicker items={attachments} onChange={setAttachments} disabled={busy} />
</div>
<div className="flex items-center gap-2 mt-2 flex-wrap">
{error && <span className="text-xs text-red-600 min-w-0 break-words">{error}</span>}
<div className="flex-1" />
{budget && !budgetEditing && (
<button
onClick={() => {
setMaxRoundsDraft(budget.unlimited ? '' : String(budget.max_rounds));
setBudgetEditing(true);
}}
title="本任务往返预算Agent 主动发信上限(自动转发与权限询问不占)"
className={`tap inline-flex items-center gap-1 text-[10px] px-2 py-1 rounded border ${
budget.unlimited
? 'border-gray-200 text-gray-500 hover:border-blue-300'
: budget.remaining === 0
? 'border-red-200 text-red-600'
: 'border-gray-200 text-gray-500 hover:border-blue-300'
}`}
>
<GaugeIcon className="w-3 h-3" />
{budget.unlimited
? '预算不限'
: `${budget.used_rounds}/${budget.max_rounds} 来回${budget.remaining === 0 ? ' · 已用尽' : ''}`}
</button>
)}
{budget && budgetEditing && (
<span className="inline-flex items-center gap-1">
<span className="text-[10px] text-gray-500"></span>
<input
value={maxRoundsDraft}
onChange={e => setMaxRoundsDraft(e.target.value)}
inputMode="numeric"
placeholder="不限"
autoFocus
className="w-14 text-xs border border-gray-300 rounded px-1.5 py-0.5 focus:outline-none focus:ring-2 focus:ring-blue-100"
/>
<button
disabled={budgetBusy || (maxRoundsDraft.trim() !== '' && !/^\d+$/.test(maxRoundsDraft.trim()))}
onClick={async () => {
setBudgetBusy(true);
await setBudget({ max_rounds: maxRoundsDraft.trim() === '' ? 0 : Number(maxRoundsDraft.trim()) });
setBudgetBusy(false);
setBudgetEditing(false);
}}
className="tap px-2 py-0.5 text-[10px] rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40"
>
</button>
<button
onClick={() => setBudgetEditing(false)}
className="tap text-[10px] text-gray-400 hover:text-gray-700"
>
×
</button>
</span>
)}
<button
onClick={() => {
setBody('');
setError(null);
}}
className="tap px-3 py-1.5 text-xs text-gray-500 hover:text-gray-800"
>
</button>
<button
onClick={send}
disabled={busy || !body.trim()}
className="tap px-4 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed"
>
{busy ? '发送中' : '发送'}
</button>
</div>
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const map: Record<string, { label: string; cls: string }> = {
active: { label: '进行中', cls: 'bg-yellow-100 text-yellow-700' },
waiting: { label: '等待中', cls: 'bg-blue-100 text-blue-700' },
completed: { label: '已完成', cls: 'bg-green-100 text-green-700' },
archived: { label: '已归档', cls: 'bg-gray-200 text-gray-600' }
};
const b = map[status] || map.active;
return <span className={`text-[10px] px-1.5 py-0.5 rounded-full ${b.cls}`}>{b.label}</span>;
}