import { Agent } from './api/models'; /** * 容器中转指令 —— 让 A 去问 B,App 负责搬运。 * * ## 为什么需要 App 中转 * * 实测(2026-10-29)确认容器**有能力**主动发 Matrix 消息(lj 实发成功, * event_id 回读验证过)。但同时暴露两个问题: * * 1. **lj-test 发送失败** —— PUT 返回空体,拿不到 event_id * 2. **两端互相看不见** —— lj 说「room 里有 lj-test」,lj-test 说「没有」, * 甚至把对方认成自己 * * 让容器直接互发,可靠性取决于**每个容器的实现质量** —— 现状是两个里有一个不行。 * * 而 App 已经能收发所有消息、认得出每个容器是谁。**由 App 做搬运更稳。** * * ## 怎么用(给用户的) * * 在 A 的对话里直接说: * * ``` * 问 lj-test:数据库用的什么版本? * 让 lj-test 检查一下接口 * @lj-test 这个问题你怎么看 * ``` * * App 识别到「对象 + 问题」后: * · 把问题发给 B * · B 的答复转回 A 的对话 * * ## 本模块只做识别,**不做发送** * * 发送涉及两个房间与状态机,放在 `relay.service.ts`; * 识别的各种说法必须能单测穷举,所以单独抽出来。 */ /** 一条中转指令 */ export interface RelayIntent { /** 要问的容器 */ target: Agent; /** 要转达的问题(已去掉「问 X:」这类前缀) */ question: string; /** 命中的原文片段(用于界面上回显与确认) */ matched: string; } /** * 触发词:出现这些词 + 后面跟容器名,才认为用户想中转。 * * 🔴 **只认明确的动词**,不做模糊匹配 —— * 误判会把普通对话转给别的容器,那比不识别更糟。 */ const VERBS = ['问一下', '问问', '问', '让', '找', '叫', '请']; /** 分隔符:动词/名字之后常见 `:` `:` `,` `,` 空格 */ const SEP = String.raw`[\s::,,]*`; /** 把字符串里的正则元字符转义(容器名可能含 `.` `-` 等) */ function esc(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } /** * 识别「去问某个容器」的指令。 * * @param text 用户输入的原文 * @param agents 可被问的容器列表(**不含当前容器**,由调用方过滤) * @returns 命中则返回目标与问题;未命中返回 null */ export function parseRelayIntent(text: string, agents: Agent[]): RelayIntent | null { const src = (text ?? '').trim(); if (!src || agents.length === 0) return null; /* * 按名字长度**从长到短**排队匹配 —— * `lj` 是 `lj-test` 的前缀,如果先匹配 `lj`, * 「问 lj-test:...」会被误判成在问 `lj`(实测容器也有同样的问题)。 */ const sorted = [...agents] .filter((a) => !!a.name) .sort((x, y) => y.name.length - x.name.length); /* * 🔴 名字后面必须**紧跟分隔符或结束**,不能是任意字符。 * * `\b` 在这里靠不住 —— 实测「问 lj-test:xxx」被短名 `lj` 命中, * 问题变成 `-test:xxx`。所以显式限定「名字之后是空白/冒号/逗号/结束」。 */ const BOUNDARY = String.raw`(?=[\s::,,]|$)`; for (const agent of sorted) { const name = esc(agent.name); // ① 显式 @ 优先:「@lj-test 你怎么看」 const at = new RegExp(String.raw`@${name}${BOUNDARY}${SEP}([\s\S]+)$`); const atHit = at.exec(src); if (atHit) { const q = cleanQuestion(atHit[1]); if (q) return { target: agent, question: q, matched: `@${agent.name}` }; } // ② 动词 + 名字 + 分隔 + 问题:「问 lj-test:xxx」「让 lj-test 检查一下」 for (const verb of VERBS) { const re = new RegExp(String.raw`${verb}\s*${name}${BOUNDARY}${SEP}([\s\S]+)$`); const hit = re.exec(src); if (hit) { const q = cleanQuestion(hit[1]); if (q) return { target: agent, question: q, matched: `${verb} ${agent.name}` }; } } } return null; } /** * 去掉问题开头残留的分隔符。 * * 分隔符与问题之间只剩一个 `:` 时,`SEP` 吞掉它后正则回溯会留下 * (实测:「问 lj-test:」得到的问题就是 `:`)。剥掉开头分隔符再判断。 */ function cleanQuestion(raw: string): string { return raw.replace(/^[\s::,,]+/, '').trim(); } /** * 把中转结果包装成回填到发起方对话里的文本。 * * 🔴 必须**标明来源**,否则发起方会以为这句话是自己说的、 * 或者以为答案直接出自用户。 */ export function formatRelayReply( from: Agent, question: string, answer: string, ): string { const q = question.length > 80 ? question.slice(0, 80) + '…' : question; return [ `【来自 ${from.name} 的答复】`, `我问:${q}`, ``, answer.trim() || '(对方没有给出内容)', ].join('\n'); } /** 包装发给目标容器的问题(让对方知道这不是用户直接问的) */ export function formatRelayQuestion(from: Agent, asker: string, question: string): string { return [ `【${asker} 让我来问你】`, ``, question, ``, `—— 以上转自 ${from.name}。请直接回答,答案会被转回给发起方。`, ].join('\n'); }