From 19a3161ee43369146b6867c8e2b7cb2117aedf06 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 11 Sep 2026 11:28:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(pi):=20=E4=BA=A4=E4=BA=92=E5=BC=8F=20pi=20?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E6=8E=A5=E5=85=A5=E9=82=AE=E4=BB=B6=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=EF=BC=88send=5Fmail/read=5Finbox=20=E7=AD=89=2010=20?= =?UTF-8?q?=E4=B8=AA=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题(⑧):守护进程用 noExtensions:true 起会话,它的邮件工具只给模型在邮件 会话里用;人在 TUI 里敲的 pi 拿不到。结果是平台的建设者自己收不到邮件 —— 一个「邮件驱动」的平台,维护者只能绕到 curl + 密钥直连 Gateway 才能看收件箱。 新增 plugins/pi-mail-bridge/extension/index.ts:把同一套工具(createMailTools) 注册到交互式会话。两者是同一条 AgentMail 身份(agent pi)的两个入口,与 DSH 的 「TUI + 邮箱是同一个 Agent」一致。 密钥解析顺序(交互式 pi 的环境里没有 AGENTMAIL_*): 1. 进程环境 2. AGENTMAIL_ENV_FILE(默认 /etc/agentmail/pi.env)—— 与守护进程同一把密钥, 因此身份一致 3. AGENTMAIL_CONFIG_DIR/agent.key 或 ~/.agentmail/agent.key (兼容 key 与 key_token 两种字段名;实测本机文件用的是 key_token, 只认 key 会静默读不到) 拿不到密钥时不注册任何工具并明确告知 —— 挂一组永远 401 的工具比没有更糟。 不注册 connect_to_server:它会重写 Gateway 坐标并重新登记密钥,而交互式会话与 守护进程共用同一身份,一次 TUI 对话不该改到守护进程的配置。 为什么不会重复注册(读 SDK 实现确认,并用探针实测): resource-loader.js 里 noExtensions 为真时只用 cliEnabledExtensions, settings.json 的 extensions 数组被排除 —— 即 noExtensions:true 只加载 命令行 -e 传入的扩展。 探针:noExtensions=true → 扩展数=0;false → 16 个且含 pi-mail-bridge。 deploy/install.sh 增加幂等的扩展注册步骤(写入 settings.json 的 extensions)。 验证:headless pi 实际调用 read_inbox 返回真实邮件主题;工具清单含 send_mail/read_inbox/read_mail/forward_mail/upload_attachment/download_attachment/ suggest_address/list_contacts/session_participants/read_thread(10 个), connect_to_server 按设计排除。 --- client/electron/src/components/MailView.tsx | 1767 +++---- client/electron/src/types/index.ts | 558 +-- .../test/components/PermissionPanel.test.tsx | 552 ++- deploy/install.sh | 34 + .../dsh-mail-bridge/lib/mail-session-id.d.ts | 10 +- .../dsh-mail-bridge/lib/mail-session-id.js | 42 +- plugins/dsh-mail-bridge/lib/sse-client.d.ts | 22 +- .../dsh-mail-bridge/lib/user-question.d.ts | 22 +- plugins/dsh-mail-bridge/src/index.ts | 4085 +++++++++-------- .../test/mail-session-id.test.mjs | 82 +- .../dsh-mail-bridge/test/sse-client.test.mjs | 187 +- plugins/opencode-mail-bridge/index.js | 2297 ++++----- .../opencode-mail-bridge/lib/sse-client.js | 249 +- .../opencode-mail-bridge/lib/user-question.js | 206 +- .../test/sse-client.test.mjs | 187 +- .../test/user-question.test.mjs | 240 +- plugins/pi-mail-bridge/extension/index.ts | 187 + plugins/pi-mail-bridge/src/gateway.mjs | 358 +- plugins/pi-mail-bridge/src/pool.mjs | 569 +-- plugins/pi-mail-bridge/test/pool.test.mjs | 613 +-- .../pi-mail-bridge/test/sse-client.test.mjs | 187 +- server/internal/handler/permission.go | 1 - 22 files changed, 6889 insertions(+), 5566 deletions(-) create mode 100644 plugins/pi-mail-bridge/extension/index.ts diff --git a/client/electron/src/components/MailView.tsx b/client/electron/src/components/MailView.tsx index 6f9ec6e..52da78c 100644 --- a/client/electron/src/components/MailView.tsx +++ b/client/electron/src/components/MailView.tsx @@ -1,128 +1,155 @@ -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 { 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'; + 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(null); - // 正在看哪封邮件的对话树;null = 看正常的邮件视图 - const [threadOf, setThreadOf] = useState(null); + 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(null); + // 正在看哪封邮件的对话树;null = 看正常的邮件视图 + const [threadOf, setThreadOf] = useState(null); - // 切换邮件时关掉树视图:树是针对某封邮件的,留着会显示上一封的线索 - const currentMailID = currentMail?.mail_id; - useEffect(() => { - setThreadOf(null); - }, [currentMailID]); + // 切换邮件时关掉树视图:树是针对某封邮件的,留着会显示上一封的线索 + 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 ( -
-
-
- - - {currentSession.session_alias - ? `.${currentSession.session_alias}` - : '(未命名会话)'} - - - - {currentSessionMails.length} 封 - -
- - -
-

{currentSession.subject}

-
- -
- {currentSessionMails.map(m => ( - setForwarding(m)} /> - ))} -
- {/* 会话视图原先只有回复,转发入口只存在于单封邮件视图 —— + if (currentSession && currentSessionMails.length > 0) { + const last = currentSessionMails[currentSessionMails.length - 1]; + // 会话视图的对端是**会话的属性**,不能由「最后一封是谁发的」决定: + // 人在这里打字就是「给这次任务的对方追加一句」,而最后一封很可能是自己刚发的, + // 那时取对端会取成自己 —— 信就发给了自己(生产已发生)。 + const sessionTarget = sessionReplyTarget( + currentSessionMails, + currentSession, + me, + ); + return ( +
+
+
+ + + {currentSession.session_alias + ? `.${currentSession.session_alias}` + : "(未命名会话)"} + + + + {currentSessionMails.length} 封 + +
+ + +
+

+ {currentSession.subject} +

+
+ +
+ {currentSessionMails.map((m) => ( + setForwarding(m)} + /> + ))} +
+ {/* 会话视图原先只有回复,转发入口只存在于单封邮件视图 —— 而人多数时间待在会话视图里,等于转发功能在 UI 上找不到 */} - {forwarding ? ( - setForwarding(null)} /> - ) : ( - - )} -
- ); - } + {forwarding ? ( + setForwarding(null)} /> + ) : ( + + )} +
+ ); + } - if (!currentMail) { - return ( -
-
- -

选择一封邮件查看,或点击左侧「新建」写邮件

-
-
- ); - } + if (!currentMail) { + return ( +
+
+ +

+ 选择一封邮件查看,或点击左侧「新建」写邮件 +

+
+
+ ); + } - if (threadOf) { - return setThreadOf(null)} />; - } + if (threadOf) { + return setThreadOf(null)} />; + } - return ( -
-
markRead(currentMail.mail_id)} - onForward={() => setForwarding(currentMail)} - onThread={() => setThreadOf(currentMail.mail_id)} - /> -
-
- {currentMail.body} -
- - {currentMail.mail_type === 'permission_request' && ( - - )} -
- {forwarding ? ( - setForwarding(null)} /> - ) : ( - - )} -
- ); + return ( +
+
markRead(currentMail.mail_id)} + onForward={() => setForwarding(currentMail)} + onThread={() => setThreadOf(currentMail.mail_id)} + /> +
+
+ {currentMail.body} +
+ + {currentMail.mail_type === "permission_request" && ( + + )} +
+ {forwarding ? ( + setForwarding(null)} /> + ) : ( + + )} +
+ ); } /** @@ -133,62 +160,65 @@ export default function MailView() { * 三档: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); + 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; + if (!session) return null; - const mode = session.permission_mode || 'workspace'; - const enforcement = session.permission_enforcement || 'advisory'; + const mode = session.permission_mode || "workspace"; + const enforcement = session.permission_enforcement || "advisory"; - if (!editing) { - return ( - - ); - } + if (!editing) { + return ( + + ); + } - const modes = [ - { value: 'plan', label: '只读', desc: '不许写/改/执行' }, - { value: 'workspace', label: '目录内', desc: '越界问人' }, - { value: 'full', label: '全权', desc: '自动放行' }, - ]; + const modes = [ + { value: "plan", label: "只读", desc: "不许写/改/执行" }, + { value: "workspace", label: "目录内", desc: "越界问人" }, + { value: "full", label: "全权", desc: "自动放行" }, + ]; - return ( -
- {modes.map(o => ( - - ))} - -
- ); + return ( +
+ {modes.map((o) => ( + + ))} + +
+ ); } /** @@ -199,85 +229,87 @@ function PermissionEditor() { * 而不是「这件事值得多少个来回」。 */ 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); + 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; + if (!budget) return null; - const exhausted = !budget.unlimited && budget.remaining === 0; + const exhausted = !budget.unlimited && budget.remaining === 0; - const open = () => { - setDraft(budget.unlimited ? '' : String(budget.max_rounds)); - setEditing(true); - }; + 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); - }; + const commit = async (patch: { max_rounds?: number; reset?: boolean }) => { + setBusy(true); + await setBudget(patch); + setBusy(false); + setEditing(false); + }; - if (!editing) { - return ( - - ); - } + if (!editing) { + return ( + + ); + } - const invalid = draft.trim() !== '' && !/^\d+$/.test(draft.trim()); + const invalid = draft.trim() !== "" && !/^\d+$/.test(draft.trim()); - return ( -
- 往返预算 - 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' - }`} - /> - - - -
- ); + return ( +
+ 往返预算 + 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" + }`} + /> + + + +
+ ); } /** @@ -288,53 +320,58 @@ function BudgetEditor() { * 提议 + 人确认,既让 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); + 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; + if (!proposal) return null; - const from = current?.session_alias ? `.${current.session_alias}` : '(未命名)'; + const from = current?.session_alias + ? `.${current.session_alias}` + : "(未命名)"; - return ( -
-
- -
-

- Agent 建议把会话别名从 {from} 改为{' '} - .{proposal.alias} -

- {proposal.reason && ( -

{proposal.reason}

- )} -

- 改名后需用 name@path.{proposal.alias} 寻址;旧别名立即失效。 - 接受后此别名不再被 Agent 平台的自动命名覆盖 -

-
- - -
-
- ); + return ( +
+
+ +
+

+ Agent 建议把会话别名从 {from}{" "} + 改为{" "} + .{proposal.alias} +

+ {proposal.reason && ( +

+ {proposal.reason} +

+ )} +

+ 改名后需用 name@path.{proposal.alias} 寻址;旧别名立即失效。 + 接受后此别名不再被 Agent 平台的自动命名覆盖 +

+
+ + +
+
+ ); } /** @@ -342,248 +379,287 @@ function RenameProposalBar() { * 收件人用与写信页一致的三段式补全,正文引用由服务端生成(保证格式统一)。 */ 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(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 [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(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); - } - }; + 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 ( -
-
- - - 转发「{mail.subject}」 - -
- -
+ return ( +
+
+ + + 转发「{mail.subject}」 + +
+ +
- + - {ccOpen && ( - - )} + {ccOpen && ( + + )} -