relay-intent.ts 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156
  1. import { Agent } from './api/models';
  2. /**
  3. * 容器中转指令 —— 让 A 去问 B,App 负责搬运。
  4. *
  5. * ## 为什么需要 App 中转
  6. *
  7. * 实测(2026-10-29)确认容器**有能力**主动发 Matrix 消息(lj 实发成功,
  8. * event_id 回读验证过)。但同时暴露两个问题:
  9. *
  10. * 1. **lj-test 发送失败** —— PUT 返回空体,拿不到 event_id
  11. * 2. **两端互相看不见** —— lj 说「room 里有 lj-test」,lj-test 说「没有」,
  12. * 甚至把对方认成自己
  13. *
  14. * 让容器直接互发,可靠性取决于**每个容器的实现质量** —— 现状是两个里有一个不行。
  15. *
  16. * 而 App 已经能收发所有消息、认得出每个容器是谁。**由 App 做搬运更稳。**
  17. *
  18. * ## 怎么用(给用户的)
  19. *
  20. * 在 A 的对话里直接说:
  21. *
  22. * ```
  23. * 问 lj-test:数据库用的什么版本?
  24. * 让 lj-test 检查一下接口
  25. * @lj-test 这个问题你怎么看
  26. * ```
  27. *
  28. * App 识别到「对象 + 问题」后:
  29. * · 把问题发给 B
  30. * · B 的答复转回 A 的对话
  31. *
  32. * ## 本模块只做识别,**不做发送**
  33. *
  34. * 发送涉及两个房间与状态机,放在 `relay.service.ts`;
  35. * 识别的各种说法必须能单测穷举,所以单独抽出来。
  36. */
  37. /** 一条中转指令 */
  38. export interface RelayIntent {
  39. /** 要问的容器 */
  40. target: Agent;
  41. /** 要转达的问题(已去掉「问 X:」这类前缀) */
  42. question: string;
  43. /** 命中的原文片段(用于界面上回显与确认) */
  44. matched: string;
  45. }
  46. /**
  47. * 触发词:出现这些词 + 后面跟容器名,才认为用户想中转。
  48. *
  49. * 🔴 **只认明确的动词**,不做模糊匹配 ——
  50. * 误判会把普通对话转给别的容器,那比不识别更糟。
  51. */
  52. const VERBS = ['问一下', '问问', '问', '让', '找', '叫', '请'];
  53. /** 分隔符:动词/名字之后常见 `:` `:` `,` `,` 空格 */
  54. const SEP = String.raw`[\s::,,]*`;
  55. /** 把字符串里的正则元字符转义(容器名可能含 `.` `-` 等) */
  56. function esc(s: string): string {
  57. return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  58. }
  59. /**
  60. * 识别「去问某个容器」的指令。
  61. *
  62. * @param text 用户输入的原文
  63. * @param agents 可被问的容器列表(**不含当前容器**,由调用方过滤)
  64. * @returns 命中则返回目标与问题;未命中返回 null
  65. */
  66. export function parseRelayIntent(text: string, agents: Agent[]): RelayIntent | null {
  67. const src = (text ?? '').trim();
  68. if (!src || agents.length === 0) return null;
  69. /*
  70. * 按名字长度**从长到短**排队匹配 ——
  71. * `lj` 是 `lj-test` 的前缀,如果先匹配 `lj`,
  72. * 「问 lj-test:...」会被误判成在问 `lj`(实测容器也有同样的问题)。
  73. */
  74. const sorted = [...agents]
  75. .filter((a) => !!a.name)
  76. .sort((x, y) => y.name.length - x.name.length);
  77. /*
  78. * 🔴 名字后面必须**紧跟分隔符或结束**,不能是任意字符。
  79. *
  80. * `\b` 在这里靠不住 —— 实测「问 lj-test:xxx」被短名 `lj` 命中,
  81. * 问题变成 `-test:xxx`。所以显式限定「名字之后是空白/冒号/逗号/结束」。
  82. */
  83. const BOUNDARY = String.raw`(?=[\s::,,]|$)`;
  84. for (const agent of sorted) {
  85. const name = esc(agent.name);
  86. // ① 显式 @ 优先:「@lj-test 你怎么看」
  87. const at = new RegExp(String.raw`@${name}${BOUNDARY}${SEP}([\s\S]+)$`);
  88. const atHit = at.exec(src);
  89. if (atHit) {
  90. const q = cleanQuestion(atHit[1]);
  91. if (q) return { target: agent, question: q, matched: `@${agent.name}` };
  92. }
  93. // ② 动词 + 名字 + 分隔 + 问题:「问 lj-test:xxx」「让 lj-test 检查一下」
  94. for (const verb of VERBS) {
  95. const re = new RegExp(String.raw`${verb}\s*${name}${BOUNDARY}${SEP}([\s\S]+)$`);
  96. const hit = re.exec(src);
  97. if (hit) {
  98. const q = cleanQuestion(hit[1]);
  99. if (q) return { target: agent, question: q, matched: `${verb} ${agent.name}` };
  100. }
  101. }
  102. }
  103. return null;
  104. }
  105. /**
  106. * 去掉问题开头残留的分隔符。
  107. *
  108. * 分隔符与问题之间只剩一个 `:` 时,`SEP` 吞掉它后正则回溯会留下
  109. * (实测:「问 lj-test:」得到的问题就是 `:`)。剥掉开头分隔符再判断。
  110. */
  111. function cleanQuestion(raw: string): string {
  112. return raw.replace(/^[\s::,,]+/, '').trim();
  113. }
  114. /**
  115. * 把中转结果包装成回填到发起方对话里的文本。
  116. *
  117. * 🔴 必须**标明来源**,否则发起方会以为这句话是自己说的、
  118. * 或者以为答案直接出自用户。
  119. */
  120. export function formatRelayReply(
  121. from: Agent,
  122. question: string,
  123. answer: string,
  124. ): string {
  125. const q = question.length > 80 ? question.slice(0, 80) + '…' : question;
  126. return [
  127. `【来自 ${from.name} 的答复】`,
  128. `我问:${q}`,
  129. ``,
  130. answer.trim() || '(对方没有给出内容)',
  131. ].join('\n');
  132. }
  133. /** 包装发给目标容器的问题(让对方知道这不是用户直接问的) */
  134. export function formatRelayQuestion(from: Agent, asker: string, question: string): string {
  135. return [
  136. `【${asker} 让我来问你】`,
  137. ``,
  138. question,
  139. ``,
  140. `—— 以上转自 ${from.name}。请直接回答,答案会被转回给发起方。`,
  141. ].join('\n');
  142. }