Pārlūkot izejas kodu

feat(core): 文件交付协议 v2(结构化清单)+ 修复清数据后仍可见

## 修复:清掉脏数据刷新仍可见
hydrateFromCloud 里 `r.deliverableUrls?.length ? ... : local?.xxx`
在云端返回空数组时会回退到本地旧值,导致「云端已清空、页面还在」。
改为 Array.isArray 判断:云端明确返回数组(含空数组)就以它为准。

## 新增:交付协议 v2
原先靠正文正则找 URL 推断交付文件,踩过两次坑:
容器举例的假 URL、用户提问里的 URL 都被当成真实文件。
改为容器显式声明,新增 core/declared-files.ts:
- parseDeclaredFiles:无声明段返回 null(回退正则),有但为空返回 []
- stripDeclaredFiles:从正文剥离,用户看不到 JSON

chat.service 文件来源改为三级优先:
Matrix 原生附件 > 容器声明段 > 正文正则兜底

## 容器侧
协议已写入 fmode-storage 技能 v1.4.0,含 7 条字段规则与自查项。

## 验证
- 端到端:普通请求 → 卡片显示 weekly-report-template.md 1.7 KB(真实大小)
- JSON 不泄漏到正文;右栏成果正确
- 单测 1423 全绿

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct 8 stundas atpakaļ
vecāks
revīzija
cc58e96e71

+ 139 - 0
docs/00-tracking/2026-11-10-修改留痕-交付协议v2.md

@@ -0,0 +1,139 @@
+# 修改留痕:文件交付协议 v2(结构化清单)+ 本地缓存回退修复
+
+| 字段 | 值 |
+|---|---|
+| 日期 | 2026-11-10 |
+| 触发 | ① 清掉脏数据后刷新仍能看到 ② 用户提议改用 JSON 方案 |
+| 结果 | 两处均已修复并端到端验证 |
+
+---
+
+## 一、清了数据还看得到 —— 本地缓存回退的锅
+
+### 现象
+
+云端已经清空某会话的 `deliverableUrls`,但用户刷新页面后,
+右栏「成果」里那条错误的 `s3.fmode.cn` 仍然在。
+
+### 根因
+
+`chat-session.service.ts` 的 `hydrateFromCloud`:
+
+```ts
+deliverableUrls: r.deliverableUrls?.length ? r.deliverableUrls : local?.deliverableUrls,
+```
+
+**云端返回空数组 `[]` 时,`?.length` 为 0(假值)→ 回退到本地旧值**。
+所以「云端清空」被本地缓存又填了回去。
+
+### 修法
+
+```ts
+deliverableUrls: Array.isArray(r.deliverableUrls)
+  ? r.deliverableUrls          // 云端明确返回数组(含空数组)就信它
+  : local?.deliverableUrls,    // 只有字段完全缺失(老数据)才兜底
+```
+
+---
+
+## 二、交付协议 v2:结构化文件清单
+
+### 为什么改
+
+原先靠**从正文正则找 URL** 推断容器交付了什么文件。这条路上踩过两次:
+
+| # | 坑 |
+|---|---|
+| 1 | 容器举例写 `https://s3.fmode.cn/...` → 被当成真实文件建卡 |
+| 2 | 用户提问里写同样的 URL → 也被收进「成果」 |
+
+根子是**靠猜**。改成容器**显式声明**。
+
+### 协议
+
+容器在回复**末尾**追加一段 HTML 注释包裹的 JSON:
+
+```
+本周报告已生成。
+
+<!--fmode:files
+[
+  {"name": "report-20261110.md", "url": "https://s3.fmode.cn/...", "size": 2048}
+]
+-->
+```
+
+### 实现
+
+新增 `src/app/core/declared-files.ts`:
+
+| 函数 | 作用 |
+|---|---|
+| `parseDeclaredFiles(text)` | 解析清单;**无声明段 → `null`**(回退正则)、**有但为空 → `[]`** |
+| `stripDeclaredFiles(text)` | 从正文剥离声明段,用户看不到 JSON |
+
+`chat.service.ts` 的文件来源改为**三级优先级**:
+
+```
+① Matrix 原生附件(m.file / m.media)
+② 容器声明段 <!--fmode:files-->        ← 新增,最可靠
+③ 正文正则提取                          ← 老格式兜底
+```
+
+**有 ② 就不走 ③** —— 容器已明确表态,不需要再猜。
+
+### 容器侧:已写入技能 v1.4.0
+
+容器把协议写进 `/opt/data/skills/fmode-storage/SKILL.md`:
+
+- 新增小节「交付文件结构化清单(v2 协议)」,7 条字段规则
+- 原来「裸 URL 独占一行」的模板标记为 v1.3.0 旧做法,仅作兜底
+- **主动把示例 URL 改成 `<put 返回的 https URL>` 这类明确占位符** ——
+  防止有人把技能里的示例复制进真实回复,重演第一次踩坑
+
+---
+
+## 三、端到端验证
+
+用普通用户请求(不提任何格式要求):
+
+> 帮我写一份团队周报模板的 Markdown 文件,包含本周完成、下周计划、风险三个部分。
+
+| 项 | 结果 |
+|---|---|
+| 正文 | 300 字,正常显示 |
+| 文件卡片 | **1 个** |
+| 卡片内容 | `weekly-report-template.md 1.7 KB · MD` |
+| **文件大小** | **1.7 KB(真实值)** ← 之前是 `-1` / 不显示 |
+| JSON 泄漏 | ❌ 无,已剥离 |
+| 右栏成果 | 1 条 |
+
+### 与之前的对比
+
+| 维度 | 正则方案 | v2 协议 |
+|---|---|---|
+| 文件大小 | 猜不到(-1) | **真实值** |
+| 误判举例链接 | 会 | **不会** |
+| 用户提问里的链接 | 会误收 | **不会** |
+| 正文可读性 | 好 | 好(JSON 被剥离) |
+
+---
+
+## 四、验证汇总
+
+| 项 | 结果 |
+|---|---|
+| `tsc --noEmit` | ✅ |
+| 全量 `npm test` | ✅(见提交记录) |
+| 声明段单测 | ✅ 10 条 |
+| 端到端 | ✅ 卡片 + 真实大小 + 无 JSON 泄漏 |
+
+---
+
+## 五、遗留
+
+| # | 项 | 级别 |
+|---|---|---|
+| 1 | S3 未配 CORS | P0(预览无沙箱,前端已降级绕过) |
+| 2 | 群聊缺陷未实测 | P1 |
+| 3 | `app-lj-test-v1` 搬运脚本缺失 | P2(用户裁决暂不管) |

+ 13 - 2
src/app/core/chat-session.service.ts

@@ -342,8 +342,19 @@ export class ChatSessionService {
          */
         kind: r.kind || local?.kind || undefined,
         memberIds: r.memberIds?.length ? r.memberIds : local?.memberIds,
-        /* 成果链接数组(2026-11-09):云端为准,本地兜底 */
-        deliverableUrls: r.deliverableUrls?.length ? r.deliverableUrls : local?.deliverableUrls,
+        /*
+         * 成果链接数组(2026-11-09):**云端为准,空数组也算数**。
+         *
+         * 🔴 原来写成 `r.deliverableUrls?.length ? r.deliverableUrls : local?.xxx`,
+         *    云端清空后(`[]` 长度为 0)会回退到**本地旧值** ——
+         *    用户清掉脏数据、刷新页面,错误成果还在(实测反馈)。
+         *
+         * 现在:云端明确返回了数组(含空数组)就以它为准;
+         *       只有字段**完全缺失**(老数据)才用本地兜底。
+         */
+        deliverableUrls: Array.isArray(r.deliverableUrls)
+          ? r.deliverableUrls
+          : local?.deliverableUrls,
       });
     }
 

+ 85 - 22
src/app/core/chat.service.ts

@@ -13,6 +13,7 @@ import {
 } from './api/models';
 import { DEMO_MESSAGES } from './api/demo-data';
 import { extractS3Links, stripLinks } from './s3-link';
+import { DeclaredFile, parseDeclaredFiles, stripDeclaredFiles } from './declared-files';
 import { environment } from '../../environments/environment';
 import { LiveService } from './live.service';
 import { SessionService } from './session.service';
@@ -937,9 +938,14 @@ export class ChatService {
      */
     for (const m of all) {
       if (m.sender !== 'agent') continue;
-      /* 与实时路径同口径:必须通过文件判定才收(排除 `https://s3.fmode.cn/...` 这类占位) */
-      for (const u of extractS3Links(m.text)) {
-        if (this.cardOfCloudLink(u)) this.sessions.addDeliverableUrl(sid, u);
+      /* 与实时路径同口径:优先声明段,否则正则兜底 + 文件判定 */
+      const declared = parseDeclaredFiles(m.text);
+      if (declared !== null) {
+        for (const d of declared) this.sessions.addDeliverableUrl(sid, d.url);
+      } else {
+        for (const u of extractS3Links(m.text)) {
+          if (this.cardOfCloudLink(u)) this.sessions.addDeliverableUrl(sid, u);
+        }
       }
     }
 
@@ -2067,16 +2073,34 @@ export class ChatService {
        * 属于容器输出格式问题,已通过「链接独占一行、一次只给一个主文件」规范约束。
        */
       /*
-       * 正文里的云盘链接 → 文件卡片,并把链接从正文里**摘掉**(用户裁决:
-       * 「卡片统一在底部,正文里的 URL 隐去」)。
+       * 文件来源(2026-11-10 起按优先级):
+       *
+       *   ① Matrix 原生附件(`m.file` / `m.media`) —— 最可靠
+       *   ② **容器声明段** `<!--fmode:files [...] -->` —— 结构化,不需要猜
+       *   ③ 正文里的云盘链接(正则提取) —— 老格式的兜底
        *
-       * 🔴 顺序不能错:**先摘链接 → 再判断剩余内容是否为空**。
-       *    上一版直接把 `text` 置成剥离后的结果,没判断空,
-       *    正文只剩链接时整条消息变成空白(用户实测「回复的文字没有了」)。
+       * 🔴 ② 是新增的:原先只有 ①③,而 ③ 靠正则猜 URL,
+       *    已经踩过两次坑(容器举例的 `https://s3.fmode.cn/...` 被当成真文件、
+       *    用户提问里的链接被收成成果)。声明段是容器**显式说**有哪些文件。
+       *
+       * 声明段存在时**不再**走 ③ —— 容器已经明确表态,不需要再猜。
+       * 没有声明段时才回退到 ③(老容器仍可用)。
        */
-      const cloudLinks = file ? [] : extractS3Links(m.body);
-      const linkCard = cloudLinks.length > 0 ? this.cardOfCloudLink(cloudLinks[0]) : undefined;
-      const bodyText = linkCard ? stripLinks(m.body, cloudLinks) : m.body;
+      const declared = file ? null : parseDeclaredFiles(m.body);
+      let linkCard: FileCard | undefined;
+      let bodyText = m.body;
+
+      if (declared !== null) {
+        // 有声明段:正文去掉那段 JSON(用户不该看到),文件从声明里取
+        bodyText = stripDeclaredFiles(m.body);
+        const first = declared.find((d) => d.url);
+        if (first) linkCard = this.cardFromDeclared(first);
+      } else if (!file) {
+        // 没有声明段:回退到「从正文正则提取」(老格式)
+        const cloudLinks = extractS3Links(m.body);
+        linkCard = cloudLinks.length > 0 ? this.cardOfCloudLink(cloudLinks[0]) : undefined;
+        bodyText = linkCard ? stripLinks(m.body, cloudLinks) : m.body;
+      }
       return {
         id: m.eventId || `${m.ts}-${m.sender}`,
         conversationId: cid,
@@ -2107,6 +2131,44 @@ export class ChatService {
     });
   }
 
+  /**
+   * 把声明段里的一项包成附件卡片(2026-11-10)。
+   *
+   * 与 `cardOfCloudLink` 的区别:这里的**文件名和大小是容器给的**,不用猜。
+   */
+  private cardFromDeclared(d: DeclaredFile): FileCard {
+    const ext = (d.name.split('.').pop() ?? '').toLowerCase();
+    const mimeByExt: Record<string, string> = {
+      md: 'text/markdown',
+      markdown: 'text/markdown',
+      html: 'text/html',
+      htm: 'text/html',
+      pdf: 'application/pdf',
+      txt: 'text/plain',
+      csv: 'text/csv',
+      json: 'application/json',
+      png: 'image/png',
+      jpg: 'image/jpeg',
+      jpeg: 'image/jpeg',
+      gif: 'image/gif',
+      webp: 'image/webp',
+      svg: 'image/svg+xml',
+      xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+      docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+      pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
+      zip: 'application/zip',
+      mp4: 'video/mp4',
+    };
+    return {
+      kind: 'file',
+      name: d.name,
+      size: d.size,
+      mimeType: mimeByExt[ext] ?? 'application/octet-stream',
+      url: d.url,
+      cloudUrl: d.url,
+    };
+  }
+
   /**
    * 把一条云盘链接包成附件卡片。
    *
@@ -2249,21 +2311,22 @@ export class ChatService {
     const fileUrl = mapped.file?.cloudUrl || (mapped.file?.url?.startsWith('https://s3.') ? mapped.file.url : undefined);
     if (fileUrl) this.sessions.addDeliverableUrl(sid, fileUrl);
     /*
-     * 🔴 正文里出现的 S3 链接也要认(2026-11-09 实测补充)。
+     * 成果链接记入会话(2026-11-10 起按优先级):
      *
-     * 容器产出的成果**不会发文件附件**,而是把云盘 URL 直接写在回复正文里。
-     * 只认 `mapped.file` 的话,用户右栏「成果」永远是空的(实测确认)。
+     *   ① 容器声明段 `<!--fmode:files-->` —— **显式声明,最可靠**
+     *   ② 正文里的云盘链接 —— 老格式兜底,且必须通过文件判定
      *
-     * ⚠️ 2026-11-09 第二次修正:**必须同时满足三个条件**,否则会误收:
-     *   ① sender === 'agent'  —— 用户在提问里写的示例链接不算成果
-     *                            (实测:用户发「不要写 https://s3.fmode.cn/...」,
-     *                              结果那条示例被收成了「成果」)
-     *   ② 不是省略号占位     —— `https://s3.fmode.cn/...` 这种是举例,不是文件
-     *   ③ 通过文件判定       —— 有合法扩展名(复用 cardOfCloudLink 的规则)
+     * ⚠️ 之前只有 ②,必须同时满足:sender === 'agent' + 不是省略号占位
+     *    + 有合法扩展名。少一条就会把用户提问里的示例链接收成成果(已实测踩坑)。
      */
     if (mapped.sender === 'agent') {
-      for (const u of extractS3Links(mapped.text)) {
-        if (this.cardOfCloudLink(u)) this.sessions.addDeliverableUrl(sid, u);
+      const declared = parseDeclaredFiles(m.body);
+      if (declared !== null) {
+        for (const d of declared) this.sessions.addDeliverableUrl(sid, d.url);
+      } else {
+        for (const u of extractS3Links(mapped.text)) {
+          if (this.cardOfCloudLink(u)) this.sessions.addDeliverableUrl(sid, u);
+        }
       }
     }
     /*

+ 72 - 0
src/app/core/declared-files.spec.ts

@@ -0,0 +1,72 @@
+import { parseDeclaredFiles, stripDeclaredFiles } from './declared-files';
+
+/**
+ * 单测:容器交付协议(声明段解析)
+ *
+ * 背景:原先靠正则从正文里找 URL,已踩过两次坑
+ * (举例链接被当真、用户提问里的链接被收成成果)。本协议改为容器显式声明。
+ */
+describe('parseDeclaredFiles', () => {
+  const wrap = (json: string): string =>
+    `本周计划已生成。\n\n<!--fmode:files\n${json}\n-->\n`;
+
+  it('解析出文件清单', () => {
+    const out = parseDeclaredFiles(
+      wrap('[{"name":"a.md","url":"https://s3.fmode.cn/x/a.md","size":1587}]'),
+    );
+    expect(out).toEqual([{ name: 'a.md', url: 'https://s3.fmode.cn/x/a.md', size: 1587 }]);
+  });
+
+  it('多个文件按顺序返回', () => {
+    const out = parseDeclaredFiles(
+      wrap(
+        '[{"name":"a.md","url":"https://s3.fmode.cn/a.md"},{"name":"b.png","url":"https://s3.fmode.cn/b.png"}]',
+      ),
+    );
+    expect(out?.map((x) => x.name)).toEqual(['a.md', 'b.png']);
+  });
+
+  it('🔴 没有声明段 → 返回 null(调用方回退到正则)', () => {
+    expect(parseDeclaredFiles('普通回复,没有标记')).toBeNull();
+    expect(parseDeclaredFiles(undefined)).toBeNull();
+  });
+
+  it('🔴 有声明段但为空数组 → 返回 [](容器明确表示没有文件)', () => {
+    expect(parseDeclaredFiles(wrap('[]'))).toEqual([]);
+  });
+
+  it('🔴 JSON 写坏 → 返回 [],不回退去猜正文', () => {
+    expect(parseDeclaredFiles(wrap('{不是合法 JSON'))).toEqual([]);
+  });
+
+  it('缺少 name 时从 URL 推断', () => {
+    const out = parseDeclaredFiles(wrap('[{"url":"https://s3.fmode.cn/x/report.md"}]'));
+    expect(out?.[0].name).toBe('report.md');
+  });
+
+  it('缺少 size 时记为 -1(未知)', () => {
+    const out = parseDeclaredFiles(wrap('[{"name":"a.md","url":"https://s3.fmode.cn/a.md"}]'));
+    expect(out?.[0].size).toBe(-1);
+  });
+
+  it('过滤掉非法项(没有 url / url 不是 http)', () => {
+    const out = parseDeclaredFiles(
+      wrap(
+        '[{"name":"a.md"},{"name":"b","url":"ftp://x"},{"name":"c.md","url":"https://s3.fmode.cn/c.md"}]',
+      ),
+    );
+    expect(out?.length).toBe(1);
+    expect(out?.[0].name).toBe('c.md');
+  });
+});
+
+describe('stripDeclaredFiles', () => {
+  it('🔴 把声明段从正文里移除(用户不该看到 JSON)', () => {
+    const src = '本周计划已生成。\n\n<!--fmode:files\n[{"name":"a.md","url":"https://x/a.md"}]\n-->';
+    expect(stripDeclaredFiles(src)).toBe('本周计划已生成。');
+  });
+
+  it('没有声明段时原样返回(去掉首尾空白)', () => {
+    expect(stripDeclaredFiles('  普通回复  ')).toBe('普通回复');
+  });
+});

+ 100 - 0
src/app/core/declared-files.ts

@@ -0,0 +1,100 @@
+/**
+ * 容器交付协议:结构化文件清单
+ *
+ * ## 为什么需要
+ *
+ * 原先靠**从正文正则找 URL** 来推断容器产出了哪些文件。这条路上已经踩过两次:
+ *   ① 容器举例写 `https://s3.fmode.cn/...` → 被当成真实文件建卡
+ *   ② 用户提问里写同样的 URL → 也被收进「成果」
+ *
+ * 根子是「靠猜」。改成**容器显式声明**:正文照常给人读,文件清单用标记段给机器解析。
+ *
+ * ## 协议
+ *
+ * 容器在回复末尾追加一段被 HTML 注释包裹的 JSON:
+ *
+ * ```
+ * 本周工作计划已生成,含 5 条任务与时间分布。
+ *
+ * <!--fmode:files
+ * [
+ *   {"name": "weekly-plan-20261109.md", "url": "https://s3.fmode.cn/xxx/a.md", "size": 1587},
+ *   {"name": "report.png", "url": "https://s3.fmode.cn/xxx/b.png"}
+ * ]
+ * -->
+ * ```
+ *
+ * 用 HTML 注释包裹的好处:
+ *   · 即使解析失败,Markdown 渲染器也不会把 JSON 显示成正文(注释不可见)
+ *   · 人读回复时完全无感
+ *
+ * ## 兼容
+ *
+ * 没有标记段时返回 `null`,调用方回退到「从正文提取」(老格式仍可用)。
+ */
+
+/** 标记段:`<!--fmode:files ... -->`,内容为 JSON 数组 */
+const FILES_BLOCK = /<!--\s*fmode:files\s*([\s\S]*?)-->/i;
+
+/** 清单里的一项 */
+export interface DeclaredFile {
+  name: string;
+  url: string;
+  /** 字节数;缺省 / 非法时为 -1(表示未知) */
+  size: number;
+}
+
+/**
+ * 解析容器声明段里的文件清单。
+ *
+ * @param text 容器回复原文
+ * @returns
+ *   · `null` —— 没有声明段(老格式,调用方回退到正则提取)
+ *   · `[]`   —— 有声明段但内容为空(容器明确表示「本次没有文件」)
+ *   · 数组   —— 解析出的文件清单(已过滤非法项)
+ */
+export function parseDeclaredFiles(text: string | undefined | null): DeclaredFile[] | null {
+  if (!text) return null;
+  const m = FILES_BLOCK.exec(text);
+  if (!m) return null;
+
+  let raw: unknown;
+  try {
+    raw = JSON.parse(m[1].trim());
+  } catch {
+    /*
+     * JSON 写坏了:返回空数组而不是 null ——
+     * 容器「声明了但写错」时,宁可不显示也不要用正文正则去猜,
+     * 否则又会把举例链接当成文件。
+     */
+    return [];
+  }
+  if (!Array.isArray(raw)) return [];
+
+  const out: DeclaredFile[] = [];
+  for (const it of raw) {
+    if (!it || typeof it !== 'object') continue;
+    const o = it as Record<string, unknown>;
+    const url = typeof o['url'] === 'string' ? o['url'].trim() : '';
+    if (!url || !/^https?:\/\//i.test(url)) continue;
+    const name =
+      typeof o['name'] === 'string' && o['name'].trim()
+        ? o['name'].trim()
+        : (decodeURIComponent(url.split('?')[0].split('/').pop() ?? '文件') || '文件');
+    const size = typeof o['size'] === 'number' && Number.isFinite(o['size']) && o['size'] >= 0
+      ? o['size']
+      : -1;
+    out.push({ name, url, size });
+  }
+  return out;
+}
+
+/**
+ * 把声明段从正文里**移除**(用户不该看到那段 JSON)。
+ *
+ * 即使解析失败也移除 —— 它是给机器看的,不是给用户看的。
+ */
+export function stripDeclaredFiles(text: string | undefined | null): string {
+  if (!text) return '';
+  return text.replace(FILES_BLOCK, '').trim();
+}