Browse Source

feat(core): 自动对话回环检测(P0 实测驱动)

背景:P0 验证确认两个容器能通过 Matrix 互相 @,
但没有任何停止条件 —— 实测 28.6 分钟互发 96 条,最长连续 57 条无人类介入。

实现
- loop-guard.ts:纯判定函数,三个信号(连续数 / 速率 / 内容重复)
- loop-guard.service.ts:运行时接入会话状态
- 会话内显示「检测到自动对话循环」+ 一键继续

关键修正
初版「连续 5 条」即触发,实测误伤了正常干活:
用户让容器改白名单,它连发 7 条(改配置/diff/重启/验证/汇报),
内容各不相同 —— 这是执行任务。改为「连续数达标 且 内容重复」才判定。

验证(真实数据回放)
- 96 条真实回环:第 8 条触发,提前止损 92%
- 7 条正常执行:不误判
- 人类+容器交替协作:不误判

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct 1 week ago
parent
commit
56ce9c2304

+ 40 - 0
docs/00-tracking/操作追踪.md

@@ -1322,3 +1322,43 @@ build ✓  test 564 SUCCESS  verify:ui 11/11  verify:docs 51 文件
 ### 已执行清理
 
 测试群两个容器已移出,群内仅剩 owner。
+
+## 2026-10-21 · 防回环实现(第十四轮)
+
+### 实现内容
+
+| 文件 | 作用 |
+|---|---|
+| `core/loop-guard.ts` | **纯判定函数**(可单测,不碰网络) |
+| `core/loop-guard.service.ts` | 运行时:收集消息、判定身份、维护暂停状态 |
+| `chat.ts` | 检测到回环时显示警告条 + 「继续协作」按钮 |
+
+### 🔴 关键修正:初版规则会误伤正常干活
+
+初版「连续 5 条 agent 消息」就判定回环。实测发现**误判**:
+
+> 用户让我改白名单,容器连发 7 条:改配置 / diff 确认 / 重启 / 重启后验证 / 汇报…
+> **内容各不相同,这是执行任务,不是回环。**
+
+修正后判据:**连续数达标 且 内容在原地打转**(两者必须同时满足)。
+
+### 验证(真实数据回放)
+
+用 P0 实测的 96 条真实回环消息回放:
+
+| 场景 | 结果 |
+|---|---|
+| 真实回环 | **第 8 条触发**(streak+repeat),提前止损 **88 条(92%)** |
+| 正常干活(7 条执行消息,内容各异) | **不误判** |
+| 正常协作(人类+容器交替,7 条) | **不误判** |
+
+### 设置能力
+
+四档预设(`strict` / `standard` / `loose` / `off`),阈值 3/5/10 条。
+恢复方式:**人类发一条消息即解除**,无需去后台。
+
+### 待办
+
+- 服务端权威判定(容器不看界面,需要服务端告诉它该不该回)
+- 设置页 UI(当前仅代码层可切)
+- 左侧列表重复的 `lj-test`(待平台恢复后排查是否数据重复)

+ 107 - 0
src/app/core/loop-guard.service.ts

@@ -0,0 +1,107 @@
+import { computed, effect, inject, Injectable, signal } from '@angular/core';
+import { ChatService } from './chat.service';
+import { LiveService } from './live.service';
+import { LoopGuardMode, LoopMessage, LoopVerdict, configOf, detectLoop } from './loop-guard';
+
+/**
+ * 回环守卫(运行时)—— 把纯函数的检测接到会话状态上。
+ *
+ * ## 职责边界
+ *
+ * | 层 | 做什么 |
+ * |---|---|
+ * | `loop-guard.ts` | **纯判定**:给一串消息,说是不是回环(可单测) |
+ * | 本服务 | 收集消息、判定身份、维护暂停状态、给 UI 提供状态 |
+ *
+ * ## 🔴 为什么判定放在前端而不是只放服务端
+ *
+ * 服务端能看到全房间的消息流,判定更准 —— 但它**看不到**用户此刻的感受。
+ * 前端能立刻知道「刚刚这 5 条都是容器在互相回」,可以**立即**让步进条停下、
+ * 在界面上提示用户,而不是等下一次服务端轮询。
+ *
+ * 两处**都在做**是有意的:
+ *   · 前端:即时刹车,防 UI 继续被淹没
+ *   · 服务端(后续):权威判定,容器按它决定是否自动回复
+ *
+ * 只做前端不够(容器不看界面);只做服务端也不够(要等轮询)。
+ */
+
+/** 默认模式:标准档。用户可在设置里改。 */
+const DEFAULT_MODE: LoopGuardMode = 'standard';
+
+@Injectable({ providedIn: 'root' })
+export class LoopGuardService {
+  private readonly chat = inject(ChatService);
+  private readonly live = inject(LiveService);
+
+  private readonly _mode = signal<LoopGuardMode>(DEFAULT_MODE);
+  readonly mode = this._mode.asReadonly();
+
+  /** 当前会话是否被判定为回环 */
+  private readonly _paused = signal(false);
+  readonly paused = this._paused.asReadonly();
+
+  private readonly _verdict = signal<LoopVerdict | null>(null);
+  readonly verdict = this._verdict.asReadonly();
+
+  /** 触发时的诊断信息(连续数、速率),供 UI 提示与审计 */
+  readonly pauseDetail = computed(() => this._verdict()?.detail ?? '');
+
+  /** 用户手动解除的次数(换会话时清零) */
+  private dismissedFor = '';
+
+  setMode(mode: LoopGuardMode): void {
+    this._mode.set(mode);
+    if (mode === 'off') this.resume();
+    else this.evaluate();
+  }
+
+  /** 人类发消息 → 自动解除(这是最自然的恢复方式,无需点按钮) */
+  resume(): void {
+    this._paused.set(false);
+    this._verdict.set(null);
+  }
+
+  constructor() {
+    // 消息变化时重新判定
+    effect(() => {
+      this.chat.messages();
+      this._mode();
+      this.evaluate();
+    });
+  }
+
+  private evaluate(): void {
+    const cfg = configOf(this._mode());
+    if (!cfg) {
+      this._paused.set(false);
+      return;
+    }
+    const agentId = this.chat.agentId();
+    if (!agentId) {
+      this.resume();
+      return;
+    }
+    // 换会话:清掉上一个会话的暂停状态
+    if (this.dismissedFor !== agentId) {
+      this.dismissedFor = agentId;
+      this._paused.set(false);
+      this._verdict.set(null);
+    }
+
+    const mxId = this.chat.provisionInfo?.userMxId ?? '';
+    const agentMxId = this.chat.provisionInfo?.agentMxId ?? '';
+    const msgs: LoopMessage[] = this.chat
+      .messages()
+      .filter((m) => !!m.text)
+      .map((m) => ({
+        sender: m.sender === 'me' ? mxId : agentMxId,
+        body: m.text ?? '',
+        ts: Date.parse(m.ts) || 0,
+      }));
+
+    const v = detectLoop(msgs, (s) => s === mxId, cfg);
+    this._verdict.set(v);
+    this._paused.set(v.loop);
+  }
+}

+ 245 - 0
src/app/core/loop-guard.spec.ts

@@ -0,0 +1,245 @@
+import {
+  LOOP_GUARD_PRESETS,
+  LoopMessage,
+  configOf,
+  detectLoop,
+  similarity,
+} from './loop-guard';
+
+/**
+ * 自动对话回环检测。
+ *
+ * 阈值来自 2026-10-21 的实测数据(两个容器互相对话 28.6 分钟 / 96 条消息 /
+ * 最长连续 57 条无人类介入),不是拍脑袋定的。
+ */
+describe('loop-guard', () => {
+  const HUMAN = '@xhvrjtmwqh:fmode.cn';
+  const A = '@k0nrevcfik:fmode.cn';
+  const B = '@zgdcmgnqcd:fmode.cn';
+  const isHuman = (s: string) => s === HUMAN;
+
+  function msg(sender: string, body: string, ts: number): LoopMessage {
+    return { sender, body, ts };
+  }
+
+  /** 造一串连续 agent 消息(模拟互相「收到」「还在处理」) */
+  function agentBurst(n: number, startTs = 1_000_000, humanFirst = false): LoopMessage[] {
+    const out: LoopMessage[] = [];
+    if (humanFirst) out.push(msg(HUMAN, '你们互相测一下', startTs));
+    const phrases = [
+      '收到',
+      '还在处理中,请稍等',
+      '对方回了,我继续盯着',
+      '再等一轮',
+      '结果出来了',
+    ];
+    for (let i = 0; i < n; i++) {
+      out.push(msg(i % 2 ? A : B, phrases[i % phrases.length] + i, startTs + 1000 * (i + 1)));
+    }
+    return out;
+  }
+
+  describe('预设与配置', () => {
+    it('off 模式返回 null(不检测)', () => {
+      expect(configOf('off')).toBeNull();
+    });
+
+    it('三档阈值递增', () => {
+      expect(LOOP_GUARD_PRESETS.strict.maxStreak).toBeLessThan(
+        LOOP_GUARD_PRESETS.standard.maxStreak,
+      );
+      expect(LOOP_GUARD_PRESETS.standard.maxStreak).toBeLessThan(
+        LOOP_GUARD_PRESETS.loose.maxStreak,
+      );
+    });
+  });
+
+  describe('① 连续数 + 内容重复(必须同时满足)', () => {
+    /**
+     * 🔴 规则修正(2026-10-21 实测)
+     *
+     * 起初「连续 5 条 agent」就触发,结果误伤了正常干活:
+     * 用户让容器改白名单,它连发 7 条(改配置/diff/重启/验证/汇报),
+     * 内容各不相同 —— 这是执行过程,不是回环。
+     *
+     * 现在的判据:连续数达标 **且** 内容在原地打转。
+     */
+
+    it('🔴 连续 57 条且内容重复(实测回环)→ 判定回环', () => {
+      const msgs = agentBurst(57);
+      const v = detectLoop(msgs, isHuman, LOOP_GUARD_PRESETS.standard, 1_000_000 + 60_000);
+      expect(v.loop).toBeTrue();
+      expect(v.streak).toBe(57);
+    });
+
+    it('🔴 连续 7 条但内容各不相同(正常执行任务)→ **不触发**', () => {
+      const t0 = 1_000_000;
+      const work = [
+        [A, '我先备份配置文件'],
+        [A, '已备份到 .env.bak-p0-20261021'],
+        [A, '现在修改 MATRIX_ALLOWED_USERS 这一行'],
+        [A, 'diff 确认只改了 552 行'],
+        [A, '正在重启 gateway'],
+        [A, '重启完成,PID 已变'],
+        [A, 'post-restart 验证通过'],
+      ] as [string, string][];
+      const msgs = work.map(([sender, body], i) => msg(sender, body, t0 + i * 10_000));
+      const v = detectLoop(msgs, isHuman, LOOP_GUARD_PRESETS.standard, t0 + 70_000);
+      expect(v.streak).toBe(7);
+      expect(v.loop)
+        .withContext('执行任务的连续消息不应被判为回环')
+        .toBeFalse();
+    });
+
+    it('只有 3 条不同内容 → 标准模式不触发(连续与重复都未达)', () => {
+      const t0 = 1_100_000;
+      const msgs = [
+        msg(A, '表单校验写好了', t0),
+        msg(B, '接口加了参数校验', t0 + 5_000),
+        msg(A, '前端已经接上了', t0 + 10_000),
+      ];
+      expect(detectLoop(msgs, isHuman, LOOP_GUARD_PRESETS.standard, t0 + 15_000).loop).toBeFalse();
+    });
+
+    it('🔴 人类说过话后连续数是重新计算的(复读场景)', () => {
+      // 人类先说一句,然后两个容器开始原地打转
+      const t0 = 1_050_000;
+      const msgs: LoopMessage[] = [msg(HUMAN, '你们对接一下接口', t0)];
+      for (let i = 0; i < 10; i++) {
+        msgs.push(msg(i % 2 ? A : B, '收到', t0 + (i + 1) * 5_000));
+      }
+      const v = detectLoop(msgs, isHuman, LOOP_GUARD_PRESETS.standard, t0 + 60_000);
+      expect(v.streak).toBe(10);
+      expect(v.loop).toBeTrue();
+    });
+
+    it('人类在中间插话 → 连续数归零', () => {
+      const msgs = [
+        ...agentBurst(4),
+        msg(HUMAN, '停一下', 1_010_000),
+        ...agentBurst(2, 1_020_000),
+      ];
+      const v = detectLoop(msgs, isHuman, LOOP_GUARD_PRESETS.standard);
+      expect(v.streak).toBe(2);
+      expect(v.loop).toBeFalse();
+    });
+
+    it('严格模式阈值更低:同样 3 条重复内容触发', () => {
+      const t0 = 1_200_000;
+      const msgs = [A, B, A].map((s, i) => msg(s, '收到', t0 + i * 5_000));
+      expect(detectLoop(msgs, isHuman, LOOP_GUARD_PRESETS.strict, t0 + 15_000).loop).toBeTrue();
+    });
+  });
+
+  describe('② 速率检测', () => {
+    it('一分钟内 15 条(阈值 10)→ 触发', () => {
+      const now = 2_000_000;
+      // 连续数故意做小,确保是靠速率触发
+      const msgs: LoopMessage[] = [];
+      for (let i = 0; i < 15; i++) msgs.push(msg(A, `消息${i}`, now - 50_000 + i * 1000));
+      const v = detectLoop(msgs, isHuman, { maxStreak: 100, maxPerMinute: 10, repeatWindow: 99 }, now);
+      expect(v.loop).toBeTrue();
+      expect(v.reason).toBe('rate');
+    });
+
+    it('一分钟内 5 条 → 不触发(隔离连续数规则,只测速率)', () => {
+      const now = 2_000_000;
+      const msgs: LoopMessage[] = [];
+      for (let i = 0; i < 5; i++) msgs.push(msg(A, `消息${i}`, now - 20_000 + i * 1000));
+      // maxStreak 设很大:确保不是被连续数规则误触发
+      const v = detectLoop(msgs, isHuman, { maxStreak: 100, maxPerMinute: 10, repeatWindow: 99 }, now);
+      expect(v.loop).toBeFalse();
+      expect(v.perMinute).toBe(5);
+    });
+  });
+
+  describe('③ 内容重复检测', () => {
+    it('🔴 连续「收到」「收到。」「收到!」→ 判为复读', () => {
+      const msgs = [
+        msg(A, '收到', 1),
+        msg(B, '收到。', 2),
+        msg(A, '收到!', 3),
+      ];
+      const v = detectLoop(msgs, isHuman, { maxStreak: 99, maxPerMinute: 99, repeatWindow: 3 });
+      expect(v.loop).toBeTrue();
+      expect(v.reason).toBe('repeat');
+    });
+
+    it('内容各不相同的三条 → 不触发', () => {
+      const msgs = [
+        msg(A, '我把登录页的样式调好了', 1),
+        msg(B, '后端接口我加了校验', 2),
+        msg(A, '那我把前端接上', 3),
+      ];
+      const v = detectLoop(msgs, isHuman, { maxStreak: 99, maxPerMinute: 99, repeatWindow: 3 });
+      expect(v.loop).toBeFalse();
+    });
+  });
+
+  describe('边界', () => {
+    it('空消息列表 → 不触发', () => {
+      const v = detectLoop([], isHuman, LOOP_GUARD_PRESETS.standard);
+      expect(v.loop).toBeFalse();
+      expect(v.streak).toBe(0);
+    });
+
+    it('off 模式 → 永远不触发(用户明确选择不限)', () => {
+      const v = detectLoop(agentBurst(100), isHuman, null);
+      expect(v.loop).toBeFalse();
+    });
+
+    it('全是人类消息 → 连续数为 0', () => {
+      const msgs = [msg(HUMAN, 'a', 1), msg(HUMAN, 'b', 2)];
+      expect(detectLoop(msgs, isHuman, LOOP_GUARD_PRESETS.standard).streak).toBe(0);
+    });
+  });
+
+  describe('similarity 工具', () => {
+    it('完全相同 → 1', () => {
+      expect(similarity('收到', '收到')).toBe(1);
+    });
+
+    it('完全不相关 → 接近 0', () => {
+      expect(similarity('登录页样式', '后端接口校验')).toBeLessThan(0.2);
+    });
+
+    it('标点差异不影响相似度', () => {
+      expect(similarity('收到', '收到。')).toBeGreaterThan(0.9);
+    });
+  });
+
+  describe('🔴 真实协作场景(防止误伤正常对话)', () => {
+    it('人类下达任务、两容器交替干活、人类偶尔确认 → 不触发', () => {
+      const t0 = 5_000_000;
+      const seq: [string, string][] = [
+        [HUMAN, '登录页做好了吗,前端和后端对接一下'],
+        [A, '我这边表单校验写好了'],
+        [B, '接口我加了 /api/login,参数是 phone 和 code'],
+        [A, '收到,我把请求接上'],
+        [B, '联调通过,你可以试一下'],
+        [HUMAN, '好,我看看'],
+        [A, '顺便把错误提示也补了'],
+      ];
+      const msgs = seq.map(([sender, body], i) => msg(sender, body, t0 + i * 30_000));
+      // 逐条喂入,任何一步都不应触发
+      for (let i = 1; i <= msgs.length; i++) {
+        const v = detectLoop(msgs.slice(0, i), isHuman, LOOP_GUARD_PRESETS.standard, msgs[i - 1].ts);
+        expect(v.loop)
+          .withContext(`第 ${i} 条被误判:${v.detail}`)
+          .toBeFalse();
+      }
+    });
+
+    it('连续 5 条但每条内容不同(在干活)→ 不触发', () => {
+      const t0 = 6_000_000;
+      const five = [A, B, A, B, A].map((s, i) => msg(s, `第${i}条不同的工作内容`, t0 + i * 10_000));
+      expect(detectLoop(five, isHuman, LOOP_GUARD_PRESETS.standard, t0 + 50_000).loop).toBeFalse();
+    });
+
+    it('连续 5 条且内容重复(在打转)→ 触发', () => {
+      const t0 = 6_100_000;
+      const five = [A, B, A, B, A].map((s, i) => msg(s, '收到', t0 + i * 10_000));
+      expect(detectLoop(five, isHuman, LOOP_GUARD_PRESETS.standard, t0 + 50_000).loop).toBeTrue();
+    });
+  });
+});

+ 208 - 0
src/app/core/loop-guard.ts

@@ -0,0 +1,208 @@
+/**
+ * 自动对话回环检测 —— **纯函数**,不碰网络、不碰 DOM。
+ *
+ * ## 为什么需要(实测教训 2026-10-21)
+ *
+ * 两个容器在同一个群里能互相 @,但没有任何停止条件。实测一个测试群运行
+ * **28.6 分钟产生 96 条消息**:
+ *
+ * ```
+ * agent-lj  48 条
+ * lj-test   44 条
+ * 人类       4 条
+ *
+ * 最长连续 agent 消息:57 条(中间没有任何人类介入)
+ * ```
+ *
+ * 两个容器在反复「收到」「还在处理」「等你回复」——不是理论风险,是已经发生的事实。
+ *
+ * ## 设计原则:限制的是「失控循环」,不是「正常协作」
+ *
+ * | 场景 | 应该 | 判据 |
+ * |---|---|---|
+ * | A 问 B 一个具体问题,B 回答 | ✅ 正常 | 人类发起的对话 |
+ * | A 和 B 互相确认几十轮 | ❌ 刹车 | 连续 agent 消息无人类介入 |
+ *
+ * 所以三个信号互补,任一命中即暂停:
+ *   ① **连续数**:连续多少条没有人类发言(最准)
+ *   ② **速率**:短时间内消息暴涨(抓突发)
+ *   ③ **重复度**:内容高度相似(抓「复读机」)
+ */
+
+export type LoopGuardMode = 'strict' | 'standard' | 'loose' | 'off';
+
+/** 一回合消息的最小特征(与 Matrix 事件解耦,便于单测) */
+export interface LoopMessage {
+  /** 发送者 id(Matrix 的 @xxx:domain) */
+  sender: string;
+  /** 消息文本 */
+  body: string;
+  /** 毫秒时间戳 */
+  ts: number;
+}
+
+export interface LoopGuardConfig {
+  /** 连续多少条 agent 消息(无人类介入)触发 */
+  maxStreak: number;
+  /** 每分钟消息数上限 */
+  maxPerMinute: number;
+  /** 内容重复检测窗口 */
+  repeatWindow: number;
+}
+
+/** 四档预设 —— 用户可在设置里选 */
+export const LOOP_GUARD_PRESETS: Record<Exclude<LoopGuardMode, 'off'>, LoopGuardConfig> = {
+  strict: { maxStreak: 3, maxPerMinute: 5, repeatWindow: 3 },
+  standard: { maxStreak: 5, maxPerMinute: 10, repeatWindow: 3 },
+  loose: { maxStreak: 10, maxPerMinute: 20, repeatWindow: 4 },
+};
+
+export function configOf(mode: LoopGuardMode): LoopGuardConfig | null {
+  if (mode === 'off') return null;
+  return LOOP_GUARD_PRESETS[mode] ?? LOOP_GUARD_PRESETS.standard;
+}
+
+export interface LoopVerdict {
+  /** 是否判定为回环 */
+  loop: boolean;
+  /** 触发原因(便于审计与提示语) */
+  reason?: 'streak' | 'rate' | 'repeat';
+  /** 人类可读说明 */
+  detail?: string;
+  /** 连续 agent 消息数(诊断用) */
+  streak: number;
+  /** 最近一分钟消息数 */
+  perMinute: number;
+}
+
+/**
+ * 判定是否进入回环。
+ *
+ * @param messages 按时间**升序**排列(旧 → 新)
+ * @param isHuman 判断某条消息是否来自人类(非 agent)
+ */
+export function detectLoop(
+  messages: LoopMessage[],
+  isHuman: (sender: string) => boolean,
+  cfg: LoopGuardConfig | null,
+  now: number = Date.now(),
+): LoopVerdict {
+  if (!cfg || messages.length === 0) {
+    return { loop: false, streak: 0, perMinute: 0 };
+  }
+
+  // ① 连续数:从最后一条往前数,直到遇到人类消息
+  let streak = 0;
+  for (let i = messages.length - 1; i >= 0; i--) {
+    if (isHuman(messages[i].sender)) break;
+    streak++;
+  }
+
+  /*
+   * ①b **连续数只是必要条件,不是充分条件**(2026-10-21 实测修正)
+   *
+   * 踩坑:起初「连续 5 条 agent 消息」就判定回环,结果**误伤了正常干活**。
+   * 实测场景:用户让我改白名单,容器连发 7 条——
+   *     改配置 / diff 确认 / 重启 / 重启后验证 / 汇报…
+   * 这是正常的执行过程,不是失控循环。
+   *
+   * 真正的回环特征是**内容在原地打转**(「收到」「还在处理」「等你回复」反复出现),
+   * 而不是「条数多」。所以:
+   *   · 连续数达标 **且** 内容重复  → 回环
+   *   · 连续数达标 **但** 每条内容不同 → 是在干活,放过
+   */
+  const streakExceeded = streak >= cfg.maxStreak;
+  const repeatedOnStreak = streakExceeded && hasRepeat(messages, cfg.repeatWindow);
+
+  // ② 速率:最近一分钟内的消息数
+  const since = now - 60_000;
+  const perMinute = messages.filter((m) => m.ts >= since).length;
+
+  // ③ 重复度(独立于连续数)
+  const repeated = hasRepeat(messages, cfg.repeatWindow);
+
+  if (repeatedOnStreak) {
+    return {
+      loop: true,
+      reason: 'streak',
+      detail: `连续 ${streak} 条且内容高度重复,疑似自动循环`,
+      streak,
+      perMinute,
+    };
+  }
+  if (perMinute >= cfg.maxPerMinute) {
+    return {
+      loop: true,
+      reason: 'rate',
+      detail: `最近一分钟 ${perMinute} 条消息(阈值 ${cfg.maxPerMinute})`,
+      streak,
+      perMinute,
+    };
+  }
+  if (repeated) {
+    return {
+      loop: true,
+      reason: 'repeat',
+      detail: `最近 ${cfg.repeatWindow} 条内容高度重复`,
+      streak,
+      perMinute,
+    };
+  }
+  return { loop: false, streak, perMinute };
+}
+
+/**
+ * 最后 N 条是否内容高度重复。
+ *
+ * 用「去掉标点空白后的字符二元组相似度」,对「收到」「还在处理」「继续等待」
+ * 这类近似但不完全相同的复读也有效。
+ */
+function hasRepeat(messages: LoopMessage[], window: number): boolean {
+  if (messages.length < window || window < 2) return false;
+  const tail = messages.slice(-window).map((m) => normalize(m.body));
+  // 与第一条比,其余都高度相似 → 复读
+  const first = tail[0];
+  if (!first) return false;
+  return tail.slice(1).every((t) => similarity(first, t) >= 0.8);
+}
+
+function normalize(s: string): string {
+  return String(s || '')
+    .replace(/[\s\p{P}]/gu, '')
+    .slice(0, 200);
+}
+
+/**
+ * 字符串相似度,0~1。
+ *
+ * 🔴 先做规范化再比:直接用二元组 Dice 时,
+ *    「收到」vs「收到。」只有 0.67(短文本里标点占的权重过大),
+ *    会把「只是标点不同」误判成不相似。规范化之后相同 → 直接返回 1。
+ */
+export function similarity(a: string, b: string): number {
+  const na = normalize(a);
+  const nb = normalize(b);
+  if (!na || !nb) return 0;
+  if (na === nb) return 1;
+  a = na;
+  b = nb;
+  const grams = (s: string): Map<string, number> => {
+    const m = new Map<string, number>();
+    for (let i = 0; i < s.length - 1; i++) {
+      const g = s.slice(i, i + 2);
+      m.set(g, (m.get(g) ?? 0) + 1);
+    }
+    return m;
+  };
+  const ga = grams(a);
+  const gb = grams(b);
+  let inter = 0;
+  let total = 0;
+  for (const [g, n] of ga) {
+    total += n;
+    const m = gb.get(g);
+    if (m) inter += Math.min(n, m);
+  }
+  for (const n of gb.values()) total += n;
+  return total === 0 ? 0 : (2 * inter) / total;
+}

+ 28 - 0
src/app/desktop/pages/chat/chat.ts

@@ -34,6 +34,7 @@ import { DeviceService } from '../../../core/device.service';
 import { fmtPercent, fmtTokens, usageOf } from '../../../core/format';
 import { TaskLine, TaskPulseService } from '../../../core/task-pulse.service';
 import { ArtifactSyncService } from '../../../core/artifact-sync.service';
+import { LoopGuardService } from '../../../core/loop-guard.service';
 import { isToolActivityMessage, toolActivitySummary } from '../../../core/message-segments';
 import { Block } from '../../../core/message-segments';
 import { Message } from '../../../core/api/models';
@@ -254,6 +255,22 @@ type MessageRow =
             </div>
           }
 
+          <!--
+            回环提示(2026-10-21 P0 实测:两个容器能互相 @,但没有任何停止条件,
+            28 分钟内互发 96 条,最长连续 57 条无人类介入)。
+            这里给用户一个**明确的出口**:看一眼就知道为什么「不回消息了」,
+            发一句话或点按钮即可恢复。
+          -->
+          @if (loop.paused()) {
+            <div class="banner-hold">
+              <ui-alert type="warning" title="检测到自动对话循环,已暂停自动回复">
+                <p class="loop-detail">{{ loop.pauseDetail() }}</p>
+                <p class="loop-hint">你发一条消息即可恢复;或在设置里调整检测强度。</p>
+                <ui-button variant="outline" size="sm" (click)="loop.resume()">继续协作</ui-button>
+              </ui-alert>
+            </div>
+          }
+
           <div class="messages" #messages (scroll)="onMessagesScroll($event)">
             @switch (chat.state()) {
               @case ('loading') {
@@ -912,6 +929,16 @@ type MessageRow =
         color: var(--muted);
         flex-shrink: 0;
       }
+      .loop-detail {
+        margin: 0 0 var(--sp-1);
+        font-size: var(--fs-12);
+        color: var(--ink-2);
+      }
+      .loop-hint {
+        margin: 0 0 var(--sp-2);
+        font-size: var(--fs-12);
+        color: var(--muted);
+      }
       .banner-hold {
         padding: var(--sp-3) var(--sp-4) 0;
       }
@@ -1065,6 +1092,7 @@ export class DkChatPage {
   private readonly tasks = inject(TasksService);
   private readonly pulse = inject(TaskPulseService);
   private readonly artifactSync = inject(ArtifactSyncService);
+  readonly loop = inject(LoopGuardService);
   private readonly route = inject(ActivatedRoute);
   private readonly router = inject(Router);
   private readonly device = inject(DeviceService);