Files
MailUI4Agents/gateway/internal/handler/rename_proposal.go
JianFeeeee 0e754617a4 feat: AgentMail —— 以邮件为统一范式的多智能体协作平台
Go 单二进制网关 + React 前端 + opencode 桥接插件。部署产物是
「一个二进制加一个 .db 文件」:前端经 go:embed 打进二进制,
数据库默认内置 SQLite,systemd 托管。

核心设计
- 三维寻址 name@path.session,按最后一个 . 切分;session 位三态:
  省略=默认会话 / new=强制新建 / 具体别名=必须已存在(否则 404 无法送达)
- 会话别名默认复用 Agent 平台自己的命名机制(opencode 的 slug 与模型生成的
  标题),不在本侧另造一套;人显式定过的别名不被平台同步覆盖
- 对话树不建 tree_nodes 表:parent_mail_id 已完整编码树结构,
  再维护一张表就是第二份真相。用递归 CTE 查,按方向分块加载
- 附件内容存磁盘、按 sha256 内容寻址,数据库只存元数据;天然去重,
  且路径与用户 filename 无关,杜绝 ../ 穿越
- 配额约束的是模型的自主发信,不是 harness 的转发:插件代劳的权限询问与
  最终总结走免配额通道,靠上游消息 id 做幂等键而非计数
- 往返预算下沉到会话(写信时给、对话页里改)+ Agent 全局配额,两层都要过

后端 gateway/
- models/repo/handler/middleware/sse/blob 分层;两方言(SQLite/PostgreSQL)
  共用一份 repo 层 SQL,差异集中在 internal/db
- 多用户认证(bcrypt cost12、登录限速、会话隔离、权限边界)
- 密钥体系:Agent 密钥与用户密钥分表,三种生命周期;登记式密钥让全文
  只从客户端流向服务器一次
- 所有「判断 + 自增」都在同一条 UPDATE 里(配额、预算、one_time 密钥、
  附件挂载),并发下不会刷穿

前端 web/
- 三栏布局、三段式地址补全、权限卡片、密钥面板、配额面板、对话树、附件
- 全站纯 SVG 图标,不使用 emoji
- api/ 即可复用的客户端 SDK:基地址与凭证集中在 api/config.ts

插件 plugins/opencode-mail-bridge/
- 六个工具 + 两类自动转发(permission.ask 钩子接管平台原生权限询问、
  session.idle 时转发本轮总结)
2026-09-02 10:29:26 +08:00

142 lines
5.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

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

package handler
import (
"regexp"
"strings"
)
// ---------- Agent 在正文里提议改会话别名 ----------
//
// 与「平台命名自动同步」POST /sessions/:id/sync互补
// 自动同步 = 平台起的名字,后台静默生效,不打扰人
// 正文提议 = Agent 干完活后觉得该换个更贴切的名字,需要人点头
//
// 为什么走正文而不是让 Agent 直接调 PUT alias
// 别名是**人**的寻址入口。Agent 干到一半自己改掉,人上一秒记住的地址下一秒失效。
// 提议 + 人确认,既让 Agent 表达意图,又保证寻址稳定性由人掌握。
//
// 载体选 HTML 注释:
// - react-markdown 默认不解析 raw HTML注释在页面上不可见实测渲染为转义文本节点
// 不是节点丢失 —— 所以必须从原始正文里剥掉,不能指望渲染器吞掉它)
// - 纯文本邮件客户端里它是一行不碍事的注释,不像自造标记那样显眼
// - 不与 Markdown 语法冲突,不会被格式化工具改写
// renameProposalRe 匹配 Agent 提议改名的标记。
//
// 形如:<!-- agentmail:rename-session alias="fix-login-leak" reason="定位到是登录态泄漏" -->
// reason 可选。alias 用双引号包裹,因此别名本身不能含双引号 —— 但合法别名连
// 空白和 . / @ 都不许有,双引号自然也在禁止之列,不构成限制。
//
// 用正则而不是完整 HTML 解析:这是一个格式固定的单行标记,正则足够且不引依赖。
var renameProposalRe = regexp.MustCompile(
`(?s)<!--\s*agentmail:rename-session\s+alias="([^"]*)"(?:\s+reason="([^"]*)")?\s*-->`)
// RenameProposal 是从正文里解析出的一条改名提议。
type RenameProposal struct {
// Alias 已经过 normalizeAlias 规范化,可直接用于 PUT /sessions/:id/alias
Alias string `json:"alias"`
// Reason 是 Agent 给出的理由,可为空
Reason string `json:"reason,omitempty"`
}
// extractRenameProposal 从正文里取出改名提议,并返回剥掉标记后的正文。
//
// 只认**最后一条**Agent 在长回复里可能反复修正措辞,最后写下的才是它的结论。
// 标记一律从正文里剥掉 —— 它是给系统看的元数据,不该出现在人读的正文里
// react-markdown 会把 HTML 注释转义成可见文本)。
//
// 非法别名(规范化后为空或不合法)视为无提议,但标记仍然剥掉:
// 与其在正文里留一行乱码,不如当它没提。
func extractRenameProposal(body string) (*RenameProposal, string) {
matches := renameProposalRe.FindAllStringSubmatch(body, -1)
cleaned := stripProposalMarkers(body)
if len(matches) == 0 {
return nil, cleaned
}
last := matches[len(matches)-1]
alias := normalizeAlias(strings.TrimSpace(last[1]))
if alias == "" {
return nil, cleaned
}
if err := validateSessionAlias(alias); err != nil {
return nil, cleaned
}
reason := ""
if len(last) > 2 {
reason = strings.TrimSpace(last[2])
}
// 理由是展示给人看的一句话,过长会把提示条撑破
const maxReason = 200
if len(reason) > maxReason {
reason = preview(reason, maxReason)
}
return &RenameProposal{Alias: alias, Reason: reason}, cleaned
}
// stripProposalMarkers 移除全部提议标记,并把因此产生的多余空行压回一个。
func stripProposalMarkers(body string) string {
out := renameProposalRe.ReplaceAllString(body, "")
// 标记独占一行时会留下连续空行压成一个空行Markdown 的段落分隔)
for strings.Contains(out, "\n\n\n") {
out = strings.ReplaceAll(out, "\n\n\n", "\n\n")
}
return strings.TrimSpace(out)
}
// preview 按 UTF-8 边界截断。与 repo.preview 同逻辑,这里为避免 handler → repo
// 的反向依赖而复制一份(两处都是 5 行,抽公共包不值当)。
func preview(s string, max int) string {
if len(s) <= max {
return s
}
cut := max
for cut > 0 && s[cut]&0xC0 == 0x80 {
cut--
}
return s[:cut] + "..."
}
// ---------- 插件代劳转发(免配额通道) ----------
// relayKinds 是允许免配额的转发类型。
//
// 白名单而不是任意字符串:免配额通道必须有明确边界,
// 否则 `relay: "whatever"` 就成了绕过配额的后门。
//
// permission —— 平台原生的权限询问opencode 的 permission.updated
// 不转给人人就看不到Agent 卡在那里等一个永远不会来的回答。
// summary —— 本轮的最终总结session.idle 时最后一条 assistant 消息)。
// 模型已经把话说完了,插件只是搬运;对它收费会导致配额用尽时
// Agent 连交代都做不了。
var relayKinds = map[string]bool{
"permission": true,
"summary": true,
}
// parseRelay 校验免配额转发参数,返回规范化后的 (kind, key)。
// 两者都为空表示这是普通的自主发信,正常扣配额。
func parseRelay(kind, key string) (string, string, error) {
kind = strings.TrimSpace(kind)
key = strings.TrimSpace(key)
if kind == "" {
if key != "" {
return "", "", errBadRequest("给了 relay_key 却没给 relay 类型")
}
return "", "", nil
}
if !relayKinds[kind] {
return "", "", errBadRequest(`relay 只能是 "permission" 或 "summary"`)
}
// 幂等键是免配额通道的唯一约束基础,不能省:
// 没有它就无法阻止同一条上游消息被反复转发。
if key == "" {
return "", "", errBadRequest("relay 转发必须带 relay_key上游消息的稳定 id")
}
if len(key) > 160 {
return "", "", errBadRequest("relay_key 过长(上限 160 字节)")
}
return kind, key, nil
}