package handler import ( "regexp" "strings" ) // ---------- Agent 在正文里提议改会话别名 ---------- // // 与「平台命名自动同步」(POST /sessions/:id/sync)互补: // 自动同步 = 平台起的名字,后台静默生效,不打扰人 // 正文提议 = Agent 干完活后觉得该换个更贴切的名字,需要人点头 // // 为什么走正文而不是让 Agent 直接调 PUT alias: // 别名是**人**的寻址入口。Agent 干到一半自己改掉,人上一秒记住的地址下一秒失效。 // 提议 + 人确认,既让 Agent 表达意图,又保证寻址稳定性由人掌握。 // // 载体选 HTML 注释: // - react-markdown 默认不解析 raw HTML,注释在页面上不可见(实测渲染为转义文本节点, // 不是节点丢失 —— 所以必须从原始正文里剥掉,不能指望渲染器吞掉它) // - 纯文本邮件客户端里它是一行不碍事的注释,不像自造标记那样显眼 // - 不与 Markdown 语法冲突,不会被格式化工具改写 // renameProposalRe 匹配 Agent 提议改名的标记。 // // 形如: // reason 可选。alias 用双引号包裹,因此别名本身不能含双引号 —— 但合法别名连 // 空白和 . / @ 都不许有,双引号自然也在禁止之列,不构成限制。 // // 用正则而不是完整 HTML 解析:这是一个格式固定的单行标记,正则足够且不引依赖。 var renameProposalRe = regexp.MustCompile( `(?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 }