# 起因:一次端到端验证暴露的静默缺口
建了示例工程让 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
977 lines
38 KiB
TypeScript
977 lines
38 KiB
TypeScript
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>;
|
||
}
|