package mcp import ( "fmt" "regexp" "strings" ) // 会话改名提议 —— 与 `plugins/*/lib/rename-proposal.js` 逐字同源的 Go 副本。 // // # 为什么要复刻一份而不是让 handler 做 // // `/mail/send` **没有** `propose_alias` 字段:提议是**搭在正文里**发出去的, // 服务端用正则摘出来、剥掉标记、把值回填到响应的 `rename_proposed`。 // 所以这不是「服务端少了个字段」,而是**传输格式定义在客户端** —— // MCP 要支持它,就得自己拼这段标记。 // // 标记格式是服务端正则的镜像(`server/internal/handler/rename_proposal.go` 的 // `renameProposalRe`)。格式必须逐字符对应:少个空格、把双引号写成单引号, // 服务端就匹配不上 —— 而失败是**静默**的:邮件照常发出,提议凭空消失, // 模型以为自己提过了,下次接着用那个不存在的别名寻址并 404。 // // 选 HTML 注释作载体的三个理由(与 JS 版一致): // - react-markdown 默认不解析 raw HTML,服务端没剥掉也只是一行转义文本,不破版 // - 纯文本邮件客户端里是一行不碍事的注释 // - 不与 Markdown 语法冲突,格式化工具不会改写它 var renameProposalRe = regexp.MustCompile( `(?s)`) // isProposableAlias 判一个别名能不能作为提议值。 // // 与 JS 版同规则:`.` 空白 `/` `@` 与三维地址解析冲突,`new` 是寻址保留字, // 双引号是标记的定界符,长度受服务端 VARCHAR(128) 约束(按字节)。 // // 这里**不做规范化**(不把非法字符换成 `-`)—— 规范化是服务端 normalizeAlias // 的职责;插件擅自改写会让模型看到的「我提议的名字」与实际入库的不一致。 func isProposableAlias(alias string) bool { a := strings.TrimSpace(alias) if a == "" || a == "new" { return false } if strings.ContainsAny(a, ".\n\r\t /@\"") { return false } if strings.ContainsAny(a, " \t\n\r") { return false } return len(a) <= 128 } // appendRenameProposal 把改名提议标记追加到正文末尾。 // // 返回拟加后的正文与是否真的拟加了。别名不合法时**原样返回**、不追加: // 与其发一个服务端匹配得上却被 validateSessionAlias 拒掉的标记, // 不如当它没提 —— 调用方据此把「没提交」告诉模型。 func appendRenameProposal(body, alias, reason string) (string, bool) { text := body if !isProposableAlias(alias) { return text, false } a := strings.TrimSpace(alias) // 理由里的双引号会截断标记,去掉而不是转义:HTML 注释里没有转义机制。 r := strings.TrimSpace(strings.ReplaceAll(reason, "\"", "")) reasonAttr := "" if r != "" { reasonAttr = fmt.Sprintf(" reason=%q", r) } return fmt.Sprintf("%s\n\n", text, a, reasonAttr), true } // renameProposalNote 拟提交后回给模型的那句话。空串表示没有需要追加的说明。 // // **别名取服务端回的 `rename_proposed`,不是本地提议的那个。** 服务端会跑 // normalizeAlias(非法字符换 `-`、`new` 变 `session-new`、超长按 UTF-8 边界截断); // 回显本地值会让模型记住一个不存在的名字,之后拿它寻址就 404。 // // 必须说明「等人确认」:不说的话模型会以为改名已生效,接着用新别名发信 —— // 而那个别名此刻还不存在,投递会失败。 func renameProposalNote(serverAlias, requestedAlias string, proposed bool) string { server := strings.TrimSpace(serverAlias) wanted := strings.TrimSpace(requestedAlias) if server != "" { changed := "" if wanted != "" && wanted != server { changed = fmt.Sprintf("(你提的 %q 被规范化成了这个)", wanted) } return fmt.Sprintf("已附上改名提议 %q%s,等用户在界面上确认后生效 —— 在那之前继续用原别名寻址。", server, changed) } if wanted == "" { return "" } if !proposed { return fmt.Sprintf("(改名提议 %q 未提交:别名不可为 new,不可含 . 空白 / @ 或双引号。)", wanted) } // 标记发出去了但服务端没回 rename_proposed:它那侧的校验也拒了 return fmt.Sprintf("(改名提议 %q 未被服务端接受,会话别名不变。)", wanted) }