Просмотр исходного кода

fix: PC 端无限闪烁 + 180 次请求风暴(均为我上一轮引入)

🔴 闪烁:上一轮加的「:id 缺失就跳根页面」与 /desktop/chat 落地页
互跳成死循环(/ → /desktop/chat → / → …)。Playwright 实测导航
序列循环 10+ 次后卡在「正在进入」。
修复:删除该分支,只保留「id 存在且容器列表已加载且容器不存在 → 回 /」。

🔴 请求风暴:syncPending 把「有 source 无 url」的附件全部入队,
某账号 180 条历史成果的 source 是容器临时路径(/tmp/_fmode_outbound_*),
源文件早已不存在,首屏第 0 秒一次性 180 次 enqueueArtifact,
容器读不到 → 卡 pending → 反复 504。
修复:新增 isDeadSource() 过滤 /tmp/、_fmode_outbound_/_fmode_managed_、
非绝对路径的 source。实测 180 次 → 1 次。

附带:measureOverflow 的写信号放进 untracked,切断渲染自触发。

验证:tsc ✅ / 1780 SUCCESS / build ✅ / 线上 main-TNSK6PG5.js 一致

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct 12 часов назад
Родитель
Сommit
6644301187

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

@@ -8213,3 +8213,18 @@ URL 不会重复显示:`chat.service.ts:2349` 的 `stripLinks` 会把已提取
 | **踩坑** | Angular 模板注释里**不能出现反引号**(`\``),会把注释内容当表达式解析,报 `TS2554: Expected 1 arguments, but got 4` / `TS2349: not callable` |
 | **踩坑** | Angular 模板注释里**不能出现反引号**(`\``),会把注释内容当表达式解析,报 `TS2554: Expected 1 arguments, but got 4` / `TS2349: not callable` |
 | **测试** | 改写 2 条旧断言(hydrate 现在抛出而非置错误态);新增 2 条:`reloadAll` 部分失败仍可用、全部失败才进错误态 |
 | **测试** | 改写 2 条旧断言(hydrate 现在抛出而非置错误态);新增 2 条:`reloadAll` 部分失败仍可用、全部失败才进错误态 |
 | **验证** | `tsc --noEmit` ✅ / `npm test` **1780 SUCCESS** / `npm run build` ✅ / 线上 `main-UWK5XTFI.js` 与本地一致 / CDN 刷新任务 `8190276258` |
 | **验证** | `tsc --noEmit` ✅ / `npm test` **1780 SUCCESS** / `npm run build` ✅ / 线上 `main-UWK5XTFI.js` 与本地一致 / CDN 刷新任务 `8190276258` |
+
+## 2026-11-14(续七)· PC 端无限闪烁(我引入)+ 180 次请求风暴
+
+| 项 | 内容 |
+|---|---|
+| **用户反馈** | PC 端报错、页面一直闪烁,mobile 端正常 |
+| **🔴 闪烁根因(我引入)** | 上一轮我给 `onRouteChange` 加了「`:id` 缺失 → 跳回根页面」。但 `/desktop/chat`(不带 id)**本身就是合法路由**(「开始一段新对话」落地页)→ 入口页 `/` 跳 `/desktop/chat`,chat 又跳回 `/`,**无限互跳**。Playwright 实测导航序列:`/ → /desktop/chat → / → /desktop/chat …` 循环 10+ 次后卡在「正在进入」 |
+| **闪烁修复** | 删除「`!id` 跳根页面」分支;只保留「`id` 存在**且**容器列表已加载**且**容器不存在 → 回 `/`」 |
+| **🔴 请求风暴根因** | `syncPending()` 把「有 source 无 url」的附件全部入队。某账号下有 **180 条历史成果**,其 `artifacts.source` 是容器临时路径 `/tmp/_fmode_outbound_99233_msg-20.md`,源文件早已不存在。首屏**第 0 秒一次性 180 次** `enqueueArtifact` → 容器读不到文件 → 卡 pending → 反复 504 |
+| **风暴实测** | 原代码 10 秒 60 次 → 我的改动后 187 次 → 定位为 180 个**不同** deliverableId 集中爆发(非死循环) |
+| **风暴修复** | 新增 `isDeadSource()`:跳过 `/tmp/` 开头、含 `_fmode_outbound_`/`_fmode_managed_`、非绝对路径的 source。实测 180 次 → **1 次** |
+| **与容器日志的呼应** | 容器报告的 `pendingArtifacts 无任务`(7 个 deliverableId)正是这类僵尸条目造成的 |
+| **附带修复** | `measureOverflow()` 的写信号放进 `untracked`,切断「渲染→测溢出→写信号→再渲染」 |
+| **我的误判** | 期间多次把故障归因于「后端 504 抖动」。实际后端在抖动**属实**(实测 504/502/200 交替),但**闪烁和请求风暴是代码问题**,用户判断正确 |
+| **验证** | `tsc --noEmit` ✅ / `npm test` **1780 SUCCESS** / `npm run build` ✅ / 线上 `main-TNSK6PG5.js` 与本地一致 / CDN 刷新 `8190635092` |

+ 41 - 15
src/app/components/domain/session-tree/session-tree.ts

@@ -1,7 +1,7 @@
 import { NgTemplateOutlet } from '@angular/common';
 import { NgTemplateOutlet } from '@angular/common';
 import { ChangeDetectionStrategy, Component, OnDestroy, computed, inject, input, output, signal } from '@angular/core';
 import { ChangeDetectionStrategy, Component, OnDestroy, computed, inject, input, output, signal } from '@angular/core';
 import { NavigationEnd, Router, RouterLink } from '@angular/router';
 import { NavigationEnd, Router, RouterLink } from '@angular/router';
-import { effect } from '@angular/core';
+import { effect, untracked } from '@angular/core';
 import { UiAvatar, UiButton, UiEmpty, UiInput, UiModal, UiSkeleton, UiSwitch, UiTooltip } from '../../../ui';
 import { UiAvatar, UiButton, UiEmpty, UiInput, UiModal, UiSkeleton, UiSwitch, UiTooltip } from '../../../ui';
 import { navItemActive } from '../../../desktop/shell/dk-top-nav/dk-top-nav';
 import { navItemActive } from '../../../desktop/shell/dk-top-nav/dk-top-nav';
 import { ChatSession, groupOf } from '../../../core/chat-session';
 import { ChatSession, groupOf } from '../../../core/chat-session';
@@ -274,6 +274,14 @@ export interface NewSessionRequest {
 
 
                 @if (isWsOpen(w.id)) {
                 @if (isWsOpen(w.id)) {
                   <div class="ws-body">
                   <div class="ws-body">
+                    <!-- 2026-11-14:加载完成前不渲染条目,避免与骨架/其它分组混排 -->
+                    @if (!sessionsReady()) {
+                      <div class="skel" role="status" aria-label="正在加载对话">
+                        @for (i of [1, 2]; track i) {
+                          <ui-skeleton variant="rect" height="32px" />
+                        }
+                      </div>
+                    } @else {
                     @if (sessionsIn(w.id).length === 0) {
                     @if (sessionsIn(w.id).length === 0) {
                       <p class="sect-hint inset">还没有对话</p>
                       <p class="sect-hint inset">还没有对话</p>
                     }
                     }
@@ -305,6 +313,7 @@ export interface NewSessionRequest {
                         }
                         }
                       }
                       }
                     }
                     }
+                    }
                   </div>
                   </div>
                 }
                 }
               </div>
               </div>
@@ -470,23 +479,30 @@ export interface NewSessionRequest {
                 </div>
                 </div>
               }
               }
 
 
-              @for (s of daily(); track s.id) {
-                <ng-container *ngTemplateOutlet="row; context: { $implicit: s, owner: null }"></ng-container>
-              }
-
               <!--
               <!--
-                孤儿归属兜底(2026-11-01):归属于某个已不存在的工作区。
-                不单独列出的话,这些会话在界面上彻底消失,用户会以为对话丢了。
-                点开仍可正常进入,移动工作区即可修正归属。
+                2026-11-14:真实条目必须等加载完成才渲染。
+                原来 daily 列表的 for 在这个 if 外面,无条件渲染 ——
+                于是骨架屏和真实列表同时出现(用户截图实证)。
+                现在整块包在 sessionsReady 里,加载期间只有骨架。
               -->
               -->
-              @if (orphanSessions().length > 0) {
-                <p class="sect-hint orphan-hint">
-                  以下 {{ orphanSessions().length }} 条对话所属的工作区已不存在,可移动到其它工作区:
-                </p>
-                @for (s of orphanSessions(); track s.id) {
+              @if (sessionsReady()) {
+                @for (s of daily(); track s.id) {
                   <ng-container *ngTemplateOutlet="row; context: { $implicit: s, owner: null }"></ng-container>
                   <ng-container *ngTemplateOutlet="row; context: { $implicit: s, owner: null }"></ng-container>
                 }
                 }
-              }
+
+                <!--
+                  孤儿归属兜底(2026-11-01):归属于某个已不存在的工作区。
+                  不单独列出的话,这些会话在界面上彻底消失,用户会以为对话丢了。
+                  点开仍可正常进入,移动工作区即可修正归属。
+                -->
+                @if (orphanSessions().length > 0) {
+                  <p class="sect-hint orphan-hint">
+                    以下 {{ orphanSessions().length }} 条对话所属的工作区已不存在,可移动到其它工作区:
+                  </p>
+                  @for (s of orphanSessions(); track s.id) {
+                    <ng-container *ngTemplateOutlet="row; context: { $implicit: s, owner: null }"></ng-container>
+                  }
+                }
 
 
               @if (dailyArchived().length > 0) {
               @if (dailyArchived().length > 0) {
                 <button
                 <button
@@ -509,6 +525,7 @@ export interface NewSessionRequest {
                   }
                   }
                 }
                 }
               }
               }
+              }
             </div>
             </div>
           }
           }
         </section>
         </section>
@@ -2119,11 +2136,20 @@ export class SessionTreeComponent implements OnDestroy {
      */
      */
     this.win.addEventListener('focus', this.onWindowFocus);
     this.win.addEventListener('focus', this.onWindowFocus);
     // 数据/尺寸变化后重测溢出(工作区与会话列表都是信号,变化即重算)
     // 数据/尺寸变化后重测溢出(工作区与会话列表都是信号,变化即重算)
+    /*
+     * 🔴 2026-11-14:**只追踪数据信号,写入放到 untracked**。
+     *
+     * `measureOverflow()` 内部有 3 处 `signal.set()`。虽然它用 queueMicrotask
+     * 延后执行,但微任务仍在同一变更检测周期内 —— 加骨架屏后渲染时序变了,
+     * 实测被放大成 10 秒 187 次 /api 请求(原代码 60 次)。
+     *
+     * 这里显式 `untracked` 包住写入口,切断「渲染 → 测溢出 → 写信号 → 再渲染」。
+     */
     effect(() => {
     effect(() => {
       this.workspaces();
       this.workspaces();
       this.sessionsSvc.all();
       this.sessionsSvc.all();
       this.dailyOpen();
       this.dailyOpen();
-      this.measureOverflow();
+      untracked(() => this.measureOverflow());
     });
     });
     if (typeof window !== 'undefined') {
     if (typeof window !== 'undefined') {
       window.addEventListener('resize', () => this.measureOverflow());
       window.addEventListener('resize', () => this.measureOverflow());

+ 36 - 0
src/app/core/artifact-sync.service.ts

@@ -24,6 +24,29 @@ import { Deliverable } from './api/models';
  * 现在是:**密钥只存在服务端一处**,浏览器全程不接触。
  * 现在是:**密钥只存在服务端一处**,浏览器全程不接触。
  * 代价只是每个附件多一次云函数调用。
  * 代价只是每个附件多一次云函数调用。
  */
  */
+/**
+ * 「这个 source 还可能是容器上真实存在的文件吗」(2026-11-14)
+ *
+ * 用于过滤掉**永远不可能搬运成功**的历史条目。实测某账号有 180 条这样的,
+ * 首屏一次性全部入队 → 容器读不到文件 → 队列塞满 + 反复 504。
+ *
+ * 判据刻意保守(宁可不搬,也不制造无效任务):
+ *   · `/tmp/` 下的文件:容器的上传临时副本,按设计就是「传完即弃」
+ *   · 文件名含 `_fmode_outbound_` / `_fmode_managed_`:内部临时命名
+ *   · 相对路径:容器按绝对路径读文件,相对路径不可能命中
+ */
+export function isDeadSource(source: string): boolean {
+  const s = (source || '').trim();
+  if (!s) return true;
+  /* 相对路径 → 容器不认 */
+  if (!s.startsWith('/')) return true;
+  /* 容器的临时中转目录 */
+  if (s.startsWith('/tmp/')) return true;
+  /* 内部临时命名(fmode 自己的中转产物) */
+  if (/\/_fmode_(outbound|managed)_/i.test(s)) return true;
+  return false;
+}
+
 @Injectable({ providedIn: 'root' })
 @Injectable({ providedIn: 'root' })
 export class ArtifactSyncService {
 export class ArtifactSyncService {
   private readonly api = inject(FmodeApi);
   private readonly api = inject(FmodeApi);
@@ -44,12 +67,25 @@ export class ArtifactSyncService {
   /**
   /**
    * 扫描一批成果,把「有 source、没 url」的附件排进搬运队列。
    * 扫描一批成果,把「有 source、没 url」的附件排进搬运队列。
    * 幂等:同一成果同一附件只处理一次。返回本次**新入队**数量。
    * 幂等:同一成果同一附件只处理一次。返回本次**新入队**数量。
+   *
+   * 🔴 2026-11-14:**过滤掉永远不可能成功的僵尸条目**。
+   *
+   * 实测:某账号下有 180 条历史成果,`artifacts` 里存的是容器内的临时路径
+   * (如 `/tmp/_fmode_outbound_99233_msg-20.md`),源文件早已不存在。
+   * 原实现会在页面首屏把它们**一次性全部入队**(第 0 秒 180 次请求),
+   * 容器领到后读不到文件 → 卡 pending → 反复 504,并把平台队列塞满。
+   *
+   * 现在跳过以下几类(它们的 `source` 不可能是容器上真实存在的交付物):
+   *   · `/tmp/` 下的临时中转文件(容器的上传临时副本,本就不该由 App 搬运)
+   *   · 带 `_fmode_outbound_` / `_fmode_` 前缀的内部临时名
+   *   · 明显不是绝对路径的(容器按绝对路径读文件)
    */
    */
   async syncPending(list: Deliverable[]): Promise<number> {
   async syncPending(list: Deliverable[]): Promise<number> {
     const jobs: { d: Deliverable; source: string; name: string; index: number }[] = [];
     const jobs: { d: Deliverable; source: string; name: string; index: number }[] = [];
     for (const d of list) {
     for (const d of list) {
       d.artifacts.forEach((a, index) => {
       d.artifacts.forEach((a, index) => {
         if (a.url || !a.source) return;
         if (a.url || !a.source) return;
+        if (isDeadSource(a.source)) return;
         const key = this.keyOf(d.id, a.source);
         const key = this.keyOf(d.id, a.source);
         if (this.done.has(key) || this.inFlight.has(key)) return;
         if (this.done.has(key) || this.inFlight.has(key)) return;
         // 失败过的项按退避重试(2026-11-07)
         // 失败过的项按退避重试(2026-11-07)

+ 11 - 14
src/app/desktop/pages/chat/chat.ts

@@ -3104,19 +3104,14 @@ export class DkChatPage implements OnDestroy {
     /*
     /*
      * 🔴 参数校验(2026-11-14 用户要求「需要携带参数但没有携带就跳回根页面」)。
      * 🔴 参数校验(2026-11-14 用户要求「需要携带参数但没有携带就跳回根页面」)。
      *
      *
-     * 三种情况:
-     *   ① `:id`(容器)缺失      → 回根页面(由根路由分流到默认端首页)
-     *   ② `:id` 指向不存在的容器 → 同上
-     *   ③ `:sessionId` 指向不存在的会话 → 回落成「打开该容器最近一条」
+     * ⚠️ **`:id` 缺失不是错误**(2026-11-14 修正,用户实测死循环):
+     *    `/desktop/chat` 本身就是合法路由 —— 它是「开始一段新对话」落地页。
+     *    我最初在这里写「无 id 就跳根页面」,结果 `/` ↔ `/desktop/chat`
+     *    无限互跳(入口页跳 chat,chat 跳回根,入口页再跳 chat…),
+     *    表现为「页面一直在闪烁、停在正在进入」。
      *
      *
-     * ①② 在数据加载完之后才能判断(容器列表要等 `ensureLoaded`),
-     *   所以放在 `Promise.all` 之后。③ 沿用原有逻辑。
+     *    现在只保留一条:`:id` **存在但指向不存在的容器** → 回根页面。
      */
      */
-    if (!id) {
-      void this.router.navigateByUrl('/', { replaceUrl: true });
-      return;
-    }
-
     this.sessions.load();
     this.sessions.load();
     await Promise.all([
     await Promise.all([
       this.live.ensureLoaded(),
       this.live.ensureLoaded(),
@@ -3128,10 +3123,12 @@ export class DkChatPage implements OnDestroy {
     /*
     /*
      * 容器不存在 → 回根页面。
      * 容器不存在 → 回根页面。
      *
      *
-     * ⚠️ 只在**容器列表确实加载出来了**时才判 —— 否则网络慢的情况下
-     * 会把合法链接误判成失效,用户一刷新就被踢回首页。
+     * ⚠️ 两个前提同时满足才判:
+     *   ① `id` 确实存在(无 id 是合法的落地页,不跳)
+     *   ② 容器列表**确实加载出来了** —— 否则网络慢会把合法链接误判失效,
+     *      用户一刷新就被踢回首页。
      */
      */
-    if (this.live.agents().length > 0 && !this.live.agentById(id)) {
+    if (id && this.live.agents().length > 0 && !this.live.agentById(id)) {
       void this.router.navigateByUrl('/', { replaceUrl: true });
       void this.router.navigateByUrl('/', { replaceUrl: true });
       return;
       return;
     }
     }