142 lines
5.6 KiB
Go
142 lines
5.6 KiB
Go
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
|
||
}
|