Explorar el Código

fix(app): 刷新误报不存在 + usage 未映射 + 成果空字段 + 手册补自检

① 刷新误报「数字生命不存在」
   LiveService.ensureLoaded 在 loading 时直接 return 不等,
   聊天页与 shell 启动加载并发时后到者拿 undefined → 判不存在
   → 复用 in-flight Promise;chat.open 区分
     加载失败 / 加载中 / 确实不存在 三种情况

② Token 用量永远空白
   mapAgent 完全没有映射 usage 字段(云函数返回了、映射层丢了)
   → 补映射 + 兼容 inputTokens/outputTokens 与 promptTokens/completionTokens

③ 成果显示「· 」(「仅有 · 的文档」)
   mapDeliverable 中 filename/fileType/size 从未赋值,只有 demo-data 有
   → 从 artifacts[0] 推导;模板无附件显示「暂无附件」

④ 补全另 3 个未映射字段
   containerRef / sessionStatus / usageSessions

⑤ 手册新增「上线前自检」章节
   自检脚本(只读)+ 结果分流四条路径
   标注最易误判情况:无进程但数据在跳 = 写入方在别处

未做:currentTask 报英文属容器数据,App 不伪造翻译

验证:ng test 510 / build / verify:ui 11 / verify:docs 49

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct hace 1 semana
padre
commit
ee3a1a08c2

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

@@ -887,3 +887,17 @@
 | **新增反模式(§1 第 3 步)** | 「`while true` 循环」标注为**陷阱**:随会话死、重启就丢(lj-test 实测,gateway 重启带走上报进程,停 15 分钟无人发现)|
 | **旧文档处置** | `reporter部署方案-20261020.md`、`容器上报标准部署包-20261020.md` 顶部加「已被取代」指引,保留供追溯 |
 | 验证证据 | `verify:docs` **49 文件**(+1)/ 表格结构全部正常 |
+
+### 2026-10-20 — App 端四处数据/状态缺陷修复 + 手册补自检章节
+
+| 项 | 内容 |
+|---|---|
+| **用户反馈** | ① 刷新时显示「数字生命不存在」「消息加载失败」② Token 用量不同步 ③ 当前任务是英文 ④ 成果显示「仅有 · 的文档」⑤ 新容器部署要先自检再操作 |
+| **🔴 缺陷①:刷新误报「不存在」** | `LiveService.ensureLoaded()` 原实现 `if (ready \|\| loading) return` —— **`loading` 时直接返回不等**。聊天页 `onRouteChange` 与 shell 启动加载并发时,后到者拿到 `undefined` → `chat.open()` 判「数字生命不存在」(实测复现)<br>**修复**:① `ensureLoaded` 复用同一个 in-flight Promise,并发调用共享结果<br>② `chat.open()` 区分三种「取不到」:加载失败→报错并给重试 / 加载中→报「请稍候」/ 确实没有→才报「不存在」|
+| **🔴 缺陷②:Token 用量永远空白** | `mapAgent()` 中 **`usage` 字段完全没有映射** —— 云函数返回了(实测 `totalTokens=2238055`),映射层丢掉了<br>**修复**:补映射 + 键名兼容(容器发 `inputTokens/outputTokens`,契约旧字段是 `promptTokens/completionTokens`)|
+| **🔴 缺陷③:当前任务英文** | `currentTask` 是容器自报原文(`periodic status reporter (xlosi3m46q)`),App 不翻译是对的。<br>**本轮处置**:不硬编码翻译(等于伪造数据),保留原文展示。建议容器侧改报业务语义 → 已记录,待容器侧处理 |
+| **🔴 缺陷④:「仅有 · 的文档」** | `mapDeliverable()` 中 `filename` / `fileType` / `size` **从未赋值**(只在 demo-data 里有)→ 模板渲染 `{{ d.filename }} · {{ d.size }}` 变成 `· `<br>**修复**:① 映射层从 `artifacts[0]` 推导 `filename`/`fileType`<br>② 模板:无附件显示「暂无附件」,有附件才拼 `· size` |
+| **✏️ 补全未映射字段** | 排查中发现另有 3 个云函数字段未映射,一并补上:`containerRef`(排障反查来源)、`sessionStatus`(token 健康灯)、`usageSessions`(会话级用量明细)|
+| **✏️ 手册补「上线前自检」章节** | 《容器接入上线手册》新增 §一:**自检脚本(只读)** + 自检结果 → 四条分流路径。<br>核心原则「先自检 → 按结果选路径 → 才动手」,并标注最易误判的第 3 种情况(无进程但数据在跳 = 写入方在别处,别装第二份)|
+| 验证证据 | `ng test` **510**(+7 用例)/ `build` 通过 / `verify:ui` **11/11** / `verify:docs` **49 文件** |
+| 未做(需容器侧) | `currentTask` 报英文属容器数据问题,App 不伪造翻译 |

+ 76 - 10
docs/03-interface/容器接入上线手册-20261020.md

@@ -33,7 +33,73 @@
 
 ---
 
-## 一、上线三步(照做即可)
+## 一、上线前自检(先跑这个,再动手)
+
+> 🔴 **顺序不能颠倒**:先自检 → 按结果选路径 → 才操作。
+> 本轮所有事故,一半以上是"没自检就动手"造成的。
+
+### 自检脚本(复制粘贴即可)
+
+```bash
+#!/bin/sh
+# 容器接入前自检 —— 只读,不改任何东西
+AGENT_ID="${AGENT_ID:-$(hostname)}"
+echo "=== 0. 基本信息 ==="
+echo "  hostname : $(hostname)"
+echo "  AGENT_ID : $AGENT_ID"
+echo "  时间     : $(date -Is)"
+
+echo "=== 1. 有没有上报进程 ==="
+PROC=$(ps aux | grep -iE "status-report|status_report" | grep -v grep)
+if [ -n "$PROC" ]; then
+  echo "  ✅ 有进程:"; echo "$PROC" | head -3
+  PID=$(echo "$PROC" | awk '{print $2}' | head -1)
+  echo "  真实脚本: $(tr '\0' ' ' < /proc/$PID/cmdline)"
+  echo "  工作目录: $(readlink /proc/$PID/cwd)"
+else
+  echo "  ❌ 无上报进程"
+fi
+
+echo "=== 2. 关键环境变量(读实际生效值)==="
+for k in AGENT_ID FMODE_SESSION_TOKEN AGENT_FUNCTION_ID WECOM_BOT_ID; do
+  v=$(printenv "$k" 2>/dev/null || grep -E "^${k}=" /opt/data/.env 2>/dev/null | cut -d= -f2-)
+  [ -n "$v" ] && echo "  ✅ $k = ${v:0:12}..." || echo "  ❌ $k 未配置"
+done
+
+echo "=== 3. 脚本在不在 ==="
+for p in /opt/data/git-repos/agent-status-reporter/scripts/status-report.py; do
+  [ -f "$p" ] && echo "  ✅ $p ($(wc -l < "$p") 行)" || echo "  ❌ $p 不存在"
+done
+
+echo "=== 4. 平台侧有没有记录 ==="
+curl -s "https://server.fmode.cn/parse/classes/FmodeAgent?where={\"agentId\":\"$AGENT_ID\"}" \
+  -H 'X-Parse-Application-Id: ncloudmaster' \
+  -o /tmp/fmode-agent.json
+grep -o '"updatedAt":"[^"]*"' /tmp/fmode-agent.json | head -1 || echo "  ❌ 无记录"
+
+echo "=== 5. Matrix 侧(如已接入)==="
+grep -iE "initial sync complete" /opt/data/logs/gateway.log 2>/dev/null | tail -1
+
+echo "=== 自检结束 ==="
+```
+
+### 自检结果 → 走哪条路
+
+| 自检结果 | 说明 | 走 |
+|---|---|---|
+| 1 有进程 + 4 有记录且在 5 分钟内 | ✅ 已在正常上报 | 跳到 §三 查格式 |
+| 1 有进程 + 4 无记录/停更 | 在跑但上报失败 | §5.1 |
+| 1 无进程 + 4 有记录且在更新 | ⚠️ **数据不是这台写的** | §5.2 |
+| 1 无进程 + 4 停更 | ❌ 死了 | **§二 从零部署** |
+
+> ⚠️ **第 3 种情况最容易误判**:容器里没有进程,但平台数据在跳 ——
+> 说明写入方在别处。别急着装脚本,先按 §5.2 查清来源,
+> 否则会装出第二份上报(格式必然不同,重演 `cny` 的坑)。
+
+---
+
+## 二、上线三步(自检不过才走这里)
+
 
 ### 第 1 步 · 确认存活(最重要,先做这个)
 
@@ -57,8 +123,8 @@ curl -s 'https://server.fmode.cn/parse/classes/FmodeAgent?where={"agentId":"<容
 | 现象 | 结论 | 下一步 |
 |---|---|---|
 | ①② 有,③ 有且 `updatedAt` 在 5 分钟内 | ✅ 活着 | 跳到第 2 步查格式 |
-| ①② 有,③ 无记录 | 在跑但上报失败 | 查 token / agentId / 云函数 id(见 §4.1)|
-| ①② 无,③ 有记录且在更新 | ⚠️ **数据不是这个进程写的** | 查 `host.hostname` 反查真实来源(见 §4.2)|
+| ①② 有,③ 无记录 | 在跑但上报失败 | 查 token / agentId / 云函数 id(见 §5.1)|
+| ①② 无,③ 有记录且在更新 | ⚠️ **数据不是这个进程写的** | 查 `host.hostname` 反查真实来源(见 §5.2)|
 | ①② 无,③ 停更超过 24 小时 | ❌ 死了 | 从第 2 步开始部署 |
 
 ---
@@ -137,7 +203,7 @@ sqlite3 <hermes>/executions.db "SELECT * FROM executions ORDER BY started_at DES
 
 ---
 
-## 二、验收清单(9 条,缺一不可)
+## 三、验收清单(9 条,缺一不可)
 
 ```bash
 curl -s 'https://server.fmode.cn/parse/classes/FmodeAgent?where={"agentId":"<容器名>"}' \
@@ -158,7 +224,7 @@ curl -s 'https://server.fmode.cn/parse/classes/FmodeAgent?where={"agentId":"<容
 
 ---
 
-## 三、🔴 四条铁律(本轮全部踩过)
+## 四、🔴 四条铁律(本轮全部踩过)
 
 ### 铁律 1 · 查运行态,不查路径
 
@@ -225,7 +291,7 @@ lastInboundAgeSecs  = 业务活跃度(只展示,不参与健康判断)
 
 ---
 
-## 四、故障速查表
+## 五、故障速查表
 
 ### 4.1 上报返回 `agent not found` / 数据不落库
 
@@ -300,11 +366,11 @@ grep -iE "inbound message" /opt/data/logs/gateway.log | tail -10
 
 **原因**:上报是 `while true` shell 循环起的,进程组随 gateway 一起死。
 
-**修复**:改用 Hermes 原生 cron 或 s6(见 §1 第 3 步)。
+**修复**:改用 Hermes 原生 cron 或 s6(见 §二 第 3 步)。
 
 ---
 
-## 五、给容器的标准操作提示词
+## 六、给容器的标准操作提示词
 
 ```markdown
 【容器状态上报 · 标准接入】
@@ -356,7 +422,7 @@ heartbeatAt=<回读> usage.totalTokens=<回读> balance.remainCny=<回读>
 
 ---
 
-## 六、上线检查表(复制给执行人)
+## 七、上线检查表(复制给执行人)
 
 ```markdown
 □ 存活三查(ps / proc / 平台回读)已做
@@ -373,7 +439,7 @@ heartbeatAt=<回读> usage.totalTokens=<回读> balance.remainCny=<回读>
 
 ---
 
-## 七、关联文档
+## 八、关联文档
 
 - `docs/03-interface/reporter字段规范-20261020.md` —— 逐字段契约(改脚本时查)
 - `docs/03-interface/容器初始化流程优化建议-20261020.md` —— workshop 流程优化 + 方法论

+ 29 - 0
src/app/core/api/models.ts

@@ -74,6 +74,23 @@ export interface Usage {
   raw?: Record<string, unknown>;
 }
 
+/** 单次会话的 token 用量(云函数 `usageSessions` 元素,契约见 reporter 字段规范 §3.4) */
+export interface UsageSession {
+  sessionId?: string;
+  title?: string;
+  model?: string;
+  messageCount?: number;
+  toolCallCount?: number;
+  inputTokens?: number;
+  outputTokens?: number;
+  cacheReadTokens?: number;
+  reasoningTokens?: number;
+  estimatedCostUsd?: number;
+  costStatus?: string;
+  /** epoch 数字(服务端不转换) */
+  startedAt?: number;
+}
+
 export interface Person {
   id: string;
   name: string;
@@ -102,8 +119,20 @@ export interface Agent {
   balance?: Balance;
   channels?: ChannelConfig;
   usage?: Usage;
+  /**
+   * 会话级用量明细(云函数 `usageSessions`,JSON 字符串形态)。
+   * 用于「Token 用量」下钻,缺失时不影响总量展示。
+   */
+  usageSessions?: UsageSession[];
   owner?: Person;
   companyId?: string;
+  /** 容器标识(云函数 `containerRef`,Docker 短 ID / 主机名,排障时用来反查来源) */
+  containerRef?: string;
+  /**
+   * Agent 自身 token 健康灯(云函数 `sessionStatus`)。
+   * `ok` / `expired` / `unknown` —— expired 时余额等依赖 token 的字段会降级。
+   */
+  sessionStatus?: 'ok' | 'expired' | 'unknown';
   /** 是否已接入 FMODE(决定「进会话 vs 进详情」,见 AgentRouteService) */
   matrixReady: boolean;
   /** 会话未读数 */

+ 57 - 0
src/app/core/api/normalize.spec.ts

@@ -184,6 +184,63 @@ describe('parse-api 字段归一(兼容非标准容器)', () => {
     });
   });
 
+  describe('usage 映射(此前完全缺失 → Token 用量永远空白)', () => {
+    it('🔴 容器上报 inputTokens/outputTokens → 映射为 prompt/completion', async () => {
+      stubFetch({
+        code: 200,
+        agents: [
+          {
+            agentId: 'a',
+            usage: '{"totalTokens":2238055,"inputTokens":1856406,"outputTokens":381649,"apiCalls":487}',
+          },
+        ],
+      });
+      const [a] = await api.fetchAgents();
+      expect(a.usage?.totalTokens).toBe(2238055);
+      expect(a.usage?.promptTokens).toBe(1856406);
+      expect(a.usage?.completionTokens).toBe(381649);
+    });
+
+    it('契约旧字段 promptTokens 优先', async () => {
+      stubFetch({
+        code: 200,
+        agents: [{ agentId: 'a', usage: '{"promptTokens":10,"inputTokens":99}' }],
+      });
+      const [a] = await api.fetchAgents();
+      expect(a.usage?.promptTokens).toBe(10);
+    });
+
+    it('usage 全空 → undefined(不是 0)', async () => {
+      stubFetch({ code: 200, agents: [{ agentId: 'a', usage: '{}' }] });
+      const [a] = await api.fetchAgents();
+      expect(a.usage).toBeUndefined();
+    });
+  });
+
+  describe('deliverable 派生字段(此前 filename/size 从未赋值 → 渲染「· 」)', () => {
+    it('🔴 从 artifacts[0] 推导 filename / fileType', async () => {
+      stubFetch({
+        code: 200,
+        deliverables: [
+          {
+            objectId: 'd1',
+            title: '报告',
+            artifacts: [{ name: 'report.pdf', url: 'https://x/1', type: 'pdf' }],
+          },
+        ],
+      });
+      const [d] = await api.fetchDeliverables();
+      expect(d.filename).toBe('report.pdf');
+      expect(d.fileType).toBe('PDF');
+    });
+
+    it('无 artifacts → filename undefined(UI 显示「暂无附件」)', async () => {
+      stubFetch({ code: 200, deliverables: [{ objectId: 'd1', title: '空成果', artifacts: [] }] });
+      const [d] = await api.fetchDeliverables();
+      expect(d.filename).toBeUndefined();
+    });
+  });
+
   describe('综合:非标准容器的完整映射', () => {
     it('数组 channels + cpuLoad1 + probedAt → 全部归一正确', async () => {
       stubFetch({

+ 53 - 0
src/app/core/api/parse-api.ts

@@ -11,6 +11,7 @@ import {
   Me,
   Task,
   UploadTicket,
+  UsageSession,
 } from './models';
 
 /**
@@ -556,6 +557,30 @@ function mapAgent(raw: Record<string, unknown>): Agent {
           wecomBotId: channels.wecomBotId,
         }
       : undefined,
+    /**
+     * 🔴 `usage` 此前**完全没有映射** —— 云函数返回了、映射层丢掉了,
+     * 造成「Token 用量」永远空白(实测:库里 totalTokens=2238055,页面不显示)。
+     *
+     * 键名兼容:容器发 `inputTokens`/`outputTokens`,
+     * 契约旧字段是 `promptTokens`/`completionTokens`,两者都收。
+     */
+    usage: (() => {
+      const u = parseJsonLoose<Record<string, unknown>>(raw['usage']);
+      if (!u) return undefined;
+      const num = (x: unknown): number | undefined =>
+        typeof x === 'number' && Number.isFinite(x) ? x : undefined;
+      const usage = {
+        totalTokens: num(u['totalTokens']),
+        promptTokens: num(u['promptTokens']) ?? num(u['inputTokens']),
+        completionTokens: num(u['completionTokens']) ?? num(u['outputTokens']),
+        updatedAt: u['updatedAt'] ? String(u['updatedAt']) : undefined,
+        raw: u,
+      };
+      // 三项全无 → 视为未上报(避免 UI 显示 0)
+      return usage.totalTokens == null && usage.promptTokens == null && usage.completionTokens == null
+        ? undefined
+        : usage;
+    })(),
     owner: user
       ? {
           id: String(user['objectId'] ?? ''),
@@ -564,6 +589,19 @@ function mapAgent(raw: Record<string, unknown>): Agent {
           mobile: user['mobile'] ? String(user['mobile']) : undefined,
         }
       : undefined,
+    // 容器标识(排障反查来源用;云函数读的是 hostname/containerRef)
+    containerRef: raw['containerRef'] ? String(raw['containerRef']) : undefined,
+    // Agent token 健康灯:expired 时余额等依赖 token 的字段会降级
+    sessionStatus: (() => {
+      const s = String(raw['sessionStatus'] ?? '').toLowerCase();
+      return s === 'ok' || s === 'expired' ? (s as 'ok' | 'expired') : 'unknown';
+    })(),
+    // 会话级用量明细(可选,缺失不影响总量展示)
+    usageSessions: (() => {
+      const list = parseJsonLoose<unknown[]>(raw['usageSessions']);
+      if (!Array.isArray(list) || list.length === 0) return undefined;
+      return list as UsageSession[];
+    })(),
     companyId: (raw['company'] as { objectId?: string } | undefined)?.objectId,
     // 「是否已接入 FMODE」= channels.matrix 为真(或已有 matrixConfig.userId)
     matrixReady: !!channels?.matrix || !!channels?.matrixConfig?.userId,
@@ -584,6 +622,17 @@ function mapDeliverable(raw: Record<string, unknown>): Deliverable {
         type: String(a['type'] ?? ''),
       }))
     : [];
+  /*
+   * 🔴 派生展示字段:`filename` / `fileType` / `size` 此前**从未赋值**
+   * (只在 demo-data 里有),真实数据下右栏渲染成「· 」,形如「仅有 · 的文档」。
+   *
+   * 契约未提供独立字段 → 从首个 artifact 推导;无 artifact 时给明确占位,
+   * 让 UI 能显示「暂无附件」而不是一串空分隔符。
+   */
+  const first = artifacts[0];
+  const filename = first?.name || undefined;
+  const rawType = first?.type || filename?.split('.').pop() || '';
+  const fileType = rawType ? rawType.replace(/^\./, '').toUpperCase() : undefined;
   return {
     id: String(raw['objectId'] ?? raw['id'] ?? ''),
     agentId: String(raw['agentId'] ?? ''),
@@ -594,6 +643,10 @@ function mapDeliverable(raw: Record<string, unknown>): Deliverable {
     artifacts,
     tags: Array.isArray(raw['tags']) ? (raw['tags'] as string[]).map(String) : [],
     deliveredAt: String(raw['deliveredAt'] ?? ''),
+    filename,
+    fileType,
+    // 契约未返回文件大小 → 保持空(UI 不该显示 0 B)
+    size: raw['size'] ? String(raw['size']) : undefined,
     // 契约 §2.2:status 取值为推断 —— 含 fail/error 子串即失败
     state: /fail|error/i.test(status) ? 'failed' : 'delivered',
   };

+ 25 - 0
src/app/core/chat.service.spec.ts

@@ -38,6 +38,9 @@ describe('ChatService', () => {
     ensureLoaded: jasmine.createSpy('ensureLoaded').and.returnValue(Promise.resolve()),
     agentById: jasmine.createSpy('agentById'),
     agents: jasmine.createSpy('agents').and.returnValue([] as Agent[]),
+    /** 加载状态(chat.open 用它区分「加载中」与「确实不存在」) */
+    state: jasmine.createSpy('state').and.returnValue('ready'),
+    error: jasmine.createSpy('error').and.returnValue(null),
   };
 
   /** 桩:MatrixClientService */
@@ -173,6 +176,28 @@ describe('ChatService', () => {
       expect(svc.state()).toBe('error');
       expect(svc.error()).toContain('不存在');
     });
+
+    it('🔴 数据仍在加载 → 不报「不存在」(刷新页面的真实场景)', async () => {
+      realMode();
+      liveStub.agentById.and.returnValue(undefined);
+      liveStub.state.and.returnValue('loading');
+      await svc.open('agent-node-lj-test');
+      expect(svc.error()).not.toContain('不存在');
+      expect(svc.error()).toContain('加载');
+      liveStub.state.and.returnValue('ready'); // 复位
+    });
+
+    it('🔴 数据加载失败 → 报加载失败而非「不存在」', async () => {
+      realMode();
+      liveStub.agentById.and.returnValue(undefined);
+      liveStub.state.and.returnValue('error');
+      liveStub.error.and.returnValue('网络异常');
+      await svc.open('agent-node-lj-test');
+      expect(svc.error()).toContain('加载失败');
+      expect(svc.error()).toContain('网络异常');
+      liveStub.state.and.returnValue('ready');
+      liveStub.error.and.returnValue(null);
+    });
   });
 
   describe('失败分环节报错(FR-04:不得笼统)', () => {

+ 17 - 1
src/app/core/chat.service.ts

@@ -167,8 +167,24 @@ export class ChatService {
 
       const agent = this.live.agentById(agentId);
       if (!agent) {
+        /*
+         * 🔴 「取不到」有三种原因,不能一律报「不存在」:
+         *   ① 数据加载失败(网络/token)→ 报加载失败并给重试
+         *   ② 仍在加载中(并发场景)→ 报加载中,不算错误
+         *   ③ 确实没这个 agent → 才报不存在
+         *
+         * 刷新页面时最容易踩:live 还是 loading,agentById 返回 undefined,
+         * 原实现直接判「数字生命不存在」(实测复现)。
+         */
+        const liveState = this.live.state();
         this._state.set('error');
-        this._error.set('数字生命不存在');
+        this._error.set(
+          liveState === 'error'
+            ? `数据加载失败:${this.live.error() ?? '未知错误'}`
+            : liveState === 'loading' || liveState === 'idle'
+              ? '正在加载数字生命数据,请稍候…'
+              : '数字生命不存在',
+        );
         return;
       }
 

+ 17 - 2
src/app/core/live.service.ts

@@ -41,11 +41,26 @@ export class LiveService {
   private timer: ReturnType<typeof setInterval> | null = null;
   /** 递增序号:切上下文/并发请求时丢弃过期回包(存量 seq 模式) */
   private seq = 0;
+  /** 进行中的首次加载(并发调用共享,避免"加载中"被误判为"不存在") */
+  private _inflight: Promise<void> | null = null;
 
   /** 首次加载(若已有数据则不重复请求) */
   async ensureLoaded(): Promise<void> {
-    if (this._state() === 'ready' || this._state() === 'loading') return;
-    await this.load();
+    if (this._state() === 'ready') return;
+    /*
+     * 🔴 `loading` 时必须**等待**进行中的那次加载,不能直接 return。
+     *
+     * 原实现 `if (ready || loading) return;` —— 并发调用方(如聊天页
+     * `onRouteChange` 与 shell 的启动加载同时触发)里,后到者立刻拿到 undefined,
+     * 上层据此判「数字生命不存在」→ 刷新页面时报错(实测)。
+     *
+     * 改为复用同一个 in-flight Promise:所有人等到同一份数据。
+     */
+    if (this._inflight) return this._inflight;
+    this._inflight = this.load().finally(() => {
+      this._inflight = null;
+    });
+    return this._inflight;
   }
 
   /**

+ 7 - 1
src/app/desktop/pages/chat/chat.ts

@@ -332,7 +332,13 @@ import { TasksService } from '../../../core/tasks.service';
                       </span>
                       <span class="rail-meta">
                         <strong class="ellipsis">{{ d.title }}</strong>
-                        <small class="ellipsis">{{ d.filename }} · {{ d.size }}</small>
+                        <!-- 🔴 无附件时不能渲染成「· 」空分隔符(曾显示成「仅有 · 的文档」) -->
+                        <small class="ellipsis">
+                          {{ d.filename || '暂无附件' }}
+                          @if (d.filename && d.size) {
+                            · {{ d.size }}
+                          }
+                        </small>
                       </span>
                       <span class="rail-actions">
                         <ui-button