| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156 |
- 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');
- }
|