chore: directory migration - gateway→server, web→client/electron

This commit is contained in:
2026-09-08 19:16:35 +08:00
parent fd9f99a3f9
commit f9d757b5e5
243 changed files with 5095 additions and 228 deletions

View File

@ -0,0 +1,141 @@
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
}