/** * 会话改名提议 —— 所有平台插件共用。 * * # 这是什么 * * 模型干完活后可能觉得当前别名不贴切:会话建立时叫 `witty-planet`(平台随机 slug) * 或 `排查登录问题`(人写的邮件主题),摸清问题后它知道这其实是 * `fix-session-cookie-leak`。改名提议就是让它把这个判断说出来。 * * # 为什么是「提议」而不是直接改 * * 别名是**人**的寻址入口 —— `name@path.<别名>` 里那一段。Agent 干到一半自己改掉, * 人上一秒记住的地址下一秒就 404(`session` 位三态语义要求指向不存在的会话直接报 * 「无法送达」,不会静默新建)。所以提议入库、由人在界面上点「接受」才真正生效。 * * 这与平台命名自动同步(`POST /sessions/{id}/sync`)互补,两者不冲突: * * | | 谁发起 | 何时 | 是否打扰人 | * |---|---|---|---| * | 自动同步 | 平台的命名机制 | 每轮结束 | 不,后台静默生效 | * | 改名提议 | 模型的主动判断 | 它认为有必要时 | 是,界面上出提示条 | * * # 为什么载体是 HTML 注释 * * `/mail/send` 没有 `propose_alias` 字段 —— 提议**搭在正文里**发出去, * 服务端用正则摘出来再把标记从入库正文中剥掉。选 HTML 注释的三个理由: * * - react-markdown 默认不解析 raw HTML,万一服务端没剥掉,它在页面上也只是 * 一行不显眼的转义文本,不会破版 * - 纯文本邮件客户端里是一行不碍事的注释,不像自造标记那样显眼 * - 不与 Markdown 语法冲突,格式化工具不会改写它 * * # 为什么必须共用 * * 标记格式是**服务端正则的镜像**(`server/internal/handler/rename_proposal.go`)。 * 各平台各写一遍拼接,某一处少个空格或把双引号写成单引号,服务端匹配不上 —— * 而失败是静默的:邮件照常发出,提议凭空消失,模型以为自己提过了。 */ /** * 服务端能识别的别名字符集。 * * 与 `validateSessionAlias` 一致:`. 空白 / @` 会与三维地址解析冲突, * `new` 是寻址保留字。这里**不做规范化**(不把非法字符替换成 `-`)—— * 规范化是服务端 `normalizeAlias` 的职责,插件擅自改写会让模型看到的 * 「我提议的名字」与实际入库的不一致。 * * @param {string} alias * @returns {boolean} */ export function isProposableAlias(alias) { const a = String(alias ?? '').trim(); if (!a) return false; if (a === 'new') return false; // 双引号是标记本身的定界符,含它会截断标记 if (/[.\s/@"]/.test(a)) return false; // 服务端 VARCHAR(128),按字节算 if (Buffer.byteLength(a, 'utf8') > 128) return false; return true; } /** * 把改名提议标记追加到正文末尾。 * * 格式必须与服务端正则逐字符对应: * `` * reason 可选,为空时**整个属性都不写**(写成 `reason=""` 服务端会存一个空理由, * 界面上的提示条就少了那句解释)。 * * 别名不合法时**原样返回正文**,不追加标记:与其发一个服务端匹配得上却 * 被 `validateSessionAlias` 拒掉的标记,不如当它没提 —— 调用方据此告诉模型。 * * @param {string} body 原始正文 * @param {string} [alias] 提议的别名 * @param {string} [reason] 提议理由,一句话 * @returns {{body: string, proposed: boolean}} proposed=false 表示别名不合法,未追加 */ export function appendRenameProposal(body, alias, reason) { const text = String(body ?? ''); if (!isProposableAlias(alias)) return { body: text, proposed: false }; const a = String(alias).trim(); // 理由里的双引号会截断标记,去掉而不是转义:HTML 注释里没有转义机制 const r = String(reason ?? '').replace(/"/g, '').trim(); const reasonAttr = r ? ` reason="${r}"` : ''; return { body: `${text}\n\n`, proposed: true, }; } /** * 提议提交后回给模型的那句话。 * * **别名取服务端回的 `rename_proposed`,不是本地提议的那个。** 服务端会跑 * `normalizeAlias` —— 非法字符换成 `-`、`new` 变 `session-new`、超长按 UTF-8 * 边界截断。回显本地值会让模型记住一个不存在的名字,之后拿它寻址就 404。 * * 必须说明「等人确认」。不说的话模型会以为改名已经生效,接着在后续邮件里 * 用新别名当地址发信 —— 而那个别名此刻还不存在,投递会失败。 * * @param {string} [serverAlias] 服务端 `/mail/send` 响应里的 `rename_proposed` * @param {string} [requestedAlias] 本地提议的别名,仅用于「未提交」时的说明 * @param {boolean} [proposed] appendRenameProposal 的返回值 * @returns {string} 空串表示没有需要追加的说明 */ export function renameProposalNote(serverAlias, requestedAlias, proposed) { const server = String(serverAlias ?? '').trim(); const wanted = String(requestedAlias ?? '').trim(); // 服务端确认收到了:用它给的最终值 if (server) { const changed = wanted && wanted !== server ? `(你提的 "${wanted}" 被规范化成了这个)` : ''; return `已附上改名提议 "${server}"${changed},等用户在界面上确认后生效 —— ` + `在那之前继续用原别名寻址。`; } if (!wanted) return ''; // 本地就判定不合法,标记没发出去 if (!proposed) { return `(改名提议 "${wanted}" 未提交:别名不可为 new,不可含 . 空白 / @ 或双引号。)`; } // 标记发出去了但服务端没回 rename_proposed:它那侧的校验也拒了 return `(改名提议 "${wanted}" 未被服务端接受,会话别名不变。)`; }