diff --git a/deploy/check-shared-libs.sh b/deploy/check-shared-libs.sh index d8862d2..61d2645 100755 --- a/deploy/check-shared-libs.sh +++ b/deploy/check-shared-libs.sh @@ -12,7 +12,7 @@ PEERS=(plugins/dsh-mail-bridge plugins/pi-mail-bridge) fail=0 for peer in "${PEERS[@]}"; do - for f in relay-dedup inbox-format session-snapshot workspace model-scope catchup addressing discovery; do + for f in relay-dedup inbox-format session-snapshot workspace model-scope catchup addressing discovery rename-proposal; do if [[ ! -f "$peer/lib/$f.js" ]]; then echo "共用模块缺失:$peer/lib/$f.js" >&2 fail=1 @@ -26,7 +26,7 @@ for peer in "${PEERS[@]}"; do done # 测试同样要同源:共用模块的行为约定写在测试里, # 只同步实现不同步测试,等于允许一侧偷偷放宽约定。 - for f in inbox-format session-snapshot workspace model-scope catchup addressing discovery; do + for f in inbox-format session-snapshot workspace model-scope catchup addressing discovery rename-proposal; do if [[ ! -f "$peer/test/$f.test.mjs" ]]; then echo "共用测试缺失:$peer/test/$f.test.mjs" >&2 fail=1 diff --git a/gateway/internal/handler/mail.go b/gateway/internal/handler/mail.go index 39030b6..14062d5 100644 --- a/gateway/internal/handler/mail.go +++ b/gateway/internal/handler/mail.go @@ -186,7 +186,44 @@ func SendMail(w http.ResponseWriter, r *http.Request) { } var budget repo.SessionBudget + // relayFree 表示本次 relay 走免配额通道。 + // + // **免配额只给发往人类的 relay。** + // + // 豁免的理由是「harness 把平台原生的权限询问与最终总结搬进邮件, + // 不该算模型的自主发信」—— 而那是**假定收件方是人**写的。 + // 收件方是另一个同样会自动转发的 Agent 时,双方都不在做决定, + // 整个回路里没有任何一处在计数 —— 生产上跑出过 41 封且间隔从 + // 15 分钟缩到 5 秒的无穷循环(会话 f3d824ce)。 + // + // 因此 Agent→Agent 的 relay 照样扣会话预算,max_rounds 就能截断它。 + relayFree := false if relay != "" { + human, hErr := repo.IsHumanUser(r.Context(), to.Name) + if hErr != nil { + Error(w, http.StatusInternalServerError, "Failed to resolve recipient") + return + } + relayFree = human + } + + if relay != "" { + // 硬上限:一条会话里**连续**的 relay 邮件不得超过上限。 + // + // 与预算无关的第二道防线:预算给得大(比如 200)时,两个 Agent 仍能 + // 烧掉 200 个来回;而故障报告这类**必须**走 relay 的邮件也需要受约束。 + // + // 「连续」是关键:中间只要有一封自主发信或人类插话,计数就归零。 + hops, hopErr := repo.CountTrailingRelayHops(r.Context(), sessionID) + if hopErr == nil && hops >= repo.MaxRelayHops() { + Error(w, http.StatusForbidden, fmt.Sprintf( + "本会话已连续 %d 封自动转发(上限 %d)。这通常意味着两个 Agent 在互相"+ + "唤醒而无人决策。若确实需要继续,请由模型主动调 send_mail(不带 relay),"+ + "或由人类在会话里插一句话。", + hops, repo.MaxRelayHops())) + return + } + // 先占幂等键。重复则说明这条上游消息已经转过, // 这是插件重试 / SSE 重放的正常结果,不是故障 —— 幂等地返回成功。 if cErr := repo.ClaimRelay(r.Context(), agentName, relayKey, relay); cErr != nil { @@ -202,7 +239,10 @@ func SendMail(w http.ResponseWriter, r *http.Request) { Error(w, http.StatusInternalServerError, "Failed to claim relay") return } - // 仅读快照用于回传,不扣预算 + } + + if relayFree { + // 只读快照用于回传,不扣预算 budget, _ = repo.GetSessionBudget(r.Context(), sessionID) } else { // 额度只看【本任务】的往返预算。 @@ -212,6 +252,11 @@ func SendMail(w http.ResponseWriter, r *http.Request) { // 靠的是新建会话速率限制(resolveTarget 里)。 budget, err = repo.ConsumeSessionBudget(r.Context(), sessionID) if errors.Is(err, repo.ErrSessionBudgetExhausted) { + // 预算耗尽时要把幂等键还回去:否则那条上游消息永远转不出来了, + // 之后管理员加了额度也无法重发。 + if relay != "" { + _ = repo.ReleaseRelay(r.Context(), agentName, relayKey) + } Error(w, http.StatusForbidden, fmt.Sprintf( "本任务的往返预算已用尽(%d/%d)。自动转发的总结与权限询问不占预算;"+ "若需继续主动发信,请让人在对话页调高本任务的预算。", diff --git a/gateway/internal/repo/relayhops.go b/gateway/internal/repo/relayhops.go new file mode 100644 index 0000000..ffb5582 --- /dev/null +++ b/gateway/internal/repo/relayhops.go @@ -0,0 +1,83 @@ +package repo + +import ( + "context" + + "github.com/agentmail/gateway/internal/db" + "github.com/google/uuid" +) + +// 连续 relay 跳数限制 —— 防止两个 Agent 靠自动转发互相唤醒到无穷。 +// +// # 这是什么问题 +// +// 每个插件都在「一轮结束时把模型最后那段话自动发回去」(契约 B-5)。 +// 当收件方也是一个装了同类插件的 Agent 时,这封信唤醒对方 → 对方跑一轮 → +// 对方也自动回一封 → 循环。**双方都没有「决定继续」,因为双方都不在做决定** —— +// 发信是插件代劳的。 +// +// 生产上真实发生过:会话 f3d824ce(dsh 与 opencode 联调 llmsproxy)共 41 封, +// 最后一封人类意图的邮件之后,**每一封都是 relay:summary**, +// 间隔从 15 分钟一路缩到 5 秒,内容已无新增信息。 +// +// # 为什么 relay_key 拦不住 +// +// 它是幂等键,职责是「同一条上游消息不重复转发」,这一点它做对了。 +// 但每一轮都是**真正不同**的新消息:opencode 侧是 assistant message id +// (msg_0655bbf6…、msg_0656cc7fc…),dsh 侧是事件计数(…:12220、…:13347)。 +// 每次 ClaimRelay 都合法通过。 +// +// # 为什么需要两道防线 +// +// 主防线是「免配额只给发往人类的 relay」(见 handler.SendMail): +// Agent→Agent 的自动转发转而消耗会话预算,max_rounds 会截断它。 +// +// 但那还不够:预算给得大(比如 200)时,两个 Agent 仍能烧掉 200 个来回; +// 而故障报告这类**必须**走 relay 的邮件也需要受约束。因此这里再加一道 +// 与预算无关的硬上限:一条会话里**连续**的 relay 邮件不得超过 maxRelayHops。 +// +// 「连续」是关键:只要中间有一封自主发信(模型真的决定说什么)或人类插话, +// 计数就归零。这让正常的「模型回一封、插件补一封总结」不受影响, +// 只掐住「全程无人决策」的那种回路。 +// +// hop_limit 列早就在 schema 里(DEFAULT 5)却从没有人读它 —— 它显然 +// 就是为这件事准备的。这里把它接上,取同一个默认值。 +const maxRelayHops = 5 + +// CountTrailingRelayHops 数会话尾部**连续**的 relay 邮件数。 +// +// 从最新一封往前扫,遇到第一封非 relay 邮件即停。返回值即「若本次再发一封 +// relay,它会是第几跳」的前一个数。 +// +// 判据用 relayed_mails 的存在性而不是 mails 上的某个标记: +// relay 身份本来就记在那张表里,在 mails 上再存一份等于给同一事实留两个答案。 +func CountTrailingRelayHops(ctx context.Context, sessionID uuid.UUID) (int, error) { + rows, err := db.DB.QueryContext(ctx, ` + SELECT CASE WHEN r.mail_id IS NULL THEN 0 ELSE 1 END AS is_relay + FROM mails m + LEFT JOIN relayed_mails r ON r.mail_id = m.mail_id + WHERE m.session_id = $1 + ORDER BY m.created_at DESC, m.mail_id DESC + `, sessionID) + if err != nil { + return 0, err + } + defer rows.Close() + + hops := 0 + for rows.Next() { + var isRelay int + if err := rows.Scan(&isRelay); err != nil { + return hops, err + } + if isRelay == 0 { + // 遇到一封自主发信/人类邮件:链条到此为止 + break + } + hops++ + } + return hops, rows.Err() +} + +// MaxRelayHops 暴露上限供错误文案与测试使用。 +func MaxRelayHops() int { return maxRelayHops } diff --git a/gateway/internal/repo/relayhops_test.go b/gateway/internal/repo/relayhops_test.go new file mode 100644 index 0000000..8977cc8 --- /dev/null +++ b/gateway/internal/repo/relayhops_test.go @@ -0,0 +1,126 @@ +package repo + +import ( + "context" + "testing" + + "github.com/google/uuid" +) + +// 连续 relay 跳数上限守的是一个真实事故:会话 f3d824ce(dsh 与 opencode 联调 +// llmsproxy)共 41 封,最后一封人类意图的邮件之后每一封都是 relay:summary, +// 间隔从 15 分钟一路缩到 5 秒。双方都没有「决定继续」,因为双方都不在做决定 —— +// 发信是插件代劳的,而免配额通道让整个回路里没有任何一处在计数。 + +func TestTrailingRelayHopsEmptySession(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + + sid, _ := CreateSession(ctx, nil, "admin", "空会话", "/tmp/ws") + n, err := CountTrailingRelayHops(ctx, sid) + if err != nil { + t.Fatalf("空会话应正常返回: %v", err) + } + if n != 0 { + t.Fatalf("空会话跳数应为 0,实为 %d", n) + } +} + +func TestTrailingRelayHopsCountsOnlyRelay(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + + sid, _ := CreateSession(ctx, nil, "admin", "混合", "/tmp/ws") + // 人类发一封(非 relay) + mustMail(t, sid, "admin", "", "dsh", "/tmp/ws", nil) + // 插件连续转发三封 + for i := 0; i < 3; i++ { + seedRelayMail(t, ctx, sid, "dsh", "opencode") + } + + n, err := CountTrailingRelayHops(ctx, sid) + if err != nil { + t.Fatalf("数跳数: %v", err) + } + if n != 3 { + t.Fatalf("尾部连续 relay 应为 3,实为 %d", n) + } +} + +func TestTrailingRelayHopsResetsOnAutonomousSend(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + + // 「连续」是这条规则的关键:中间只要有一封自主发信(模型真的决定说什么) + // 或人类插话,计数就归零。否则正常的「模型回一封、插件补一封总结」 + // 会被误判成回路。 + sid, _ := CreateSession(ctx, nil, "admin", "打断", "/tmp/ws") + for i := 0; i < 4; i++ { + seedRelayMail(t, ctx, sid, "dsh", "opencode") + } + // 模型亲手发了一封 —— 链条到此为止 + mustMail(t, sid, "dsh", "", "opencode", "/tmp/ws", nil) + seedRelayMail(t, ctx, sid, "opencode", "dsh") + + n, err := CountTrailingRelayHops(ctx, sid) + if err != nil { + t.Fatalf("数跳数: %v", err) + } + if n != 1 { + t.Fatalf("自主发信之后只剩 1 跳,实为 %d", n) + } +} + +func TestTrailingRelayHopsReachesLimit(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + + // 攒到上限:此时 handler 应当拒绝下一封 relay。 + sid, _ := CreateSession(ctx, nil, "admin", "到顶", "/tmp/ws") + mustMail(t, sid, "admin", "", "dsh", "/tmp/ws", nil) + for i := 0; i < MaxRelayHops(); i++ { + seedRelayMail(t, ctx, sid, "dsh", "opencode") + } + + n, _ := CountTrailingRelayHops(ctx, sid) + if n < MaxRelayHops() { + t.Fatalf("应达到上限 %d,实为 %d", MaxRelayHops(), n) + } +} + +func TestTrailingRelayHopsIsPerSession(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + + // 一条会话的回路不该影响另一条:两个 Agent 在 A 会话里刷爆了, + // B 会话的正常自动转发仍应放行。 + a, _ := CreateSession(ctx, nil, "admin", "A", "/tmp/a") + b, _ := CreateSession(ctx, nil, "admin", "B", "/tmp/b") + for i := 0; i < 5; i++ { + seedRelayMail(t, ctx, a, "dsh", "opencode") + } + seedRelayMail(t, ctx, b, "dsh", "admin") + + na, _ := CountTrailingRelayHops(ctx, a) + nb, _ := CountTrailingRelayHops(ctx, b) + if na != 5 || nb != 1 { + t.Fatalf("跳数应按会话独立计:A=%d(期望 5)B=%d(期望 1)", na, nb) + } +} + +// seedRelayMail 建一封走 relay 通道的邮件(同时占幂等键并关联 mail_id), +// 复刻 handler.SendMail 的真实写入顺序。 +func seedRelayMail(t *testing.T, ctx context.Context, sid uuid.UUID, from, to string) { + t.Helper() + key := "relay-" + uuid.NewString() + if err := ClaimRelay(ctx, from, key, "summary"); err != nil { + t.Fatalf("占幂等键: %v", err) + } + mid, err := CreateMail(ctx, sid, nil, from, "", to, "", "Re: 主题", "正文", nil) + if err != nil { + t.Fatalf("建邮件: %v", err) + } + if err := BindRelayMail(ctx, from, key, mid); err != nil { + t.Fatalf("关联 relay: %v", err) + } +} diff --git a/plugins/dsh-mail-bridge/lib/rename-proposal.d.ts b/plugins/dsh-mail-bridge/lib/rename-proposal.d.ts new file mode 100644 index 0000000..2c1a518 --- /dev/null +++ b/plugins/dsh-mail-bridge/lib/rename-proposal.d.ts @@ -0,0 +1,18 @@ +export declare function isProposableAlias(alias: string | null | undefined): boolean; + +export interface ProposalResult { + body: string; + proposed: boolean; +} + +export declare function appendRenameProposal( + body: string, + alias?: string, + reason?: string, +): ProposalResult; + +export declare function renameProposalNote( + serverAlias?: string, + requestedAlias?: string, + proposed?: boolean, +): string; diff --git a/plugins/dsh-mail-bridge/lib/rename-proposal.js b/plugins/dsh-mail-bridge/lib/rename-proposal.js new file mode 100644 index 0000000..9deb385 --- /dev/null +++ b/plugins/dsh-mail-bridge/lib/rename-proposal.js @@ -0,0 +1,109 @@ +/** + * 会话改名提议 —— 所有平台插件共用。 + * + * # 这是什么 + * + * 模型干完活后可能觉得当前别名不贴切:会话建立时叫 `witty-planet`(平台随机 slug) + * 或 `排查登录问题`(人写的邮件主题),摸清问题后它知道这其实是 + * `fix-session-cookie-leak`。改名提议就是让它把这个判断说出来。 + * + * # 为什么是「提议」而不是直接改 + * + * 别名是**人**的寻址入口 —— `name@path.<别名>` 里那一段。Agent 干到一半自己改掉, + * 人上一秒记住的地址下一秒就 404(`session` 位三态语义要求指向不存在的会话直接报 + * 「无法送达」,不会静默新建)。所以提议入库、由人在界面上点「接受」才真正生效。 + * + * 这与平台命名自动同步(`POST /sessions/{id}/sync`)互补,两者不冲突: + * + * | | 谁发起 | 何时 | 是否打扰人 | + * |---|---|---|---| + * | 自动同步 | 平台的命名机制 | 每轮结束 | 不,后台静默生效 | + * | 改名提议 | 模型的主动判断 | 它认为有必要时 | 是,界面上出提示条 | + * + * # 为什么载体是 HTML 注释 + * + * `/mail/send` 没有 `propose_alias` 字段 —— 提议**搭在正文里**发出去, + * 服务端用正则摘出来再把标记从入库正文中剥掉。选 HTML 注释的三个理由: + * + * - react-markdown 默认不解析 raw HTML,万一服务端没剥掉,它在页面上也只是 + * 一行不显眼的转义文本,不会破版 + * - 纯文本邮件客户端里是一行不碍事的注释,不像自造标记那样显眼 + * - 不与 Markdown 语法冲突,格式化工具不会改写它 + * + * # 为什么必须共用 + * + * 标记格式是**服务端正则的镜像**(`gateway/internal/handler/rename_proposal.go`)。 + * 各平台各写一遍拼接,某一处少个空格或把双引号写成单引号,服务端匹配不上 —— + * 而失败是静默的:邮件照常发出,提议凭空消失,模型以为自己提过了。 + */ + +/** + * 服务端能识别的别名字符集。 + * + * 与 `validateSessionAlias` 一致:`. 空白 / @` 会与三维地址解析冲突, + * `new` 是寻址保留字。这里**不做规范化**(不把非法字符替换成 `-`)—— + * 规范化是服务端 `normalizeAlias` 的职责,插件擅自改写会让模型看到的 + * 「我提议的名字」与实际入库的不一致。 + * + * @param {string} alias + * @returns {boolean} + */ +export function isProposableAlias(alias) { + const a = String(alias ?? '').trim(); + if (!a) return false; + if (a === 'new') return false; + // 双引号是标记本身的定界符,含它会截断标记 + if (/[.\s/@"]/.test(a)) return false; + // 服务端 VARCHAR(128),按字节算 + if (Buffer.byteLength(a, 'utf8') > 128) return false; + return true; +} + +/** + * 把改名提议标记追加到正文末尾。 + * + * 格式必须与服务端正则逐字符对应: + * `` + * reason 可选,为空时**整个属性都不写**(写成 `reason=""` 服务端会存一个空理由, + * 界面上的提示条就少了那句解释)。 + * + * 别名不合法时**原样返回正文**,不追加标记:与其发一个服务端匹配得上却 + * 被 `validateSessionAlias` 拒掉的标记,不如当它没提 —— 调用方据此告诉模型。 + * + * @param {string} body 原始正文 + * @param {string} [alias] 提议的别名 + * @param {string} [reason] 提议理由,一句话 + * @returns {{body: string, proposed: boolean}} proposed=false 表示别名不合法,未追加 + */ +export function appendRenameProposal(body, alias, reason) { + const text = String(body ?? ''); + if (!isProposableAlias(alias)) return { body: text, proposed: false }; + + const a = String(alias).trim(); + // 理由里的双引号会截断标记,去掉而不是转义:HTML 注释里没有转义机制 + const r = String(reason ?? '').replace(/"/g, '').trim(); + const reasonAttr = r ? ` reason="${r}"` : ''; + + return { + body: `${text}\n\n`, + proposed: true, + }; +} + +/** + * 提议提交后回给模型的那句话。 + * + * 必须说明「等人确认」。不说的话模型会以为改名已经生效,接着在后续邮件里 + * 用新别名当地址发信 —— 而那个别名此刻还不存在,投递会失败。 + * + * @param {string} alias + * @param {boolean} proposed appendRenameProposal 的返回值 + * @returns {string} 空串表示没有需要追加的说明 + */ +export function renameProposalNote(alias, proposed) { + if (!alias) return ''; + if (!proposed) { + return `(改名提议 "${alias}" 未提交:别名不可为 new,不可含 . 空白 / @ 或双引号。)`; + } + return `已附上改名提议 "${alias}",等用户在界面上确认后生效 —— 在那之前继续用原别名寻址。`; +} diff --git a/plugins/dsh-mail-bridge/src/index.ts b/plugins/dsh-mail-bridge/src/index.ts index 61a8b97..ceb48cc 100644 --- a/plugins/dsh-mail-bridge/src/index.ts +++ b/plugins/dsh-mail-bridge/src/index.ts @@ -12,7 +12,7 @@ */ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; -import { readFile, writeFile } from 'node:fs/promises'; +import { readFile, writeFile, mkdir, stat } from 'node:fs/promises'; import { randomBytes } from 'node:crypto'; import { homedir } from 'node:os'; import { join, dirname } from 'node:path'; @@ -50,6 +50,7 @@ import { renderContacts, renderThread, } from '../lib/discovery.js'; +import { appendRenameProposal, renameProposalNote } from '../lib/rename-proposal.js'; // ─── 凭证管理 ─── @@ -628,14 +629,27 @@ export function apply(ctx: any, config: PluginConfig): void { // 的唯一路径。不挂的后果:会话永远落在 Ungrouped,人类在侧栏看不到它 // 与项目的从属关系,也无法用 DSH 的工作区级操作(批量归档、重命名等)。 // - // 失败不可怕:会话本身已经能用了,只是 GUI 分组不对。最常见失败是 - // 邮件里的 cwd 在 DSH 主机上不存在(跨主机场景),此时 mkdirSync - // 建不出目录,workspaceRegistry.create 也报错。 - if (cwd && handle) { + // **cwd 必须取自会话 header,不能用上面那个 `cwd` 变量。** + // + // 上一版就错在这里:那个变量是 `resolveWorkspaceCwd` 的结果,可能是**兜底值** + // (`~/.dsh/mail-sessions/mail-`);而 resume 路径下会话的真实 cwd 取自 + // 持久化的 header,两者不一致时 `attachSession` 的校验直接拒绝: + // + // cannot attach session 'mail-f3d824ce…' to workspace + // '/root/.dsh/mail-sessions/mail-f3d824ce…': its cwd resolves to '/home/program/llmsproxy' + // + // 更糟的是 `wr.create()` 已经先执行了 —— 于是每封邮件都往注册表里 + // 塞一条永远为空的垃圾 workspace。先读 header 再注册就不会有这个问题。 + // + // 失败不可怕:会话本身已经能用了,只是 GUI 分组不对。 + if (handle) { try { + // 真实 cwd:create 路径下就是上面传的 meta.cwd, + // resume 路径下是持久化 header 里那个。两种情形都从 session 读。 + const actualCwd = String(handle.agent?.session?.header?.cwd ?? ''); const wr: any = (ctx as any).get?.('workspaceRegistry'); - if (wr?.create) { - const ws = await wr.create(cwd, cwd.split('/').pop() || cwd); + if (actualCwd && wr?.create) { + const ws = await wr.create(actualCwd, actualCwd.split('/').pop() || actualCwd); if (ws?.attachSession) { await ws.attachSession(attemptSessionId as any); } @@ -754,14 +768,24 @@ export function apply(ctx: any, config: PluginConfig): void { reply_to: { type: 'string', description: '回复某封邮件时传其 mail_id' }, session_alias: { type: 'string', description: '给新会话命名' }, attachment_ids: { type: 'array', items: { type: 'string' }, description: '附件 ID 列表' }, + propose_alias: { + type: 'string', + description: '建议把当前会话改名成这个别名(例如摸清问题后从「排查登录问题」改成 fix-session-cookie-leak)。这只是建议:别名是人的寻址入口,实际改名由用户在界面上确认。不可含 . / @ 空白,不可为 new', + }, + propose_reason: { type: 'string', description: '改名理由,一句话,展示给用户看' }, }, output: { schema: { type: 'string' }, render: (_args: any, value: string) => [{ type: 'text', text: value }], }, async execute(args: any, toolCtx: any): Promise { + // 改名提议以 HTML 注释形式附在正文末尾,由网关解析后剥离。 + // 拼接收在 lib/rename-proposal.js(三平台共用):标记格式是服务端正则的 + // 镜像,各写一遍的话少个空格就静默失效 —— 邮件照常发出,提议凭空消失。 + const { body, proposed } = appendRenameProposal( + args.body, args.propose_alias, args.propose_reason); const result = await client.post('/mail/send', { - to: args.to, subject: args.subject, body: args.body, + to: args.to, subject: args.subject, body, cc: args.cc || '', reply_to: args.reply_to || '', session_alias: args.session_alias || '', attachment_ids: args.attachment_ids || [], @@ -769,7 +793,10 @@ export function apply(ctx: any, config: PluginConfig): void { noteExplicitSend(toolCtx?.sessionID, args.to, args.reply_to); const budget = typeof result.budget_remaining === 'number' ? ` 本任务剩余 ${result.budget_remaining}/${result.budget_max} 个来回。` : ''; - return `邮件已发送(ID: ${result.mail_id})${budget}`; + // 别名取服务端回的 rename_proposed(它跑过 normalizeAlias), + // 回显本地值会让模型记住一个不存在的名字,之后拿它寻址就 404 + const note = renameProposalNote(result.rename_proposed, args.propose_alias, proposed); + return `邮件已发送(ID: ${result.mail_id})${budget}` + (note ? `\n${note}` : ''); }, })); @@ -813,17 +840,31 @@ export function apply(ctx: any, config: PluginConfig): void { // upload_attachment ctx.tools.register(defineTool({ name: 'upload_attachment', - description: '上传本地文件作为邮件附件,返回 attachment_id。', + description: + '上传本地文件作为邮件附件,返回 attachment_id。' + + '拿到 id 后必须在 send_mail 的 attachment_ids 里带上,附件才会随邮件发出。' + + '未随邮件发出的附件 24 小时后自动清理。', parameters: { - file_path: { type: 'string', required: true, description: '本地文件路径' }, + file_path: { type: 'string', required: true, description: '要上传的本地文件绝对路径' }, + filename: { type: 'string', description: '自定义展示文件名,默认取路径的最后一段' }, }, output: { schema: { type: 'string' }, render: (_args: any, value: string) => [{ type: 'text', text: value }], }, async execute(args: any): Promise { + // 先 stat 再读:目录和不存在的路径都要给出能行动的错误。 + // 直接 readFile 的话,目录抛的 EISDIR 只会让模型重试同一个路径。 + let st; + try { + st = await stat(args.file_path); + } catch { + return `文件不存在或不可读: ${args.file_path}`; + } + if (!st.isFile()) return `不是普通文件: ${args.file_path}`; + const data = await readFile(args.file_path); - const filename = args.file_path.split('/').pop() || 'file'; + const filename = args.filename || args.file_path.split('/').pop() || 'file'; // **必须发真正的 multipart。** // // 早先这里发的是 `Content-Type: application/octet-stream` 加一个 @@ -843,17 +884,18 @@ export function apply(ctx: any, config: PluginConfig): void { const json = await res.json() as any; if (!res.ok) throw new Error(json?.error || `HTTP ${res.status}`); const a = json.attachment; - return `已上传 ${a.filename}(${formatSize(a.size_bytes)})。attachment_id: ${a.attachment_id}`; + return `已上传 ${a.filename}(${formatSize(a.size_bytes)})。attachment_id: ${a.attachment_id}\n` + + `在 send_mail 的 attachment_ids 里带上这个 id 才会随邮件发出。`; }, })); // download_attachment ctx.tools.register(defineTool({ name: 'download_attachment', - description: '下载邮件附件到本地文件。', + description: '下载邮件附件到本地文件。attachment_id 从 read_inbox 的附件清单里取。', parameters: { attachment_id: { type: 'string', required: true, description: '附件 ID' }, - save_path: { type: 'string', required: true, description: '保存路径' }, + save_path: { type: 'string', required: true, description: '保存到的本地绝对路径' }, }, output: { schema: { type: 'string' }, @@ -865,6 +907,9 @@ export function apply(ctx: any, config: PluginConfig): void { }); if (!res.ok) throw new Error(`下载失败: HTTP ${res.status}`); const buf = Buffer.from(await res.arrayBuffer()); + // 父目录不存在时先建:模型经常写 ./downloads/x.pdf 这类还不存在的路径, + // 不建的话 writeFile 抛 ENOENT,而那个错误看起来像「附件不存在」。 + await mkdir(dirname(args.save_path), { recursive: true }); await writeFile(args.save_path, buf); return `已保存到 ${args.save_path}(${formatSize(buf.length)})`; }, @@ -1043,11 +1088,71 @@ export function apply(ctx: any, config: PluginConfig): void { }, })); + // connect_to_server —— 连接自愈。 + // + // 之前只有 opencode 侧有这个工具。后果是:Gateway 换了地址、或密钥需要 + // 重新登记时,opencode 里的模型能自己修好,而 DSH 里的模型只能干等 + // systemd 环境变量被人改 —— 同一类能力在不同平台上时有时无, + // 等于让人记住哪个平台能自己修。 + // + // 失败时**把需要登记的密钥全文打出来**:密钥未登记是最常见的失败, + // 不给值的话要多走一轮「密钥无效 → 去哪拿 → 让管理员登记」。 + ctx.tools.register(defineTool({ + name: 'connect_to_server', + description: + '连接到 AgentMail Gateway:登记本机密钥并完成注册。首次安装或换了 Gateway 地址时调用。' + + '密钥若未在后台登记过,此处会返回需要登记的密钥全文。', + parameters: { + gateway_url: { type: 'string', description: 'Gateway 地址,如 https://mail.example.com;省略则用当前配置' }, + key_token: { type: 'string', description: '管理员签发的 Agent 密钥;省略则用本地密钥(不存在时自动生成)' }, + }, + output: { + schema: { type: 'string' }, + render: (_args: any, value: string) => [{ type: 'text', text: value }], + }, + async execute(args: any): Promise { + let key = client.agentKey; + if (args.key_token) { + key = String(args.key_token).trim(); + // 管理员给的密钥落盘,重启后仍然可用 + saveLocalKey(key); + } else if (!key) { + key = generateLocalKey(); + } + + const url = String(args.gateway_url || client.baseURL).replace(/\/+$/, ''); + + const res = await fetch(`${url}/api/v1/agent/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` }, + body: JSON.stringify({ name: AGENT_NAME, workspaces: [], platform: 'dsh' }), + }); + const data = await res.json().catch(() => ({})) as any; + + if (!res.ok) { + return [ + `连接失败(HTTP ${res.status}):${data?.error || '未知错误'}`, + ``, + `若提示密钥无效,请让管理员在 AgentMail 后台「Agent 密钥」中登记:`, + key, + ``, + `密钥文件:${KEY_FILE}`, + ].join('\n'); + } + + // 成功后把新坐标写回客户端,当场生效(不用等重启) + client.baseURL = url; + client.agentKey = key; + return `已连接 ${url},注册为 ${data?.agent_name || AGENT_NAME}。`; + }, + })); + return () => { for (const n of [ 'send_mail', 'read_inbox', 'read_mail', 'forward_mail', 'upload_attachment', 'download_attachment', 'suggest_address', 'list_contacts', 'session_participants', 'read_thread', + 'connect_to_server', ]) { try { ctx.tools.unregister(n); } catch {} } diff --git a/plugins/dsh-mail-bridge/test/rename-proposal.test.mjs b/plugins/dsh-mail-bridge/test/rename-proposal.test.mjs new file mode 100644 index 0000000..a7e366e --- /dev/null +++ b/plugins/dsh-mail-bridge/test/rename-proposal.test.mjs @@ -0,0 +1,119 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + isProposableAlias, + appendRenameProposal, + renameProposalNote, +} from '../lib/rename-proposal.js'; + +// 这一组测试钉住的是「插件拼的标记与服务端正则逐字符对应」。 +// 服务端那条正则在 gateway/internal/handler/rename_proposal.go: +// +// 拼错不会报错 —— 邮件照常发出,提议凭空消失。 + +/** 服务端正则的等价实现,用来验证我们拼出来的标记真的能被摘出来。 */ +const SERVER_RE = + //s; + +test('isProposableAlias: 合法别名', () => { + assert.equal(isProposableAlias('fix-login-leak'), true); + assert.equal(isProposableAlias('修复登录态泄漏'), true, '中文别名合法'); + assert.equal(isProposableAlias('v2_migration'), true); +}); + +test('isProposableAlias: new 是寻址保留字', () => { + // `.new` 是「强制新建会话」的动作,别名叫 new 会让地址无从解释 + assert.equal(isProposableAlias('new'), false); +}); + +test('isProposableAlias: 拒绝与三维地址冲突的字符', () => { + // 这四个字符都会让 name@path.session 的切分产生歧义 + assert.equal(isProposableAlias('a.b'), false, '. 是 session 位分隔符'); + assert.equal(isProposableAlias('a/b'), false, '/ 出现在 path 位'); + assert.equal(isProposableAlias('a@b'), false, '@ 是 name/path 分隔符'); + assert.equal(isProposableAlias('a b'), false, '空白'); + assert.equal(isProposableAlias('a\tb'), false, '制表符也算空白'); +}); + +test('isProposableAlias: 拒绝双引号', () => { + // 双引号是标记自身的定界符,含它会把标记截断成非法形式 + assert.equal(isProposableAlias('say"hi'), false); +}); + +test('isProposableAlias: 空与空白视为没提', () => { + assert.equal(isProposableAlias(''), false); + assert.equal(isProposableAlias(' '), false); + assert.equal(isProposableAlias(undefined), false); + assert.equal(isProposableAlias(null), false); +}); + +test('isProposableAlias: 超过 128 字节按字节算', () => { + // 服务端是 VARCHAR(128)。中文一个字 3 字节,43 字 = 129 字节 + assert.equal(isProposableAlias('a'.repeat(128)), true); + assert.equal(isProposableAlias('a'.repeat(129)), false); + assert.equal(isProposableAlias('汉'.repeat(42)), true, '126 字节'); + assert.equal(isProposableAlias('汉'.repeat(43)), false, '129 字节'); +}); + +test('标记能被服务端正则摘出来', () => { + const { body, proposed } = appendRenameProposal('已定位到问题。', 'fix-login-leak', '登录态泄漏'); + assert.equal(proposed, true); + const m = SERVER_RE.exec(body); + assert.ok(m, '服务端正则必须匹配得上'); + assert.equal(m[1], 'fix-login-leak'); + assert.equal(m[2], '登录态泄漏'); +}); + +test('没有理由时整个 reason 属性都不写', () => { + // 写成 reason="" 会让服务端存一个空理由,界面提示条就少了那句解释 + const { body } = appendRenameProposal('正文', 'fix-leak'); + assert.doesNotMatch(body, /reason=/); + const m = SERVER_RE.exec(body); + assert.equal(m[1], 'fix-leak'); + assert.equal(m[2], undefined); +}); + +test('理由里的双引号被去掉而不是转义', () => { + // HTML 注释里没有转义机制,留着会截断标记 + const { body } = appendRenameProposal('正文', 'fix-leak', '他说"这是泄漏"'); + const m = SERVER_RE.exec(body); + assert.ok(m); + assert.equal(m[2], '他说这是泄漏'); +}); + +test('原正文完整保留在标记之前', () => { + const original = '第一行\n\n第二行'; + const { body } = appendRenameProposal(original, 'fix-leak'); + assert.ok(body.startsWith(original), '正文不得被改写'); +}); + +test('别名不合法时原样返回,不追加标记', () => { + const { body, proposed } = appendRenameProposal('正文', 'a.b'); + assert.equal(proposed, false); + assert.equal(body, '正文'); + assert.doesNotMatch(body, /agentmail:rename-session/); +}); + +test('不给别名时正文完全不变', () => { + const { body, proposed } = appendRenameProposal('正文', ''); + assert.equal(proposed, false); + assert.equal(body, '正文'); +}); + +test('renameProposalNote: 成功时必须说明等人确认', () => { + // 不说的话模型会以为改名已生效,接着用新别名当地址发信 —— 那个别名还不存在 + const note = renameProposalNote('fix-leak', true); + assert.match(note, /fix-leak/); + assert.match(note, /确认/); + assert.match(note, /原别名/, '要明确说在那之前用哪个'); +}); + +test('renameProposalNote: 失败时说清为什么', () => { + const note = renameProposalNote('a.b', false); + assert.match(note, /未提交/); + assert.match(note, /a\.b/); +}); + +test('renameProposalNote: 没提议时不产生噪音', () => { + assert.equal(renameProposalNote('', false), ''); +}); diff --git a/plugins/homeagent-mail-bridge/plugin.go b/plugins/homeagent-mail-bridge/plugin.go index fa97d72..f0aff7b 100644 --- a/plugins/homeagent-mail-bridge/plugin.go +++ b/plugins/homeagent-mail-bridge/plugin.go @@ -28,13 +28,21 @@ const ( // 因此本插件不做会话映射 —— 所有邮件注入同一个事件循环,像 QQ 插件一样。 // 邮件的 context 完全靠中断消息的文本传递,不靠 platform_sessions 上报。 type Plugin struct { - name string - sdk *sdk.PluginSDK - gwURL string - key string - client *http.Client - stopCh chan struct{} - stopOnce sync.Once + // name 是**插件名**(homed 注册用,如 homeagent-mail-bridge)。 + name string + // agentName 是**AgentMail 身份**(如 homeagent)。 + // + // 两者必须分开:密钥绑定的是 AgentMail 身份,拿插件名去注册会被拒 + // 403 该密钥已绑定到 Agent "homeagent",不能用于注册 "homeagent-mail-bridge" + // 这不是 Gateway 太严格 —— 名字与人类用户名共用命名空间, + // 让一把密钥能注册任意名字等于让它能冒充任何人。 + agentName string + sdk *sdk.PluginSDK + gwURL string + key string + client *http.Client + stopCh chan struct{} + stopOnce sync.Once } func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) { @@ -55,12 +63,28 @@ func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, e if key == "" { key = os.Getenv("AGENTMAIL_AGENT_KEY") } + + // AgentMail 身份:config > 环境变量 > 从插件名去掉 -mail-bridge 后缀。 + // 兜底那条让默认配置能直接跑通(homeagent-mail-bridge → homeagent), + // 但显式配置永远优先 —— 插件名是部署细节,不该决定对外身份。 + agentName := "" + if v, ok := config["agent_name"].(string); ok { + agentName = strings.TrimSpace(v) + } + if agentName == "" { + agentName = strings.TrimSpace(os.Getenv("AGENTMAIL_AGENT_NAME")) + } + if agentName == "" { + agentName = strings.TrimSuffix(name, "-mail-bridge") + } + return &Plugin{ - name: name, - gwURL: strings.TrimRight(gw, "/"), - key: key, - client: &http.Client{Timeout: 30 * time.Second}, - stopCh: make(chan struct{}), + name: name, + agentName: agentName, + gwURL: strings.TrimRight(gw, "/"), + key: key, + client: &http.Client{Timeout: 30 * time.Second}, + stopCh: make(chan struct{}), }, nil } @@ -106,6 +130,94 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { }, }, p.handleSendMail) + // 读一封的完整内容(收件箱只给摘要;要回给抄收方就得先看清发给了谁) + s.RegisterTool("read_mail", sdk.ToolDef{ + Name: "read_mail", + Description: "读一封邮件的完整内容,含收件人、抄送清单、附件与每个参与方的可投递地址。", + Parameters: oneStringParam("mail_id", "邮件 ID", true), + }, p.handleReadMail) + + // 转发 —— 引用原文与附件,按目标地址另行定位会话(它是一条新线索) + s.RegisterTool("forward_mail", sdk.ToolDef{ + Name: "forward_mail", + Description: "转发一封邮件给新的收件人(自动引用原文与附件)。与回复不同:回复落回原会话,转发按目标地址另行定位会话。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "mail_id": map[string]interface{}{"type": "string", "description": "要转发的邮件 ID"}, + "to": map[string]interface{}{"type": "string", "description": "新收件人的三维地址(先用 suggest_address 确认)"}, + "comment": map[string]interface{}{"type": "string", "description": "转发说明,置于引用原文之前"}, + "cc": map[string]interface{}{"type": "string", "description": "抄送,逗号分隔多个三维地址"}, + "subject": map[string]interface{}{"type": "string", "description": "自定义主题;留空则自动加 Fwd: 前缀"}, + "session_alias": map[string]interface{}{"type": "string", "description": "仅当目标地址以 .new 结尾时生效:给新会话命名"}, + }, + "required": []string{"mail_id", "to"}, + }, + }, p.handleForwardMail) + + // ─── 寻址发现 ─── + // + // 没有这一组时,send_mail 的 to 是个只能靠记忆拼写的自由文本字段, + // 而拼错不报错:生产上另一个平台猜了 `opencode@/home`,投递成功, + // 但那不是它的工作目录,错误路径静默变成了新会话的 workspace。 + + s.RegisterTool("suggest_address", sdk.ToolDef{ + Name: "suggest_address", + Description: "查询可用的收件人地址。不带参数给候选收件人名;带 name 给它可用的工作目录;" + + "name+path 都带则给该目录下可续谈的会话与现成地址。**发信前应先用它确认地址**,不要凭记忆拼写。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "name": map[string]interface{}{"type": "string", "description": "收件人名;留空则列出所有候选收件人"}, + "path": map[string]interface{}{"type": "string", "description": "工作目录;与 name 同时给出才列会话"}, + }, + }, + }, p.handleSuggestAddress) + + s.RegisterTool("list_contacts", sdk.ToolDef{ + Name: "list_contacts", + Description: "列出自己参与过的全部会话及各自的可投递地址、未读数、剩余往返预算。用于回答「我还有什么没处理」。", + Parameters: oneStringParam("limit", "最多列出多少条,默认 20", false), + }, p.handleListContacts) + + s.RegisterTool("session_participants", sdk.ToolDef{ + Name: "session_participants", + Description: "列出某条会话的全部参与方(发件人/收件人/抄送方)及各自的可投递地址,并标出谁还没回应。" + + "**要回给抄收方或向第三方转达时先用它拿地址**。", + Parameters: oneStringParam("session_id", "会话 ID", true), + }, p.handleSessionParticipants) + + s.RegisterTool("read_thread", sdk.ToolDef{ + Name: "read_thread", + Description: "查看一封邮件所在线索的完整往来(谁回了谁、谁还没回)。多方抄送协作时用它确认" + + "别人已经说了什么,避免重复提问或重复汇报。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "mail_id": map[string]interface{}{"type": "string", "description": "线索中任一封邮件的 ID"}, + "offset": map[string]interface{}{"type": "number", "description": "分页偏移,续取时传上次返回的 next_offset"}, + }, + "required": []string{"mail_id"}, + }, + }, p.handleReadThread) + + // connect_to_server —— 连接自愈。 + // + // Gateway 换了地址、或密钥需要重新登记时,模型能自己修好而不必等人改 + // 环境变量。失败时把需要登记的密钥全文打出来,省掉一轮来回。 + s.RegisterTool("connect_to_server", sdk.ToolDef{ + Name: "connect_to_server", + Description: "连接到 AgentMail Gateway:登记本机密钥并完成注册。首次安装或换了 Gateway 地址时调用。" + + "密钥若未在后台登记过,此处会返回需要登记的密钥全文。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "gateway_url": map[string]interface{}{"type": "string", "description": "Gateway 地址;省略则用当前配置"}, + "key_token": map[string]interface{}{"type": "string", "description": "管理员签发的 Agent 密钥;省略则用当前密钥"}, + }, + }, + }, p.handleConnectToServer) + // 注册输出通道 —— agent 可以主动调 output_send__homeagent 发信 s.RegisterOutputChannel("homeagent", sdk.CapText|sdk.CapFile, "发送邮件。meta JSON 格式:{to, subject, reply_to},type: text", @@ -148,7 +260,7 @@ func (p *Plugin) heartbeatLoop() { func (p *Plugin) register() error { body := map[string]interface{}{ - "name": p.name, + "name": p.agentName, "platform": "homeagent", "workspaces": []interface{}{}, } @@ -337,7 +449,7 @@ func (p *Plugin) handleNewMail(evt struct { "**回信不用你自己发**:你把本轮工作做完、把结论说出来就行,\n"+ "插件会在这一轮结束时自动把你最后那段话作为回信发回给 %s(不消耗你的发信配额)。\n"+ "只有在需要主动联系其他人、或要带附件时才调用 send_mail。", - evt.FromName, evt.Subject, evt.MailID, p.name, evt.FromName, + evt.FromName, evt.Subject, evt.MailID, p.agentName, evt.FromName, ) // 非阻塞注入 —— TrueAgent 的事件循环会处理 @@ -487,9 +599,9 @@ func (p *Plugin) handleSendMail(args map[string]interface{}) (interface{}, error } payload := map[string]interface{}{ - "to": to, - "subject": subj, - "body": body, + "to": to, + "subject": subj, + "body": body, } if cc != "" { payload["cc"] = cc diff --git a/plugins/homeagent-mail-bridge/tools.go b/plugins/homeagent-mail-bridge/tools.go new file mode 100644 index 0000000..9cd9364 --- /dev/null +++ b/plugins/homeagent-mail-bridge/tools.go @@ -0,0 +1,460 @@ +package main + +import ( + "fmt" + "strings" +) + +// ─── 工具定义(与 opencode/dsh/pi 同源逻辑,Go 版本)─── +// +// 所有工具都是对 Gateway REST API 的薄封装:HTTP → 渲染 → 模型可读文本。 +// 与 JS 插件的区别仅在 HTTP 辅助函数(p.get / p.post),行为完全一致。 + +func (p *Plugin) handleReadMail(args map[string]interface{}) (interface{}, error) { + mid, _ := args["mail_id"].(string) + if mid == "" { + return nil, fmt.Errorf("缺少 mail_id") + } + + var data struct { + Mail struct { + FromName string `json:"from_name"` + ToName string `json:"to_name"` + ToWorkspace string `json:"to_workspace"` + Subject string `json:"subject"` + Body string `json:"body"` + CCList []struct { + Name string `json:"name"` + Path string `json:"path"` + Raw string `json:"raw"` + } `json:"cc_list"` + Attachments []struct { + Filename string `json:"filename"` + SizeBytes int `json:"size_bytes"` + AttachmentID string `json:"attachment_id"` + } `json:"attachments"` + } `json:"mail"` + SessionAlias string `json:"session_alias"` + ReplyAddress string `json:"reply_address"` + SelfAddress string `json:"self_address"` + Participants []struct { + Name string `json:"name"` + Path string `json:"path"` + Roles []string `json:"roles"` + Address string `json:"address"` + IsSelf bool `json:"is_self"` + } `json:"participants"` + } + if err := p.get(p.gwURL+"/api/v1/agent/mail/"+mid, &data); err != nil { + return nil, err + } + + var sb strings.Builder + fmt.Fprintf(&sb, "发件人: %s\n", data.Mail.FromName) + if data.Mail.ToWorkspace != "" { + fmt.Fprintf(&sb, "收件人: %s@%s\n", data.Mail.ToName, data.Mail.ToWorkspace) + } else { + fmt.Fprintf(&sb, "收件人: %s\n", data.Mail.ToName) + } + fmt.Fprintf(&sb, "主题: %s\n", data.Mail.Subject) + fmt.Fprintf(&sb, "会话: #%s(session_id: %s)\n", data.SessionAlias, data.Mail.FromName) + + if len(data.Mail.CCList) > 0 { + names := make([]string, 0, len(data.Mail.CCList)) + for _, c := range data.Mail.CCList { + names = append(names, c.Raw) + } + fmt.Fprintf(&sb, "抄送: %s\n", strings.Join(names, "、")) + } + if len(data.Mail.Attachments) > 0 { + fmt.Fprintf(&sb, "附件:\n") + for _, a := range data.Mail.Attachments { + fmt.Fprintf(&sb, " - %s (%.1fKB, id=%s)\n", a.Filename, float64(a.SizeBytes)/1024, a.AttachmentID) + } + } + fmt.Fprintf(&sb, "\n%s\n", data.Mail.Body) + + if len(data.Participants) > 0 { + fmt.Fprintf(&sb, "\n可投递地址:\n") + for _, pt := range data.Participants { + if pt.Address != "" && !pt.IsSelf { + fmt.Fprintf(&sb, " - %s (%s)\n", pt.Address, strings.Join(pt.Roles, "/")) + } + } + } + if data.ReplyAddress != "" { + fmt.Fprintf(&sb, "回信给发件人用 %s,或传 reply_to=%s\n", data.ReplyAddress, data.Mail.FromName) + } + + return map[string]interface{}{ + "content": []map[string]interface{}{{"type": "text", "text": sb.String()}}, + }, nil +} + +func (p *Plugin) handleForwardMail(args map[string]interface{}) (interface{}, error) { + mid, _ := args["mail_id"].(string) + to, _ := args["to"].(string) + comment, _ := args["comment"].(string) + cc, _ := args["cc"].(string) + subj, _ := args["subject"].(string) + sa, _ := args["session_alias"].(string) + + if mid == "" || to == "" { + return nil, fmt.Errorf("缺少 mail_id 和 to") + } + + payload := map[string]interface{}{ + "to": to, + "comment": comment, + "cc": cc, + "subject": subj, + "session_alias": sa, + } + var result map[string]interface{} + if err := p.post("/mail/"+mid+"/forward", payload, &result); err != nil { + return nil, err + } + + text := fmt.Sprintf("已转发。新 Mail ID: %s,Session: %s", result["mail_id"], result["session_id"]) + return map[string]interface{}{ + "content": []map[string]interface{}{{"type": "text", "text": text}}, + }, nil +} + +func (p *Plugin) handleSuggestAddress(args map[string]interface{}) (interface{}, error) { + name, _ := args["name"].(string) + path, _ := args["path"].(string) + name = strings.TrimSpace(name) + path = strings.TrimSpace(path) + + qs := "" + if name != "" { + qs += "name=" + name + } + if path != "" { + if qs != "" { + qs += "&" + } + qs += "path=" + path + } + + var data struct { + Kind string `json:"kind"` + Suggestions []string `json:"suggestions"` + Addresses []string `json:"addresses"` + Candidates []struct { + Alias string `json:"alias"` + Title string `json:"title"` + Unread int `json:"unread"` + Source string `json:"source"` + } `json:"candidates"` + } + if err := p.get(p.gwURL+"/api/v1/agent/contacts/suggest?"+qs, &data); err != nil { + return nil, err + } + + var sb strings.Builder + switch data.Kind { + case "name": + sb.WriteString(fmt.Sprintf("可投递的收件人(%d 个):\n", len(data.Suggestions))) + for _, n := range data.Suggestions { + fmt.Fprintf(&sb, "- %s\n", n) + } + sb.WriteString("\n下一步:用 suggest_address 带上 name 查它可用的工作目录(path 位)。") + + case "path": + if len(data.Suggestions) == 0 { + fmt.Fprintf(&sb, "%s 没有记录在案的工作目录。\npath 位可以留空。", name) + } else { + fmt.Fprintf(&sb, "%s 用过的工作目录(按最近使用排序):\n", name) + for _, p := range data.Suggestions { + fmt.Fprintf(&sb, "- %s\n", p) + } + } + default: + // session + existing := 0 + for _, a := range data.Suggestions { + if a != "new" { + existing++ + } + } + if existing == 0 { + fmt.Fprintf(&sb, "%s@%s 下还没有可续谈的会话。", name, path) + } else { + fmt.Fprintf(&sb, "%s@%s 下可续谈的会话:\n", name, path) + for i, alias := range data.Suggestions { + if alias == "new" { + continue + } + addr := "" + if i < len(data.Addresses) { + addr = data.Addresses[i] + } + fmt.Fprintf(&sb, "- %s\n", addr) + } + } + } + + return map[string]interface{}{ + "content": []map[string]interface{}{{"type": "text", "text": sb.String()}}, + }, nil +} + +func (p *Plugin) handleListContacts(args map[string]interface{}) (interface{}, error) { + limit := 20 + if v, ok := args["limit"].(float64); ok && v > 0 { + limit = int(v) + } + + var data struct { + Contacts []struct { + Address string `json:"address"` + Subject string `json:"subject"` + Unread int `json:"unread_count"` + MaxRounds int `json:"max_rounds"` + UsedRounds int `json:"used_rounds"` + Alias string `json:"session_alias"` + } `json:"contacts"` + } + if err := p.get(p.gwURL+"/api/v1/agent/contacts", &data); err != nil { + return nil, err + } + + if len(data.Contacts) == 0 { + return map[string]interface{}{ + "content": []map[string]interface{}{{"type": "text", "text": "还没有任何往来会话。"}}, + }, nil + } + + // 按未读优先排序 + for i := 0; i < len(data.Contacts)-1; i++ { + for j := i + 1; j < len(data.Contacts); j++ { + if data.Contacts[j].Unread > data.Contacts[i].Unread { + data.Contacts[i], data.Contacts[j] = data.Contacts[j], data.Contacts[i] + } + } + } + + var sb strings.Builder + n := limit + if n > len(data.Contacts) { + n = len(data.Contacts) + } + fmt.Fprintf(&sb, "往来会话(共 %d 条):\n", len(data.Contacts)) + for _, c := range data.Contacts[:n] { + bits := []string{} + if c.Unread > 0 { + bits = append(bits, fmt.Sprintf("%d 封未读", c.Unread)) + } + if c.Subject != "" { + bits = append(bits, c.Subject) + } + if c.MaxRounds > 0 { + left := c.MaxRounds - c.UsedRounds + if left < 0 { + left = 0 + } + bits = append(bits, fmt.Sprintf("剩 %d/%d 个来回", left, c.MaxRounds)) + } + extra := "" + if len(bits) > 0 { + extra = fmt.Sprintf(" (%s)", strings.Join(bits, ",")) + } + fmt.Fprintf(&sb, "- %s%s\n", c.Address, extra) + } + + return map[string]interface{}{ + "content": []map[string]interface{}{{"type": "text", "text": sb.String()}}, + }, nil +} + +func (p *Plugin) handleSessionParticipants(args map[string]interface{}) (interface{}, error) { + sid, _ := args["session_id"].(string) + if sid == "" { + return nil, fmt.Errorf("缺少 session_id") + } + + var data struct { + SessionAlias string `json:"session_alias"` + Participants []struct { + Name string `json:"name"` + Path string `json:"path"` + Roles []string `json:"roles"` + IsSelf bool `json:"is_self"` + MailCount int `json:"mail_count"` + Address string `json:"address"` + } `json:"participants"` + } + if err := p.get(p.gwURL+"/api/v1/agent/sessions/"+sid+"/participants", &data); err != nil { + return nil, err + } + + if len(data.Participants) == 0 { + return map[string]interface{}{ + "content": []map[string]interface{}{{"type": "text", "text": "该会话还没有参与方。"}}, + }, nil + } + + var sb strings.Builder + fmt.Fprintf(&sb, "会话 #%s 的参与方:\n", data.SessionAlias) + for _, pt := range data.Participants { + tags := []string{} + if pt.IsSelf { + tags = append(tags, "就是你") + } + if len(pt.Roles) > 0 { + tags = append(tags, strings.Join(pt.Roles, "/")) + } + if pt.MailCount == 0 && !pt.IsSelf { + tags = append(tags, "尚未回应") + } + extra := "" + if len(tags) > 0 { + extra = fmt.Sprintf(" [%s]", strings.Join(tags, ",")) + } + fmt.Fprintf(&sb, "- %s %s%s\n", pt.Name, pt.Address, extra) + } + sb.WriteString("\n要联系其中某一方,把它的地址原样填进 send_mail 的 to。") + + return map[string]interface{}{ + "content": []map[string]interface{}{{"type": "text", "text": sb.String()}}, + }, nil +} + +func (p *Plugin) handleReadThread(args map[string]interface{}) (interface{}, error) { + mid, _ := args["mail_id"].(string) + if mid == "" { + return nil, fmt.Errorf("缺少 mail_id") + } + offset := "" + if v, ok := args["offset"].(float64); ok && v > 0 { + offset = fmt.Sprintf("?offset=%d", int(v)) + } + + var data struct { + Total int `json:"total"` + Hidden int `json:"hidden"` + HasMore bool `json:"has_more"` + NextOff int `json:"next_offset"` + AnchorID string `json:"anchor_mail_id"` + Nodes []struct { + MailID string `json:"mail_id"` + FromName string `json:"from_name"` + ToName string `json:"to_name"` + Subject string `json:"subject"` + Depth int `json:"depth"` + Detached bool `json:"detached"` + ParentHid bool `json:"parent_hidden"` + } `json:"nodes"` + } + if err := p.get(p.gwURL+"/api/v1/agent/mail/"+mid+"/thread"+offset, &data); err != nil { + return nil, err + } + + if len(data.Nodes) == 0 { + return map[string]interface{}{ + "content": []map[string]interface{}{{"type": "text", "text": "这条线索上没有可见的邮件。"}}, + }, nil + } + + var sb strings.Builder + fmt.Fprintf(&sb, "线索共 %d 封", data.Total) + if data.Hidden > 0 { + fmt.Fprintf(&sb, "(另有 %d 封无权查看)", data.Hidden) + } + sb.WriteString(":\n") + + for _, n := range data.Nodes { + indent := "" + if n.Depth > 0 { + indent = strings.Repeat(" ", min(n.Depth, 8)) + } + marks := []string{} + if n.MailID == data.AnchorID { + marks = append(marks, "当前这封") + } + if n.Detached { + if n.ParentHid { + marks = append(marks, "父邮件无权查看") + } else { + marks = append(marks, "父邮件尚未加载") + } + } + extra := "" + if len(marks) > 0 { + extra = fmt.Sprintf(" (%s)", strings.Join(marks, ",")) + } + fmt.Fprintf(&sb, "%s- %s → %s: %s [%s]%s\n", + indent, n.FromName, n.ToName, n.Subject, n.MailID, extra) + } + if data.HasMore { + fmt.Fprintf(&sb, "\n还有更多,用 offset=%d 继续取。\n", data.NextOff) + } + + return map[string]interface{}{ + "content": []map[string]interface{}{{"type": "text", "text": sb.String()}}, + }, nil +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +// oneStringParam 给只有一个参数的工具生成 schema。 +// +// 单独提出来不是为了省字数,而是因为手写 JSON Schema 字面量很容易漏掉 +// `"type": "object"` 或把 required 写成字符串而不是数组 —— 那类错误不会 +// 在编译期暴露,而是让模型收到一个它无法调用的工具。 +func oneStringParam(name, desc string, required bool) map[string]interface{} { + schema := map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + name: map[string]interface{}{"type": "string", "description": desc}, + }, + } + if required { + schema["required"] = []string{name} + } + return schema +} + +// handleConnectToServer 重新登记密钥并注册。 +// +// 成功后把新坐标写回 p,当场生效 —— 不用等重启。这是这个工具存在的全部意义: +// 若还要重启才生效,人直接改环境变量就行了,不需要给模型一个工具。 +func (p *Plugin) handleConnectToServer(args map[string]interface{}) (interface{}, error) { + url := p.gwURL + if v, ok := args["gateway_url"].(string); ok && strings.TrimSpace(v) != "" { + url = strings.TrimRight(strings.TrimSpace(v), "/") + } + key := p.key + if v, ok := args["key_token"].(string); ok && strings.TrimSpace(v) != "" { + key = strings.TrimSpace(v) + } + if key == "" { + return nil, fmt.Errorf("没有可用的密钥:请传 key_token,或在 AGENTMAIL_AGENT_KEY 环境变量里配置") + } + + // 用候选坐标试注册,成功了才写回 —— 失败时不该把原本能用的配置改坏 + probe := &Plugin{agentName: p.agentName, gwURL: url, key: key, client: p.client} + if err := probe.register(); err != nil { + return map[string]interface{}{ + "content": []map[string]interface{}{{"type": "text", "text": strings.Join([]string{ + fmt.Sprintf("连接失败:%v", err), + "", + "若提示密钥无效,请让管理员在 AgentMail 后台「Agent 密钥」中登记:", + key, + }, "\n")}}, + }, nil + } + + p.gwURL = url + p.key = key + return map[string]interface{}{ + "content": []map[string]interface{}{{"type": "text", "text": fmt.Sprintf("已连接 %s,注册为 %s。", url, p.agentName)}}, + }, nil +} diff --git a/plugins/opencode-mail-bridge/index.js b/plugins/opencode-mail-bridge/index.js index fb20fd3..71a54d7 100644 --- a/plugins/opencode-mail-bridge/index.js +++ b/plugins/opencode-mail-bridge/index.js @@ -33,6 +33,7 @@ import { noteExplicitSend, shouldSkipAutoRelay, } from "./lib/relay-dedup.js"; +import { appendRenameProposal, renameProposalNote } from "./lib/rename-proposal.js"; const GATEWAY_URL = process.env.AGENTMAIL_GATEWAY_URL || "http://127.0.0.1:8180"; const AGENT_NAME = process.env.AGENTMAIL_AGENT_NAME || "opencode"; @@ -170,14 +171,11 @@ const sendMailTool = { // 让 session.idle 的自动转发让位,避免同一件事发两封。 async execute(args, context) { // 改名建议以 HTML 注释形式附在正文末尾,由网关解析后剥离。 - // 选注释而不是自造标记:react-markdown 不解析 raw HTML, - // 万一网关没剥掉,它在页面上也只是一行不显眼的转义文本而非破版内容。 - let body = args.body; - if (args.propose_alias) { - const esc = (v) => String(v).replace(/"/g, ""); // 双引号是标记的定界符 - const reason = args.propose_reason ? ` reason="${esc(args.propose_reason)}"` : ""; - body += `\n\n`; - } + // 标记格式是服务端正则的镜像,因此拼接收在 lib/rename-proposal.js + // (三平台共用)—— 各写一遍的话少个空格就静默失效:邮件照常发出, + // 提议凭空消失,而模型以为自己提过了。 + const { body, proposed } = appendRenameProposal( + args.body, args.propose_alias, args.propose_reason); const result = await apiPost("/mail/send", { to: args.to, @@ -205,11 +203,11 @@ const sendMailTool = { ? "预算即将用尽,请尽快给出结论;自动转发的总结不占预算。" : "") : ""; - // 回传规范化后的别名:Agent 提的名字可能含非法字符被改写过 - const proposed = result.rename_proposed - ? `\n已向用户提议把会话改名为 ${result.rename_proposed},等待其确认。` - : ""; - return `已发送。Mail ID: ${result.mail_id},Session: ${result.session_id}${alias}${budget}${proposed}`; + // 别名取服务端回的 rename_proposed:它跑过 normalizeAlias, + // 回显本地值会让模型记住一个不存在的名字 + const note = renameProposalNote(result.rename_proposed, args.propose_alias, proposed); + return `已发送。Mail ID: ${result.mail_id},Session: ${result.session_id}${alias}${budget}` + + (note ? `\n${note}` : ""); }, }; diff --git a/plugins/opencode-mail-bridge/lib/rename-proposal.js b/plugins/opencode-mail-bridge/lib/rename-proposal.js new file mode 100644 index 0000000..346efea --- /dev/null +++ b/plugins/opencode-mail-bridge/lib/rename-proposal.js @@ -0,0 +1,130 @@ +/** + * 会话改名提议 —— 所有平台插件共用。 + * + * # 这是什么 + * + * 模型干完活后可能觉得当前别名不贴切:会话建立时叫 `witty-planet`(平台随机 slug) + * 或 `排查登录问题`(人写的邮件主题),摸清问题后它知道这其实是 + * `fix-session-cookie-leak`。改名提议就是让它把这个判断说出来。 + * + * # 为什么是「提议」而不是直接改 + * + * 别名是**人**的寻址入口 —— `name@path.<别名>` 里那一段。Agent 干到一半自己改掉, + * 人上一秒记住的地址下一秒就 404(`session` 位三态语义要求指向不存在的会话直接报 + * 「无法送达」,不会静默新建)。所以提议入库、由人在界面上点「接受」才真正生效。 + * + * 这与平台命名自动同步(`POST /sessions/{id}/sync`)互补,两者不冲突: + * + * | | 谁发起 | 何时 | 是否打扰人 | + * |---|---|---|---| + * | 自动同步 | 平台的命名机制 | 每轮结束 | 不,后台静默生效 | + * | 改名提议 | 模型的主动判断 | 它认为有必要时 | 是,界面上出提示条 | + * + * # 为什么载体是 HTML 注释 + * + * `/mail/send` 没有 `propose_alias` 字段 —— 提议**搭在正文里**发出去, + * 服务端用正则摘出来再把标记从入库正文中剥掉。选 HTML 注释的三个理由: + * + * - react-markdown 默认不解析 raw HTML,万一服务端没剥掉,它在页面上也只是 + * 一行不显眼的转义文本,不会破版 + * - 纯文本邮件客户端里是一行不碍事的注释,不像自造标记那样显眼 + * - 不与 Markdown 语法冲突,格式化工具不会改写它 + * + * # 为什么必须共用 + * + * 标记格式是**服务端正则的镜像**(`gateway/internal/handler/rename_proposal.go`)。 + * 各平台各写一遍拼接,某一处少个空格或把双引号写成单引号,服务端匹配不上 —— + * 而失败是静默的:邮件照常发出,提议凭空消失,模型以为自己提过了。 + */ + +/** + * 服务端能识别的别名字符集。 + * + * 与 `validateSessionAlias` 一致:`. 空白 / @` 会与三维地址解析冲突, + * `new` 是寻址保留字。这里**不做规范化**(不把非法字符替换成 `-`)—— + * 规范化是服务端 `normalizeAlias` 的职责,插件擅自改写会让模型看到的 + * 「我提议的名字」与实际入库的不一致。 + * + * @param {string} alias + * @returns {boolean} + */ +export function isProposableAlias(alias) { + const a = String(alias ?? '').trim(); + if (!a) return false; + if (a === 'new') return false; + // 双引号是标记本身的定界符,含它会截断标记 + if (/[.\s/@"]/.test(a)) return false; + // 服务端 VARCHAR(128),按字节算 + if (Buffer.byteLength(a, 'utf8') > 128) return false; + return true; +} + +/** + * 把改名提议标记追加到正文末尾。 + * + * 格式必须与服务端正则逐字符对应: + * `` + * reason 可选,为空时**整个属性都不写**(写成 `reason=""` 服务端会存一个空理由, + * 界面上的提示条就少了那句解释)。 + * + * 别名不合法时**原样返回正文**,不追加标记:与其发一个服务端匹配得上却 + * 被 `validateSessionAlias` 拒掉的标记,不如当它没提 —— 调用方据此告诉模型。 + * + * @param {string} body 原始正文 + * @param {string} [alias] 提议的别名 + * @param {string} [reason] 提议理由,一句话 + * @returns {{body: string, proposed: boolean}} proposed=false 表示别名不合法,未追加 + */ +export function appendRenameProposal(body, alias, reason) { + const text = String(body ?? ''); + if (!isProposableAlias(alias)) return { body: text, proposed: false }; + + const a = String(alias).trim(); + // 理由里的双引号会截断标记,去掉而不是转义:HTML 注释里没有转义机制 + const r = String(reason ?? '').replace(/"/g, '').trim(); + const reasonAttr = r ? ` reason="${r}"` : ''; + + return { + body: `${text}\n\n`, + proposed: true, + }; +} + +/** + * 提议提交后回给模型的那句话。 + * + * **别名取服务端回的 `rename_proposed`,不是本地提议的那个。** 服务端会跑 + * `normalizeAlias` —— 非法字符换成 `-`、`new` 变 `session-new`、超长按 UTF-8 + * 边界截断。回显本地值会让模型记住一个不存在的名字,之后拿它寻址就 404。 + * + * 必须说明「等人确认」。不说的话模型会以为改名已经生效,接着在后续邮件里 + * 用新别名当地址发信 —— 而那个别名此刻还不存在,投递会失败。 + * + * @param {string} [serverAlias] 服务端 `/mail/send` 响应里的 `rename_proposed` + * @param {string} [requestedAlias] 本地提议的别名,仅用于「未提交」时的说明 + * @param {boolean} [proposed] appendRenameProposal 的返回值 + * @returns {string} 空串表示没有需要追加的说明 + */ +export function renameProposalNote(serverAlias, requestedAlias, proposed) { + const server = String(serverAlias ?? '').trim(); + const wanted = String(requestedAlias ?? '').trim(); + + // 服务端确认收到了:用它给的最终值 + if (server) { + const changed = wanted && wanted !== server + ? `(你提的 "${wanted}" 被规范化成了这个)` + : ''; + return `已附上改名提议 "${server}"${changed},等用户在界面上确认后生效 —— ` + + `在那之前继续用原别名寻址。`; + } + + if (!wanted) return ''; + + // 本地就判定不合法,标记没发出去 + if (!proposed) { + return `(改名提议 "${wanted}" 未提交:别名不可为 new,不可含 . 空白 / @ 或双引号。)`; + } + + // 标记发出去了但服务端没回 rename_proposed:它那侧的校验也拒了 + return `(改名提议 "${wanted}" 未被服务端接受,会话别名不变。)`; +} diff --git a/plugins/opencode-mail-bridge/test/rename-proposal.test.mjs b/plugins/opencode-mail-bridge/test/rename-proposal.test.mjs new file mode 100644 index 0000000..960d079 --- /dev/null +++ b/plugins/opencode-mail-bridge/test/rename-proposal.test.mjs @@ -0,0 +1,136 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + isProposableAlias, + appendRenameProposal, + renameProposalNote, +} from '../lib/rename-proposal.js'; + +// 这一组测试钉住的是「插件拼的标记与服务端正则逐字符对应」。 +// 服务端那条正则在 gateway/internal/handler/rename_proposal.go: +// +// 拼错不会报错 —— 邮件照常发出,提议凭空消失。 + +/** 服务端正则的等价实现,用来验证我们拼出来的标记真的能被摘出来。 */ +const SERVER_RE = + //s; + +test('isProposableAlias: 合法别名', () => { + assert.equal(isProposableAlias('fix-login-leak'), true); + assert.equal(isProposableAlias('修复登录态泄漏'), true, '中文别名合法'); + assert.equal(isProposableAlias('v2_migration'), true); +}); + +test('isProposableAlias: new 是寻址保留字', () => { + // `.new` 是「强制新建会话」的动作,别名叫 new 会让地址无从解释 + assert.equal(isProposableAlias('new'), false); +}); + +test('isProposableAlias: 拒绝与三维地址冲突的字符', () => { + // 这四个字符都会让 name@path.session 的切分产生歧义 + assert.equal(isProposableAlias('a.b'), false, '. 是 session 位分隔符'); + assert.equal(isProposableAlias('a/b'), false, '/ 出现在 path 位'); + assert.equal(isProposableAlias('a@b'), false, '@ 是 name/path 分隔符'); + assert.equal(isProposableAlias('a b'), false, '空白'); + assert.equal(isProposableAlias('a\tb'), false, '制表符也算空白'); +}); + +test('isProposableAlias: 拒绝双引号', () => { + // 双引号是标记自身的定界符,含它会把标记截断成非法形式 + assert.equal(isProposableAlias('say"hi'), false); +}); + +test('isProposableAlias: 空与空白视为没提', () => { + assert.equal(isProposableAlias(''), false); + assert.equal(isProposableAlias(' '), false); + assert.equal(isProposableAlias(undefined), false); + assert.equal(isProposableAlias(null), false); +}); + +test('isProposableAlias: 超过 128 字节按字节算', () => { + // 服务端是 VARCHAR(128)。中文一个字 3 字节,43 字 = 129 字节 + assert.equal(isProposableAlias('a'.repeat(128)), true); + assert.equal(isProposableAlias('a'.repeat(129)), false); + assert.equal(isProposableAlias('汉'.repeat(42)), true, '126 字节'); + assert.equal(isProposableAlias('汉'.repeat(43)), false, '129 字节'); +}); + +test('标记能被服务端正则摘出来', () => { + const { body, proposed } = appendRenameProposal('已定位到问题。', 'fix-login-leak', '登录态泄漏'); + assert.equal(proposed, true); + const m = SERVER_RE.exec(body); + assert.ok(m, '服务端正则必须匹配得上'); + assert.equal(m[1], 'fix-login-leak'); + assert.equal(m[2], '登录态泄漏'); +}); + +test('没有理由时整个 reason 属性都不写', () => { + // 写成 reason="" 会让服务端存一个空理由,界面提示条就少了那句解释 + const { body } = appendRenameProposal('正文', 'fix-leak'); + assert.doesNotMatch(body, /reason=/); + const m = SERVER_RE.exec(body); + assert.equal(m[1], 'fix-leak'); + assert.equal(m[2], undefined); +}); + +test('理由里的双引号被去掉而不是转义', () => { + // HTML 注释里没有转义机制,留着会截断标记 + const { body } = appendRenameProposal('正文', 'fix-leak', '他说"这是泄漏"'); + const m = SERVER_RE.exec(body); + assert.ok(m); + assert.equal(m[2], '他说这是泄漏'); +}); + +test('原正文完整保留在标记之前', () => { + const original = '第一行\n\n第二行'; + const { body } = appendRenameProposal(original, 'fix-leak'); + assert.ok(body.startsWith(original), '正文不得被改写'); +}); + +test('别名不合法时原样返回,不追加标记', () => { + const { body, proposed } = appendRenameProposal('正文', 'a.b'); + assert.equal(proposed, false); + assert.equal(body, '正文'); + assert.doesNotMatch(body, /agentmail:rename-session/); +}); + +test('不给别名时正文完全不变', () => { + const { body, proposed } = appendRenameProposal('正文', ''); + assert.equal(proposed, false); + assert.equal(body, '正文'); +}); + +test('renameProposalNote: 用服务端回的别名,不是本地提议的', () => { + // 服务端跑 normalizeAlias:非法字符换 -、new 变 session-new、超长截断。 + // 回显本地值会让模型记住一个不存在的名字,之后拿它寻址就 404。 + const note = renameProposalNote('fix-login-leak', 'fix.login.leak', true); + assert.match(note, /fix-login-leak/); + assert.match(note, /规范化/, '要告诉模型名字被改写过'); + assert.match(note, /确认/); + assert.match(note, /原别名/, '要明确说在那之前用哪个'); +}); + +test('renameProposalNote: 服务端别名与提议一致时不提规范化', () => { + const note = renameProposalNote('fix-leak', 'fix-leak', true); + assert.match(note, /fix-leak/); + assert.doesNotMatch(note, /规范化/); +}); + +test('renameProposalNote: 本地判非法时说清为什么', () => { + const note = renameProposalNote('', 'a.b', false); + assert.match(note, /未提交/); + assert.match(note, /a\.b/); +}); + +test('renameProposalNote: 标记发出但服务端没接受', () => { + // 本地校验比服务端宽的情况(例如服务端加了新约束)—— + // 不能沉默,否则模型以为提议成功了 + const note = renameProposalNote('', 'somealias', true); + assert.match(note, /未被服务端接受/); + assert.match(note, /somealias/); +}); + +test('renameProposalNote: 没提议时不产生噪音', () => { + assert.equal(renameProposalNote('', '', false), ''); + assert.equal(renameProposalNote(undefined, undefined, false), ''); +}); diff --git a/plugins/pi-mail-bridge/lib/rename-proposal.js b/plugins/pi-mail-bridge/lib/rename-proposal.js new file mode 100644 index 0000000..9deb385 --- /dev/null +++ b/plugins/pi-mail-bridge/lib/rename-proposal.js @@ -0,0 +1,109 @@ +/** + * 会话改名提议 —— 所有平台插件共用。 + * + * # 这是什么 + * + * 模型干完活后可能觉得当前别名不贴切:会话建立时叫 `witty-planet`(平台随机 slug) + * 或 `排查登录问题`(人写的邮件主题),摸清问题后它知道这其实是 + * `fix-session-cookie-leak`。改名提议就是让它把这个判断说出来。 + * + * # 为什么是「提议」而不是直接改 + * + * 别名是**人**的寻址入口 —— `name@path.<别名>` 里那一段。Agent 干到一半自己改掉, + * 人上一秒记住的地址下一秒就 404(`session` 位三态语义要求指向不存在的会话直接报 + * 「无法送达」,不会静默新建)。所以提议入库、由人在界面上点「接受」才真正生效。 + * + * 这与平台命名自动同步(`POST /sessions/{id}/sync`)互补,两者不冲突: + * + * | | 谁发起 | 何时 | 是否打扰人 | + * |---|---|---|---| + * | 自动同步 | 平台的命名机制 | 每轮结束 | 不,后台静默生效 | + * | 改名提议 | 模型的主动判断 | 它认为有必要时 | 是,界面上出提示条 | + * + * # 为什么载体是 HTML 注释 + * + * `/mail/send` 没有 `propose_alias` 字段 —— 提议**搭在正文里**发出去, + * 服务端用正则摘出来再把标记从入库正文中剥掉。选 HTML 注释的三个理由: + * + * - react-markdown 默认不解析 raw HTML,万一服务端没剥掉,它在页面上也只是 + * 一行不显眼的转义文本,不会破版 + * - 纯文本邮件客户端里是一行不碍事的注释,不像自造标记那样显眼 + * - 不与 Markdown 语法冲突,格式化工具不会改写它 + * + * # 为什么必须共用 + * + * 标记格式是**服务端正则的镜像**(`gateway/internal/handler/rename_proposal.go`)。 + * 各平台各写一遍拼接,某一处少个空格或把双引号写成单引号,服务端匹配不上 —— + * 而失败是静默的:邮件照常发出,提议凭空消失,模型以为自己提过了。 + */ + +/** + * 服务端能识别的别名字符集。 + * + * 与 `validateSessionAlias` 一致:`. 空白 / @` 会与三维地址解析冲突, + * `new` 是寻址保留字。这里**不做规范化**(不把非法字符替换成 `-`)—— + * 规范化是服务端 `normalizeAlias` 的职责,插件擅自改写会让模型看到的 + * 「我提议的名字」与实际入库的不一致。 + * + * @param {string} alias + * @returns {boolean} + */ +export function isProposableAlias(alias) { + const a = String(alias ?? '').trim(); + if (!a) return false; + if (a === 'new') return false; + // 双引号是标记本身的定界符,含它会截断标记 + if (/[.\s/@"]/.test(a)) return false; + // 服务端 VARCHAR(128),按字节算 + if (Buffer.byteLength(a, 'utf8') > 128) return false; + return true; +} + +/** + * 把改名提议标记追加到正文末尾。 + * + * 格式必须与服务端正则逐字符对应: + * `` + * reason 可选,为空时**整个属性都不写**(写成 `reason=""` 服务端会存一个空理由, + * 界面上的提示条就少了那句解释)。 + * + * 别名不合法时**原样返回正文**,不追加标记:与其发一个服务端匹配得上却 + * 被 `validateSessionAlias` 拒掉的标记,不如当它没提 —— 调用方据此告诉模型。 + * + * @param {string} body 原始正文 + * @param {string} [alias] 提议的别名 + * @param {string} [reason] 提议理由,一句话 + * @returns {{body: string, proposed: boolean}} proposed=false 表示别名不合法,未追加 + */ +export function appendRenameProposal(body, alias, reason) { + const text = String(body ?? ''); + if (!isProposableAlias(alias)) return { body: text, proposed: false }; + + const a = String(alias).trim(); + // 理由里的双引号会截断标记,去掉而不是转义:HTML 注释里没有转义机制 + const r = String(reason ?? '').replace(/"/g, '').trim(); + const reasonAttr = r ? ` reason="${r}"` : ''; + + return { + body: `${text}\n\n`, + proposed: true, + }; +} + +/** + * 提议提交后回给模型的那句话。 + * + * 必须说明「等人确认」。不说的话模型会以为改名已经生效,接着在后续邮件里 + * 用新别名当地址发信 —— 而那个别名此刻还不存在,投递会失败。 + * + * @param {string} alias + * @param {boolean} proposed appendRenameProposal 的返回值 + * @returns {string} 空串表示没有需要追加的说明 + */ +export function renameProposalNote(alias, proposed) { + if (!alias) return ''; + if (!proposed) { + return `(改名提议 "${alias}" 未提交:别名不可为 new,不可含 . 空白 / @ 或双引号。)`; + } + return `已附上改名提议 "${alias}",等用户在界面上确认后生效 —— 在那之前继续用原别名寻址。`; +} diff --git a/plugins/pi-mail-bridge/src/gateway.mjs b/plugins/pi-mail-bridge/src/gateway.mjs index 314f40a..9142bb7 100644 --- a/plugins/pi-mail-bridge/src/gateway.mjs +++ b/plugins/pi-mail-bridge/src/gateway.mjs @@ -11,9 +11,24 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; const CONFIG_DIR = process.env.AGENTMAIL_CONFIG_DIR || join(homedir(), '.agentmail'); -const KEY_FILE = join(CONFIG_DIR, 'agent.key'); +export const KEY_FILE = join(CONFIG_DIR, 'agent.key'); const CONFIG_FILE = join(CONFIG_DIR, 'config.json'); +/** + * 把管理员给的密钥落盘(0600)。 + * + * connect_to_server 工具靠它:模型拿到一把新密钥后必须落盘, + * 否则重启后又回到无法连接的状态 —— 而那正是这个工具要解决的问题。 + */ +export function saveLocalKey(token) { + mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 }); + writeFileSync( + KEY_FILE, + JSON.stringify({ key_token: token, created_at: new Date().toISOString() }, null, 2), + { mode: 0o600 }, + ); +} + /** 读取本地密钥文件;不存在或损坏时返回 null。 */ export function readLocalKey() { try { @@ -228,6 +243,28 @@ export class GatewayClient { }); } + /** + * 换 Gateway 地址或换密钥。 + * + * 守护进程不能靠重启来应用新配置 —— connect_to_server 是模型在**运行中** + * 调的,它期望调完就能收信。所以这里除了改字段还要重置断点: + * `lastEventID` 是**旧** Gateway 环形缓冲里的序号,拿去问新 Gateway 会 + * 命中一段完全无关的历史(或直接被拒),得到的事件属于别人的会话。 + * + * @returns {boolean} 是否真的变了(没变就不必重连 SSE,省一次断流) + */ + reconfigure({ url, agentKey }) { + const nextURL = url ? String(url).replace(/\/+$/, '') : this.baseURL; + const nextKey = agentKey || this.agentKey; + const changed = nextURL !== this.baseURL || nextKey !== this.agentKey; + if (!changed) return false; + + if (nextURL !== this.baseURL) this.lastEventID = ''; + this.baseURL = nextURL; + this.agentKey = nextKey; + return true; + } + stopSSE() { this.sseAbort?.abort(); this.sseAbort = null; diff --git a/plugins/pi-mail-bridge/src/tools.mjs b/plugins/pi-mail-bridge/src/tools.mjs index b427182..7847e88 100644 --- a/plugins/pi-mail-bridge/src/tools.mjs +++ b/plugins/pi-mail-bridge/src/tools.mjs @@ -10,8 +10,8 @@ * 「工具能拿到当前会话 id」,自动转发去重(B-5.3)靠它把发信记到正确的会话上。 */ -import { readFile, writeFile } from 'node:fs/promises'; -import { basename } from 'node:path'; +import { readFile, writeFile, mkdir, stat } from 'node:fs/promises'; +import { basename, dirname } from 'node:path'; import { renderInbox, idsToMarkRead, @@ -28,6 +28,8 @@ import { renderThread, } from '../lib/discovery.js'; import { noteExplicitSend } from '../lib/relay-dedup.js'; +import { appendRenameProposal, renameProposalNote } from '../lib/rename-proposal.js'; +import { saveLocalKey, generateLocalKey, saveConfig, KEY_FILE } from './gateway.mjs'; const text = (s) => ({ content: [{ type: 'text', text: s }] }); @@ -37,8 +39,10 @@ const text = (s) => ({ content: [{ type: 'text', text: s }] }); * @param {(msg: string) => void} deps.log * @param {string} [deps.agentName] 自己的 Agent 名。收件箱渲染靠它判定 * 「我是收件人还是抄送方」并给出可投递地址。 + * @param {() => void} [deps.onReconnect] connect_to_server 换了坐标后调用, + * 由入口重连 SSE。不给则只改客户端字段(下次重连时生效)。 */ -export function createMailTools({ client, log, agentName = '' }) { +export function createMailTools({ client, log, agentName = '', onReconnect }) { const sendMail = { name: 'send_mail', label: 'SendMail', @@ -59,15 +63,29 @@ export function createMailTools({ client, log, agentName = '' }) { items: { type: 'string' }, description: '附件 ID 列表(先用 upload_attachment 取得)', }, + propose_alias: { + type: 'string', + description: + '建议把当前会话改名成这个别名(例如摸清问题后从「排查登录问题」改成 ' + + 'fix-session-cookie-leak)。这只是建议:别名是人的寻址入口,实际改名由用户' + + '在界面上确认。不可含 . / @ 空白,不可为 new', + }, + propose_reason: { type: 'string', description: '改名理由,一句话,展示给用户看' }, }, required: ['to', 'subject', 'body'], additionalProperties: false, }, async execute(_id, params, _signal, _onUpdate, ctx) { + // 改名提议以 HTML 注释形式附在正文末尾,由网关解析后剥离。 + // 拼接收在 lib/rename-proposal.js(三平台共用):标记格式是服务端正则的 + // 镜像,各写一遍的话少个空格就静默失效 —— 邮件照常发出,提议凭空消失。 + const { body, proposed } = appendRenameProposal( + params.body, params.propose_alias, params.propose_reason); + const result = await client.post('/mail/send', { to: params.to, subject: params.subject, - body: params.body, + body, cc: params.cc || '', reply_to: params.reply_to || '', session_alias: params.session_alias || '', @@ -81,7 +99,11 @@ export function createMailTools({ client, log, agentName = '' }) { const budget = typeof result.budget_remaining === 'number' ? ` 本任务剩余 ${result.budget_remaining}/${result.budget_max} 个来回。` : ''; - return text(`邮件已发送(ID: ${result.mail_id})${budget}`); + // 别名取服务端回的 rename_proposed(它跑过 normalizeAlias), + // 回显本地值会让模型记住一个不存在的名字,之后拿它寻址就 404 + const note = renameProposalNote(result.rename_proposed, params.propose_alias, proposed); + return text( + `邮件已发送(ID: ${result.mail_id})${budget}` + (note ? `\n${note}` : '')); }, }; @@ -159,20 +181,38 @@ export function createMailTools({ client, log, agentName = '' }) { const uploadAttachment = { name: 'upload_attachment', label: 'UploadAttachment', - description: '上传本地文件作为邮件附件,返回 attachment_id。', + description: + '上传本地文件作为邮件附件,返回 attachment_id。' + + '拿到 id 后必须在 send_mail 的 attachment_ids 里带上,附件才会随邮件发出。' + + '未随邮件发出的附件 24 小时后自动清理。', parameters: { type: 'object', properties: { - file_path: { type: 'string', description: '本地文件的绝对路径' }, + file_path: { type: 'string', description: '要上传的本地文件绝对路径' }, + filename: { type: 'string', description: '自定义展示文件名,默认取路径的最后一段' }, }, required: ['file_path'], additionalProperties: false, }, async execute(_id, params) { + // 先 stat 再读:目录和不存在的路径都要给出能行动的错误。 + // 直接 readFile 的话,目录会抛 EISDIR —— 模型看到那个 errno + // 只会重试同一个路径,而不是去改参数。 + let st; + try { + st = await stat(params.file_path); + } catch { + return text(`文件不存在或不可读: ${params.file_path}`); + } + if (!st.isFile()) return text(`不是普通文件: ${params.file_path}`); + + // 附件上限 25MB,一次性读入内存可接受。上限放宽的话这里要改成流式 multipart。 const buf = await readFile(params.file_path); - const a = await client.uploadFile(buf, basename(params.file_path) || 'file'); + const name = params.filename || basename(params.file_path) || 'file'; + const a = await client.uploadFile(buf, name); return text( - `已上传 ${a.filename}(${formatSize(a.size_bytes)})。attachment_id: ${a.attachment_id}`, + `已上传 ${a.filename}(${formatSize(a.size_bytes)})。attachment_id: ${a.attachment_id}\n` + + `在 send_mail 的 attachment_ids 里带上这个 id 才会随邮件发出。`, ); }, }; @@ -180,18 +220,21 @@ export function createMailTools({ client, log, agentName = '' }) { const downloadAttachment = { name: 'download_attachment', label: 'DownloadAttachment', - description: '下载邮件附件到本地文件。', + description: '下载邮件附件到本地文件。attachment_id 从 read_inbox 的附件清单里取。', parameters: { type: 'object', properties: { attachment_id: { type: 'string', description: '附件 ID(read_inbox 的清单里给出)' }, - save_path: { type: 'string', description: '保存路径' }, + save_path: { type: 'string', description: '保存到的本地绝对路径' }, }, required: ['attachment_id', 'save_path'], additionalProperties: false, }, async execute(_id, params) { const buf = await client.downloadFile(params.attachment_id); + // 父目录不存在时先建:模型经常写 ./downloads/x.pdf 这类还不存在的路径, + // 不建的话 writeFile 抛 ENOENT,而那个错误看起来像「附件不存在」。 + await mkdir(dirname(params.save_path), { recursive: true }); await writeFile(params.save_path, buf); return text(`已保存到 ${params.save_path}(${formatSize(buf.length)})`); }, @@ -343,6 +386,73 @@ export function createMailTools({ client, log, agentName = '' }) { }, }; + // connect_to_server —— 连接自愈。 + // + // 之前只有 opencode 侧有。后果是:Gateway 换了地址、或密钥需要重新登记时, + // opencode 里的模型能自己修好,其他平台只能干等环境变量被人改 —— + // 同一类能力在不同平台上时有时无,等于让人记住哪个平台能自己修。 + // + // 失败时**把需要登记的密钥全文打出来**:密钥未登记是最常见的失败, + // 不给值的话要多走一轮「密钥无效 → 去哪拿 → 让管理员登记」。 + const connectToServer = { + name: 'connect_to_server', + label: 'ConnectToServer', + description: + '连接到 AgentMail Gateway:登记本机密钥并完成注册。首次安装或换了 Gateway 地址时调用。' + + '密钥若未在后台登记过,此处会返回需要登记的密钥全文。', + parameters: { + type: 'object', + properties: { + gateway_url: { type: 'string', description: 'Gateway 地址;省略则用当前配置' }, + key_token: { type: 'string', description: '管理员签发的 Agent 密钥;省略则用本地密钥(不存在时自动生成)' }, + }, + additionalProperties: false, + }, + async execute(_id, params) { + let key = client.agentKey; + if (params.key_token) { + key = String(params.key_token).trim(); + // 管理员给的密钥落盘,重启后仍然可用 + saveLocalKey(key); + } else if (!key) { + key = generateLocalKey(log); + } + + const url = String(params.gateway_url || client.baseURL).replace(/\/+$/, ''); + + const res = await fetch(`${url}/api/v1/agent/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` }, + body: JSON.stringify({ name: client.agentName, workspaces: [], platform: 'pi' }), + }); + const data = await res.json().catch(() => ({})); + + if (!res.ok) { + return text([ + `连接失败(HTTP ${res.status}):${data?.error || '未知错误'}`, + ``, + `若提示密钥无效,请让管理员在 AgentMail 后台「Agent 密钥」中登记:`, + key, + ``, + `密钥文件:${KEY_FILE}`, + ].join('\n')); + } + + // 成功。桥是守护进程,不能靠重启来应用新坐标 —— 模型调这个工具时 + // 期望调完就能收信,所以要当场改客户端并重连 SSE。 + // reconfigure 顺带清掉 lastEventID:那是旧 Gateway 缓冲里的序号。 + const changed = client.reconfigure({ url, agentKey: key }); + saveConfig({ gateway_url: url, agent_name: client.agentName, registered_at: new Date().toISOString() }); + if (changed && onReconnect) { + onReconnect(); + log(`connect_to_server 换了坐标,SSE 已重连到 ${url}`); + } + return text( + `已连接 ${url},注册为 ${data?.agent_name || client.agentName}。` + + (changed ? '事件流已切到新地址。' : '')); + }, + }; + // 故意**没有** request_permission(N-1 / T-7): // 权限询问由 tool_call 钩子接管 —— 模型可能忘了调,也可能在不需要时乱调, // 而真正被 pi 拦下的那一次才是事实。 @@ -351,5 +461,7 @@ export function createMailTools({ client, log, agentName = '' }) { uploadAttachment, downloadAttachment, // 寻址发现:让模型选地址而不是拼地址 suggestAddress, listContacts, sessionParticipants, readThread, + // 连接自愈 + connectToServer, ]; } diff --git a/plugins/pi-mail-bridge/test/rename-proposal.test.mjs b/plugins/pi-mail-bridge/test/rename-proposal.test.mjs new file mode 100644 index 0000000..a7e366e --- /dev/null +++ b/plugins/pi-mail-bridge/test/rename-proposal.test.mjs @@ -0,0 +1,119 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + isProposableAlias, + appendRenameProposal, + renameProposalNote, +} from '../lib/rename-proposal.js'; + +// 这一组测试钉住的是「插件拼的标记与服务端正则逐字符对应」。 +// 服务端那条正则在 gateway/internal/handler/rename_proposal.go: +// +// 拼错不会报错 —— 邮件照常发出,提议凭空消失。 + +/** 服务端正则的等价实现,用来验证我们拼出来的标记真的能被摘出来。 */ +const SERVER_RE = + //s; + +test('isProposableAlias: 合法别名', () => { + assert.equal(isProposableAlias('fix-login-leak'), true); + assert.equal(isProposableAlias('修复登录态泄漏'), true, '中文别名合法'); + assert.equal(isProposableAlias('v2_migration'), true); +}); + +test('isProposableAlias: new 是寻址保留字', () => { + // `.new` 是「强制新建会话」的动作,别名叫 new 会让地址无从解释 + assert.equal(isProposableAlias('new'), false); +}); + +test('isProposableAlias: 拒绝与三维地址冲突的字符', () => { + // 这四个字符都会让 name@path.session 的切分产生歧义 + assert.equal(isProposableAlias('a.b'), false, '. 是 session 位分隔符'); + assert.equal(isProposableAlias('a/b'), false, '/ 出现在 path 位'); + assert.equal(isProposableAlias('a@b'), false, '@ 是 name/path 分隔符'); + assert.equal(isProposableAlias('a b'), false, '空白'); + assert.equal(isProposableAlias('a\tb'), false, '制表符也算空白'); +}); + +test('isProposableAlias: 拒绝双引号', () => { + // 双引号是标记自身的定界符,含它会把标记截断成非法形式 + assert.equal(isProposableAlias('say"hi'), false); +}); + +test('isProposableAlias: 空与空白视为没提', () => { + assert.equal(isProposableAlias(''), false); + assert.equal(isProposableAlias(' '), false); + assert.equal(isProposableAlias(undefined), false); + assert.equal(isProposableAlias(null), false); +}); + +test('isProposableAlias: 超过 128 字节按字节算', () => { + // 服务端是 VARCHAR(128)。中文一个字 3 字节,43 字 = 129 字节 + assert.equal(isProposableAlias('a'.repeat(128)), true); + assert.equal(isProposableAlias('a'.repeat(129)), false); + assert.equal(isProposableAlias('汉'.repeat(42)), true, '126 字节'); + assert.equal(isProposableAlias('汉'.repeat(43)), false, '129 字节'); +}); + +test('标记能被服务端正则摘出来', () => { + const { body, proposed } = appendRenameProposal('已定位到问题。', 'fix-login-leak', '登录态泄漏'); + assert.equal(proposed, true); + const m = SERVER_RE.exec(body); + assert.ok(m, '服务端正则必须匹配得上'); + assert.equal(m[1], 'fix-login-leak'); + assert.equal(m[2], '登录态泄漏'); +}); + +test('没有理由时整个 reason 属性都不写', () => { + // 写成 reason="" 会让服务端存一个空理由,界面提示条就少了那句解释 + const { body } = appendRenameProposal('正文', 'fix-leak'); + assert.doesNotMatch(body, /reason=/); + const m = SERVER_RE.exec(body); + assert.equal(m[1], 'fix-leak'); + assert.equal(m[2], undefined); +}); + +test('理由里的双引号被去掉而不是转义', () => { + // HTML 注释里没有转义机制,留着会截断标记 + const { body } = appendRenameProposal('正文', 'fix-leak', '他说"这是泄漏"'); + const m = SERVER_RE.exec(body); + assert.ok(m); + assert.equal(m[2], '他说这是泄漏'); +}); + +test('原正文完整保留在标记之前', () => { + const original = '第一行\n\n第二行'; + const { body } = appendRenameProposal(original, 'fix-leak'); + assert.ok(body.startsWith(original), '正文不得被改写'); +}); + +test('别名不合法时原样返回,不追加标记', () => { + const { body, proposed } = appendRenameProposal('正文', 'a.b'); + assert.equal(proposed, false); + assert.equal(body, '正文'); + assert.doesNotMatch(body, /agentmail:rename-session/); +}); + +test('不给别名时正文完全不变', () => { + const { body, proposed } = appendRenameProposal('正文', ''); + assert.equal(proposed, false); + assert.equal(body, '正文'); +}); + +test('renameProposalNote: 成功时必须说明等人确认', () => { + // 不说的话模型会以为改名已生效,接着用新别名当地址发信 —— 那个别名还不存在 + const note = renameProposalNote('fix-leak', true); + assert.match(note, /fix-leak/); + assert.match(note, /确认/); + assert.match(note, /原别名/, '要明确说在那之前用哪个'); +}); + +test('renameProposalNote: 失败时说清为什么', () => { + const note = renameProposalNote('a.b', false); + assert.match(note, /未提交/); + assert.match(note, /a\.b/); +}); + +test('renameProposalNote: 没提议时不产生噪音', () => { + assert.equal(renameProposalNote('', false), ''); +}); diff --git a/web/src/components/AdminUsersPage.tsx b/web/src/components/AdminUsersPage.tsx index 7aa9b01..5d67502 100644 --- a/web/src/components/AdminUsersPage.tsx +++ b/web/src/components/AdminUsersPage.tsx @@ -108,7 +108,7 @@ export default function AdminUsersPage() { setTab('quotas')}> - 默认预算 + Agent 管理 setTab('models')}> diff --git a/web/src/components/QuotaPanel.tsx b/web/src/components/QuotaPanel.tsx index c1b7405..172c6ed 100644 --- a/web/src/components/QuotaPanel.tsx +++ b/web/src/components/QuotaPanel.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState } from 'react'; import * as api from '../api/client'; -import { BotIcon, CheckIcon, CloseIcon } from './icons'; +import { BotIcon, CheckIcon, ArchiveIcon } from './icons'; /** * Agent 管理面板(管理员):默认预算 + 停用/恢复。 @@ -14,6 +14,7 @@ export default function QuotaPanel() { const [busy, setBusy] = useState(null); const [drafts, setDrafts] = useState>({}); const [confirming, setConfirming] = useState(null); + const [notice, setNotice] = useState(null); const load = useCallback(async () => { try { @@ -50,14 +51,17 @@ export default function QuotaPanel() { const toggleStatus = async (name: string, currentDisabled: boolean) => { setBusy(name); setError(null); + setNotice(null); try { const result = await api.adminSetAgentStatus(name, !currentDisabled); await load(); setConfirming(null); - // 停用时显示撤销了几把密钥,这是操作结果里最有信息量的部分 - if (result.detail) setError(null); // 清掉旧错误,让 detail 独占提示区 - setBusy(null); - return; + // 撤销了几把密钥是操作结果里最有信息量的部分 —— 恢复后要重新签发几把, + // 只说「已停用」的话用户不知道还有这一步。 + const revoked = typeof result.keys_revoked === 'number' && result.keys_revoked > 0 + ? `(已撤销 ${result.keys_revoked} 把密钥)` + : ''; + setNotice(`${name} ${result.disabled ? '已停用' : '已恢复'}${revoked}`); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { @@ -85,13 +89,22 @@ export default function QuotaPanel() {

- 派给某个 Agent 的新任务默认多少个来回(填 0 = 不限)。这只是默认值 —— - 写信时可以单独指定,之后在对话页里还能随时调整。 -
+ 默认预算:派给某个 Agent 的新任务默认多少个来回(填 0 = 不限)。 + 这只是默认值 —— 写信时可以单独指定,之后在对话页里还能随时调整。 插件自动转发的最终总结与权限询问不占用预算。 +
+ 停用:撤销该 Agent 的全部密钥、 + 从地址补全与联系人里隐藏,并拒绝它重新注册。 + 邮件、会话、模型范围全部保留,随时可恢复。 +
+ + 没有「彻底删除」:Agent 名与人类用户名共用命名空间,删掉之后若有人注册同名, + 历史邮件会看起来像是他发的。 +

- {error &&
{error}
} + {error &&
{error}
} + {notice &&
{notice}
} {stats.length === 0 ? (
暂无已注册的 Agent
@@ -159,14 +172,17 @@ export default function QuotaPanel() { )}