Bläddra i källkod

fix: 容器产出的文件也优先使用云盘链接 + 关闭自循环误报

## 云盘链接(核心)
容器发文件时同时做两件事:发 m.file 附件(mxc://)+ 正文写「已上云」s3 链接。
原代码 `if (!file)` 一刀切 —— 有附件就完全不看正文的云盘链接,
于是卡片/新标签/复制链接全走 /_matrix/media/...(用户实测反馈)。
现在:同一条消息里有 s3 链接就用它覆盖附件地址。
实测 10 条文件事件全部带 mxc://,其中 4 条是容器附件。

## 自循环误报
detectLoop 的第 ③ 条规则「尾部连续 N 条内容相似」独立于连续数判定,
容器连发几条结构相近的工具行就命中 → 正常干活频繁弹
「检测到自动对话循环,已暂停自动回复」。
- 默认模式改为 off(用户裁决:收益低于误报成本)
- 该规则收回到「连续数也达标」时才计入
- 补测试:连续 6 条相同内容仍能识别真回环

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct 2 timmar sedan
förälder
incheckning
5c4a4dc860

+ 34 - 7
src/app/core/chat.service.ts

@@ -3072,6 +3072,25 @@ export class ChatService {
        */
       let bodyText = m.body;
 
+      /*
+       * 🔴 带 Matrix 附件时,也要找同一条消息里的云盘链接(2026-11-13 用户实测)。
+       *
+       * 容器产出发文件时,**同时**做两件事:
+       *   ① 发 `m.file` 原生附件(`mxc://`,供 App 内联显示)
+       *   ② 在正文写「**已上云(可直接下载)** + https://s3.fmode.cn/...」
+       *
+       * 原实现 `if (!file)` 一刀切 —— 只要有附件就**完全不看正文的云盘链接**,
+       * 于是卡片/新标签/复制链接全走 `/_matrix/media/...`。
+       * 那个地址 ① 需要鉴权 ② Safari 兼容差 ③ 平台清理后就失效,
+       * 用户要的是**云盘公网链接**(`s3.fmode.cn`)。
+       *
+       * 现在:附件优先但**可被云盘链接覆盖** —— 同一条消息里有 s3 链接就用它。
+       */
+      const siblingCloud =
+        file != null
+          ? (extractDeliveredLinks(m.body)[0] ?? extractS3Links(m.body)[0])
+          : undefined;
+
       if (!file) {
         const delivered = extractDeliveredLinks(m.body);
         if (delivered.length > 0) {
@@ -3108,15 +3127,23 @@ export class ChatService {
               name: file.name,
               size: file.size,
               mimeType: file.mimeType,
-              url: file.url,
               /*
-               * `url` 已由 MatrixClientService 优先取事件里的云盘链接
-               * (`com.fmode.cloud_url`),所以这里直接复用。
-               * 内存 Map 只作老消息的兜底 —— 新消息不再依赖它。
+               * 🔴 同一条消息里若还有云盘链接 → **用它覆盖 Matrix 附件地址**
+               *    (2026-11-13 用户实测)。矩阵媒体地址需要鉴权、Safari 兼容差,
+               *    云盘地址才是用户要的「长期可访问」链接。
+               */
+              url: siblingCloud ?? file.url,
+              /*
+               * 云盘链接的确定顺序:
+               *   ① 同一条消息里的「已上云」链接(容器产出,最准)
+               *   ② 事件自带的 `com.fmode.cloud_url`(用户自己发的文件,已在 url 里)
+               *   ③ 内存 Map(老消息兜底,刷新即丢)
                */
-              cloudUrl: isCloudUrl(file.url)
-                ? file.url
-                : this._cloudUrls.get(this.cloudKeyOf(file.name, file.size)),
+              cloudUrl:
+                siblingCloud ??
+                (isCloudUrl(file.url)
+                  ? file.url
+                  : this._cloudUrls.get(this.cloudKeyOf(file.name, file.size))),
             }
           : linkCard,
         ts: new Date(m.ts).toISOString(),

+ 17 - 2
src/app/core/loop-guard.service.spec.ts

@@ -75,8 +75,16 @@ describe('LoopGuardService', () => {
     svc = TestBed.inject(LoopGuardService);
   });
 
-  it('默认是标准模式,未暂停', () => {
-    expect(svc.mode()).toBe('standard');
+  it('🔴 默认是 off 模式(2026-11-13 用户裁决:误报太多,彻底关闭)', () => {
+    expect(svc.mode()).toBe('off');
+    expect(svc.paused()).toBeFalse();
+  });
+
+  it('默认 off 时,即使连续 20 条相同内容也不暂停', () => {
+    chatStub.messages.and.returnValue(
+      Array.from({ length: 20 }, (_, i) => msg('agent', '收到', i)),
+    );
+    svc.reevaluate();
     expect(svc.paused()).toBeFalse();
   });
 
@@ -138,6 +146,13 @@ describe('LoopGuardService', () => {
    * 之间切换不会清暂停态 → 上个会话的判定串到下一个会话。
    */
   describe('🔴 按会话隔离', () => {
+    /*
+     * 🔴 显式打开检测(2026-11-13)。
+     *    默认模式已改为 `off`(误报太多),而本组用例测的是
+     *    「暂停态是否按会话隔离」——与默认值无关,必须自己开起来。
+     */
+    beforeEach(() => svc.setMode('standard'));
+
     /** s1:回环中 */
     const LOOPING = () => Array.from({ length: 8 }, (_, i) => msg('agent', '收到', i));
     /** s2:正常对话,不该暂停 */

+ 16 - 2
src/app/core/loop-guard.service.ts

@@ -26,8 +26,22 @@ import { LoopGuardMode, LoopMessage, LoopVerdict, configOf, detectLoop } from '.
  * 只做前端不够(容器不看界面);只做服务端也不够(要等轮询)。
  */
 
-/** 默认模式:标准档。用户可在设置里改。 */
-const DEFAULT_MODE: LoopGuardMode = 'standard';
+/**
+ * 默认模式:**关闭**(2026-11-13 用户裁决)。
+ *
+ * 🔴 原先默认 `standard`,但实测**正常干活也会误报**:
+ *    「检测到自动对话循环,已暂停自动回复」频繁出现在正常对话里,
+ *    用户反馈「我正常输出也会这样」。
+ *
+ * 根因是 `detectLoop()` 的第 ③ 条规则「尾部连续 N 条内容相似」——
+ * 容器连续输出几条结构相近的工具行(如多条 `💻 terminal ...`)
+ * 就能命中,完全不看中间有没有人类介入。
+ *
+ * 该判据**收益低于误报成本**:真正的失控循环由容器侧兜底,
+ * 前端这个横幅只会制造惊吓。因此默认关闭。
+ * 需要时可以 `setMode('standard')` 手动打开(当前设置页未暴露入口)。
+ */
+const DEFAULT_MODE: LoopGuardMode = 'off';
 
 @Injectable({ providedIn: 'root' })
 export class LoopGuardService {

+ 36 - 4
src/app/core/loop-guard.spec.ts

@@ -154,15 +154,28 @@ describe('loop-guard', () => {
   });
 
   describe('③ 内容重复检测', () => {
-    it('🔴 连续「收到」「收到。」「收到!」→ 判为复读', () => {
+    /*
+     * 🔴 2026-11-13 行为变更:**重复检测不再独立触发**。
+     *
+     * 原规则「尾部连续 N 条内容相似就算回环」,不看连续数、也不看人类是否介入。
+     * 结果容器连续输出几条结构相近的工具行就误报,用户反馈
+     * 「我正常输出也会这样」。现在重复只在「连续数也达标」时才计入。
+     */
+    it('🔴 只有 3 条重复、未达连续数阈值 → 不触发(旧行为会误报)', () => {
       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).withContext('未达连续数阈值,不应判回环').toBeFalse();
+    });
+
+    it('🔴 达到连续数阈值 + 内容重复 → 才判回环', () => {
+      const msgs = Array.from({ length: 6 }, (_, i) => msg(A, `收到${'。'.repeat(i)}`, i));
+      const v = detectLoop(msgs, isHuman, LOOP_GUARD_PRESETS.standard);
       expect(v.loop).toBeTrue();
-      expect(v.reason).toBe('repeat');
+      expect(v.reason).toBe('streak');
     });
 
     it('内容各不相同的三条 → 不触发', () => {
@@ -305,7 +318,7 @@ describe('loop-guard', () => {
       expect(v.loop).withContext(v.detail ?? '').toBeFalse();
     });
 
-    it('agent 连续 3 条「收到」仍然触发(收紧口径没有放过真回环)', () => {
+    it('agent 连续 3 条「收到」未达阈值 → 不触发(2026-11-13 收紧口径)', () => {
       const t0 = 7_400_000;
       const msgs: LoopMessage[] = [
         { sender: HUMAN, body: '你好', ts: t0, fromMe: true },
@@ -314,8 +327,27 @@ describe('loop-guard', () => {
         { sender: A, body: '收到!', ts: t0 + 3000, fromMe: false },
       ];
       const v = detectLoop(msgs, isHuman, LOOP_GUARD_PRESETS.standard, t0 + 3000);
+      /*
+       * standard 的 maxStreak=5,这里只有 3 条连续 → 不达阈值。
+       * 旧逻辑会因「内容重复」独立触发而误报。
+       */
+      expect(v.loop).toBeFalse();
+    });
+
+    it('agent 连续 6 条相同内容 → 仍能识别(真回环不放过)', () => {
+      const t0 = 7_400_000;
+      const msgs: LoopMessage[] = [
+        { sender: HUMAN, body: '帮我改配置', ts: t0, fromMe: true },
+        ...Array.from({ length: 6 }, (_, i) => ({
+          sender: A,
+          body: '收到',
+          ts: t0 + 1000 * (i + 1),
+          fromMe: false as const,
+        })),
+      ];
+      const v = detectLoop(msgs, isHuman, LOOP_GUARD_PRESETS.standard, t0 + 7000);
       expect(v.loop).toBeTrue();
-      expect(v.reason).toBe('repeat');
+      expect(v.reason).toBe('streak');
     });
 
     it('保护期内(quiet=true)不触发,但诊断值照常返回', () => {

+ 14 - 2
src/app/core/loop-guard.ts

@@ -148,8 +148,20 @@ export function detectLoop(
     (m) => m.ts >= since && !isFromMe(m, isHuman),
   ).length;
 
-  // ③ 重复度(独立于连续数,但同样只在连续 agent 段内看)
-  const repeated = hasRepeat(tail, cfg.repeatWindow);
+  /*
+   * ③ 重复度 —— **必须同时满足连续数达标**(2026-11-13 修正误报)。
+   *
+   * 🔴 原来这条**独立于 `streakExceeded`** 单独判定:
+   *    只要尾部连续 N 条内容相似就报警,不看中间有没有人类介入、
+   *    也不看条数是否够。结果容器连续输出几条结构相近的工具行
+   *    (多条 `💻 terminal ...`)就命中 → 正常干活时频繁弹
+   *    「检测到自动对话循环,已暂停自动回复」(用户实测反馈)。
+   *
+   *    这与本函数开头 §①b 的设计原则自相矛盾:
+   *    「连续数达标 **且** 内容重复 → 回环;内容不同 → 是在干活,放过」。
+   *    现在把它收回到同一条判据上。
+   */
+  const repeated = repeatedOnStreak;
 
   // 保护期:诊断值照常返回,但一律不触发
   if (opts.quiet) {