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

fix: 云函数调用剥离 X-Parse-* 头 + fmode.kind 结构化消息类别

- 云函数入口不再带任何 X-Parse-* 请求头:线上网关多实例预检响应不一致,
  固定白名单不含 x-parse-*,逐个删头治标不治本,统一剥离;鉴权改由 body token
- 新增 fmodeKind 字段(Matrix content["fmode.kind"]):容器声明 process/final
  时优先于文本启发式,缺失则自动回退,未改造容器照常可用
- 新增《容器接入规范提示词》文档(lj-test 取证,供新容器接入复用)
- 新增 scripts/ask-seq.mjs、scripts/verify-fmode-kind.mjs 取证脚本
- 同步操作追踪

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

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

@@ -7663,3 +7663,35 @@ URL 不会重复显示:`chat.service.ts:2349` 的 `stripLinks` 会把已提取
 若将来恢复群聊,需要先解决它。
 
 ---
+
+## 2026-11-11(续十)· 容器接入规范取证(与 lj-test 逐条对话)
+
+| 项 | 内容 |
+|---|---|
+| **触发** | 用户要求:向 lj-test 问清「为接入本项目改过哪些规范」,产出可供其它容器复用的提示词文档 |
+| **方法** | 通过 Matrix 直接向 `agent-node-lj-test` 逐条提问(短问题、一次一问),回答均来自容器对自身代码/配置的只读核查 |
+| **产出** | `docs/03-interface/容器接入规范提示词-20261111.md` v1.0 |
+
+### 取证到的关键事实
+
+| # | 事实 | 出处 |
+|---|---|---|
+| 1 | 收文件**只能**靠 `mxc://` 附件;把 https 塞进 `url` 字段会被**静默丢弃** | 容器亲述 |
+| 2 | 回文件格式逐字为:空行 + `**已上云(可直接下载)**` + 每条 `- url` | 容器给出插件源码模板 |
+| 3 | **过程/正文区分目前没有结构化字段** —— `_interim_send` 在 Matrix adapter **未被消费**,字段不上线 | 容器核查 `adapter.py:1385` / `:2737-2751` |
+| 4 | 容器建议改 adapter 落 `fmode.kind: "process" \| "final"` 自定义键 | 容器主动提出 |
+| 5 | 心跳 **3600s + 0~900s 抖动 = 60~75 分钟**(与 App 80 分钟阈值吻合) | 容器查 cron 配置 |
+| 6 | 输出策略:**过程分条、结论整段** | 容器自述沟通铁则 |
+| 7 | 回复**必须挂 thread**,否则 App 判成新会话 | 容器说明 |
+| 8 | 交付插件会**删除**未真实推送的 https,以及 `/opt/data` `/tmp` `/home` 的 `MEDIA:` 行 | 容器说明 `_keep()` |
+
+### 最重要的一条
+
+**第 3 条是 App 侧「日志吞正文」反复出问题的根本原因**:
+容器不提供结构化标记,App 只能靠文本启发式(emoji / `· 工具名` / 裸命令)判断,
+一旦工具行与结论混排就会误判。
+
+本规范已把「实现 `fmode.kind`」列为**强烈推荐的进阶项**,
+并在过渡期给出硬要求(工具行独占一行、不与中文结论混排)。
+
+---

+ 334 - 0
docs/03-interface/容器接入规范提示词-20261111.md

@@ -0,0 +1,334 @@
+# 容器接入 Fmode App —— 规范提示词(v1.0)
+
+> **本文档用途**:任何一个新容器要接入 fmode-app-v2 客户端时,把本文档作为提示词交给它,
+> 它就能知道需要做哪些改造。
+>
+> **取证来源**:2026-11-11 与 `agent-node-lj-test`(已完整接入的参考容器)逐条对话取证,
+> 回答均来自该容器对自身实际代码/配置的只读核查,非推测。
+>
+> **基准对象**:fmode-app-v2(Angular SPA,`https://ai.fmode.cn`)
+
+---
+
+## 一、最重要的三件事(先把这三条做对,其余都好办)
+
+### 1. 收文件:只能靠 `mxc://` 附件,不能靠正文链接
+
+**原文(lj-test 回答)**:
+
+> 读 mxc 附件:媒体事件(`m.file`、`m.image` 等)里的 url 必须是 `mxc://`,
+> 适配器用 bot 自己的登录态下载并缓存到本地供我读取;
+> 如果链接放在正文里,只能走纯文本 body 由我 curl 去读;
+> **如果把 https 塞进 url 字段,会被静默丢弃。**
+
+| 项 | 要求 |
+|---|---|
+| 附件字段 | `content.url` 必须是 `mxc://...` |
+| ❌ 禁止 | 把 `https://` 塞进 `url` 字段(**静默丢弃,无报错**) |
+| 大小上限 | 20MB(App 侧同口径,`upload-limits.ts`) |
+
+**App 侧对应行为**:用户发文件时 App 会**双写** —— Matrix 存 `mxc://`(给容器读)+ 云盘存 https(给 App 长期查看)。
+
+---
+
+### 2. 回文件:`**已上云(可直接下载)**` 链接块
+
+**逐字模板(lj-test 插件源码原文)**:
+
+```
+**已上云(可直接下载)**
+- {url_1}
+- {url_2}
+```
+
+**拼接规则**:正文末尾**空一行** → 表头行 `**已上云(可直接下载)**` → 其下每条链接一行
+(格式 `- ` + 完整 https 地址),链接之间换行、**无空行**。
+
+| 项 | 说明 |
+|---|---|
+| App 识别正则 | `/\*\*已上云[^*]*\*\*/`(`core/s3-link.ts:53`) |
+| 必须的域名 | `s3.fmode.cn` 或 `file-cloud.fmode.cn`(其它域名不认) |
+| 大小 | 无上限(走对象存储) |
+
+**⚠️ 另一个可选通道 `MEDIA:<绝对路径>`**(平台原生语法):
+
+- App 按行解析 `MEDIA:` 前缀当作附件
+- **但**:限制 20MB(图片/视频 10MB、语音 2MB),超限才转对象存储
+- **lj-test 明确提醒**:它的交付注入插件会删除指向 `/opt/data`、`/tmp`、`/home` 的 `MEDIA:` 行(防假设假附件),
+  也会删除正文里所有不在本轮推送白名单内的 https —— 所以**手写链接会被删掉**,
+  链接必须由上传 hook 真实推送后才出现
+
+> **给新容器的建议**:用 `**已上云(可直接下载)**` 链接块,不要手写 `MEDIA:`。
+
+---
+
+### 3. 过程与正文的区分:**目前没有结构化字段**(最大缺口)
+
+**lj-test 原文**:
+
+> `_interim_send` 在 Matrix 侧根本没被写进 `msg_content`,所以它止步于进程内,
+> 从未进入实际发送的事件内容。
+> ……**App 判断哪条是过程、哪条是正文时不看前缀,只看带外 metadata 或消息类型,
+> 所以别把 emoji 当协议。**
+>
+> 现在只能二选一:
+> 1. 给 Matrix adapter 加字段(推荐):在 `send()` 里把 `metadata.get("_interim_send")`
+>    落成 content 里的自定义键,例如 `"fmode.kind": "process" | "final"`
+> 2. 继续靠文本前缀(`💻 `/`📖 ` 开头)启发式判断——**脆弱,正文里出现代码块就会误判**
+
+**现状**:App 侧(`core/message-segments.ts`)**只能靠文本启发式**判断
+(工具 emoji 前缀、`· 工具名` 行、裸命令行),这正是「日志吞正文」问题反复出现的根源。
+
+| 状态 | 说明 |
+|---|---|
+| 🔴 待改造 | **推荐新容器实现 `fmode.kind: "process" \| "final"` 自定义键** |
+| ⚠️ 现状兜底 | 若暂不改造,App 靠启发式;要求工具行**必须带固定 emoji 前缀**且**不与正文混排** |
+
+**给新容器的硬要求(在改造前的过渡期)**:
+
+- 工具/进度行**独占一行**,以固定 emoji 开头(`💻` `📖` `🔎` `✍️` `🐍` `⚙️`)
+- **不要把工具行与中文结论写在同一条消息里** —— App 会按行拆,混排时容易误判
+- 结论/正文**单独一条消息**发
+
+#### 过渡期之后:改用 `fmode.kind` 结构化标记(推荐)
+
+上面的 emoji 约定是**过渡方案**,本质仍是让 App 猜。根治办法是容器在消息内容里
+显式声明每条消息是过程还是正文:
+
+```json
+{ "msgtype": "m.text", "body": "...", "fmode.kind": "process" }
+```
+
+| 值 | 语义 | App 行为 |
+|---|---|---|
+| `"process"` | 工具执行 / 进度 / 旁白 | 整条进顶部折叠区,**不再逐行猜测** |
+| `"final"` | 正文 / 结论 | 整条进正文,**即使内容里出现命令行也不拆** |
+| 缺失 | 未改造的容器 | App 自动回退到 emoji 启发式,**老容器照常可用** |
+
+**App 侧已就绪**(2026-11-11):`core/matrix-client.service.ts` 的 `extractFmodeKind()`
+读取该键 → `Message.fmodeKind` → `core/assistant-turn.ts` 的 `splitIntoParts()` 优先采用。
+
+##### 容器侧怎么改(以 lj-test 的 Hermes 架构为例)
+
+⚠️ **实测发现的关键事实**:`send()` 路径只覆盖「中途旁白 / 审批提示 / 状态播报」,
+**工具进度行不走 `send()`** —— 它们是 channel 自己拼接 `msg_content` 产生的。
+所以要**改两处**:
+
+| # | 位置 | 改法 |
+|---|---|---|
+| 1 | Matrix adapter 的 `send()` | `metadata.get("_interim_send")` 为真 → `content["fmode.kind"] = "process"`,否则 `"final"` |
+| 2 | 工具进度行的产出链路(`_progress_build_message` 之后) | 同样写入 `content["fmode.kind"] = "process"` |
+
+**改造纪律**:
+1. 先备份目标文件(如 `adapter.py.bak-fmodekind-<ts>`)
+2. 改完**重启 gateway**(adapter 是进程内加载的)
+3. **用真实房间事件回读验证**:直读 `/_matrix/client/v3/rooms/{roomId}/messages`,
+   逐条打 `sorted(content.keys())`,确认 `fmode.kind` 确实出现且 `process`/`final` 交替
+   —— **不能靠看日志或看自己发的文本判断**
+4. 如某处确实做不到,**如实说明原因,不要假装做了**
+
+##### 已知瑕疵(改造会暴露,需评估是否接受)
+
+`create_handoff_thread()` 的种子消息会被标成 `final`(它没有 `_interim_send`),
+按语义它不算正文。可在该处显式跳过写入。
+
+---
+
+## 二、会话与线程
+
+### 2.1 必须挂 thread(否则每条消息变成新会话)
+
+**lj-test 原文**:
+
+> 会。我发出的回复默认会挂 Matrix thread(`m.relates_to` / `rel_type=m.thread`),
+> 前提是群房间启用了自动线程(`MATRIX_AUTO_THREAD=true`、`MATRIX_SESSION_SCOPE=auto`):
+> 每条不带 thread 的消息都会被当作新 thread 的根,后续回复就落在这个 thread 下。
+> **如果 App 侧主动带了 thread 根(真实 thread 优先于自动分叉),则挂到你指定的那个 thread。**
+
+| 项 | 要求 |
+|---|---|
+| App 发送 | 用户消息带 thread 根(`SESSION_KEY: com.fmode.session`) |
+| 容器回复 | **必须沿用 App 给的 thread**,不能自己新开 |
+| ❌ 后果 | 不挂 thread → App 认为每条都是新会话 → 对话记录碎片化 |
+
+### 2.2 会话归集字段
+
+**lj-test 原文**:
+
+> 会话是按 `session_key` 归集的,格式由冒号分隔的几段组成:
+> `agent:main:<platform>:<chat_type>:<chat_id>[:<thread_id>][:<user_id>]`
+
+---
+
+## 三、分条发送策略
+
+**lj-test 原文**:
+
+> **拆成多条发。**
+> 原因:这个房间走的是 Matrix 自动分线程,每条消息都是独立会话,历史不共享。
+> 真按一条整段发反而吃亏 —— 中间步骤的进度你立刻能看到,不受我长回合跑工具卡住的影响。
+>
+> 不过有个例外:**结果类的内容**(报告、链接、结论)我会一次说完,不分片,避免你要拼图。
+> **过程分条、结论整段。**
+
+| 内容类型 | 发送方式 |
+|---|---|
+| 过程/进度 | 分条,每条带工具 emoji 前缀 |
+| 结论/正文/文件链接 | **一整段发完**,不要分片 |
+
+---
+
+## 四、状态上报(心跳)
+
+**lj-test 原文**:
+
+> 定时上报不是 1 小时:**心跳间隔为 3600s,再加每个容器 0~900s 的随机偏移
+> (实际周期 60~75 分钟)**,而状态变更由事件驱动(`pre/post_llm_call`)实时上报。
+
+| 项 | 值 |
+|---|---|
+| 心跳间隔 | 3600 秒 + 0~900 秒随机偏移 |
+| 实际周期 | **60~75 分钟** |
+| App 离线判定阈值 | **80 分钟**(`HEARTBEAT_STALE_SEC = 80 * 60`) |
+
+> **要求**:新容器的心跳周期不能超过 80 分钟,否则 App 会判离线。
+
+**上报字段**(App 侧消费):
+`heartbeatAgeSec` / `heartbeatAt` / `status` / `currentTask` / `currentAction` /
+`channels.matrixConfig` / `extra.currentSessions` / `balance` / `usage`
+
+---
+
+## 五、配置与环境变量
+
+**lj-test 实测的 `.env` 条目**(供新容器对照):
+
+```
+MATRIX_HOMESERVER
+MATRIX_USER_ID
+MATRIX_ACCESS_TOKEN
+MATRIX_DEVICE_ID
+MATRIX_ALLOWED_USERS
+MATRIX_IGNORE_USER_PATTERNS
+MATRIX_REQUIRE_MENTION
+MATRIX_HOME_ROOM
+MATRIX_HOME_ROOM_NAME
+FMODE_SESSION_TOKEN
+API_SERVER_KEY
+```
+
+**关键项说明**:
+
+| 变量 | 作用 |
+|---|---|
+| `MATRIX_ALLOWED_USERS` | 白名单,限制谁能派活 |
+| `MATRIX_REQUIRE_MENTION` | 必须 @ 才响应(App 群聊依赖此行为) |
+| `MATRIX_AUTO_THREAD` | 自动分线程(**必须 true**,与 App 的会话模型对齐) |
+| `MATRIX_SESSION_SCOPE=auto` | 会话作用域 |
+| `FMODE_SESSION_TOKEN` | 调用平台云函数用 |
+
+---
+
+## 六、检查清单(新容器接入前逐项确认)
+
+### 收文件
+- [ ] 附件走 `m.file` / `m.image` + `content.url = mxc://...`
+- [ ] 用 bot 自己的登录态下载媒体
+- [ ] 单文件 ≤ 20MB
+
+### 回文件
+- [ ] 正文末尾空一行 + `**已上云(可直接下载)**`
+- [ ] 每条链接一行 `- https://s3.fmode.cn/...`
+- [ ] 域名是 `s3.fmode.cn` 或 `file-cloud.fmode.cn`
+- [ ] 链接由真实上传 hook 产生(非手写占位)
+
+### 会话
+- [ ] 回复挂 `m.relates_to` / `rel_type=m.thread`
+- [ ] 沿用 App 给的 thread 根,不自己新开
+- [ ] `MATRIX_AUTO_THREAD=true`
+
+### 输出
+- [ ] 过程分条 + 工具行独占一行 + 固定 emoji 前缀
+- [ ] 结论整段一次发完
+- [ ] **工具行不与中文结论混排在同一消息**
+
+### 状态
+- [ ] 心跳周期 ≤ 80 分钟
+- [ ] 上报 `status` / `currentTask` / `channels` / `extra.currentSessions`
+
+### 结构化标记(强烈推荐,App 侧已就绪)
+- [ ] Matrix adapter 的 `send()` 落 `fmode.kind: "process" | "final"`
+- [ ] **工具进度行的产出链路**也落 `fmode.kind: "process"`(不走 `send()`,需单独改)
+- [ ] 重启 gateway 后用真实房间事件回读,确认字段确实出现在 `content` 里
+
+> 只做第一处是不够的:`send()` 只覆盖旁白,**工具进度行包含「日志吞正文」的主体**。
+
+---
+
+## 六点五、云函数调用:不要依赖 `X-Parse-*` 请求头
+
+**2026-11-11 线上实测(App 侧踩坑,接入方同需注意)**:
+
+`server.fmode.cn` 的 CORS 预检响应**不稳定** —— 连续 10 次 OPTIONS:
+
+| 返回 | 次数 |
+|---|---|
+| `Access-Control-Allow-Headers: *` | 4 |
+| 固定白名单(**无任何 `x-parse-*`**) | 6 |
+
+后端网关多实例、配置不一致。若客户端(尤其是**浏览器**)调用云函数时带了
+`X-Parse-Application-Id` / `X-Parse-Session-Token` 之类的头,就会**间歇性被 CORS 拦**,
+表现为「时好时坏、不影响主流程但 Console 一直报错」。
+
+**正确做法**:云函数调用只带 `Content-Type: application/json`,
+鉴权用 **body 里的 `token` 字段**(云函数 `_sessionToken()` 支持 `params.token` 回退)。
+
+```js
+fetch('https://server.fmode.cn/api/functions', {
+  method: 'POST',
+  headers: { 'Content-Type': 'application/json' },   // ← 不要加 X-Parse-*
+  body: JSON.stringify({ id: '<函数objectId>', token: sessionToken, params: {...} }),
+})
+```
+
+> **服务端到服务端**调用不受 CORS 约束,带不带这些头都无所谓;
+> 这条只对**浏览器直连**成立。
+
+---
+
+## 七、已知坑(lj-test 踩过的)
+
+| # | 坑 | 说明 |
+|---|---|---|
+| 1 | https 塞进 `url` 字段 | 被适配器**静默丢弃**,没有任何报错 |
+| 2 | 手写文件链接 | 交付插件会删掉未真实推送的 https |
+| 3 | 写 `MEDIA:/opt/data/...` 占位 | 被插件整行删除(防假附件) |
+| 4 | `_interim_send` 当作已落库 | Matrix adapter **没有消费它**,字段不上线 |
+| 5 | 工具行与结论混排 | App 按行拆分时会把结论误判成过程 |
+| 6 | 回复不挂 thread | App 判成新会话,对话碎片化 |
+| 7 | 心跳间隔 >80 分钟 | App 判离线,用户看到灰色 |
+
+---
+
+## 八、验收标准
+
+新容器接入后,用以下 3 个测试确认:
+
+| # | 测试 | 通过标准 |
+|---|---|---|
+| 1 | 用户在 App 发一张图 + 一句"看看这个" | 容器能读取图片内容并描述 |
+| 2 | 容器生成一个 .md 文件 | App 右栏「成果」出现文件卡片,**刷新后仍在** |
+| 3 | 容器跑一个 bash 命令并总结 | 命令进「运行了 N 步」折叠区,**总结在正文里** |
+
+---
+
+## 附:本规范的 App 侧对应实现(便于对齐)
+
+| App 侧 | 文件 | 作用 |
+|---|---|---|
+| 交付段落识别 | `core/s3-link.ts:53` | `**已上云(可直接下载)**` 正则 |
+| 过程/正文拆分 | `core/message-segments.ts` | 工具 emoji / `· 工具名` / 裸命令启发式 |
+| 回合视图 | `core/assistant-turn.ts` | 折叠区与正文分区 |
+| 心跳阈值 | `core/agent-liveness.ts` | 80 分钟 |
+| 文件台账 | 云函数 `THXAOGKiPK` | 按会话/群隔离 |
+| 文件读取代理 | 云函数 `tocXyQjyh6` | 绕过 S3 CORS |

+ 28 - 0
scripts/ask-seq.mjs

@@ -0,0 +1,28 @@
+#!/usr/bin/env node
+import { readFileSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { join } from 'node:path';
+const F='https://server.fmode.cn/api/functions', T='agent-node-lj-test';
+const sleep=(ms)=>new Promise(r=>setTimeout(r,ms));
+const t=JSON.parse(readFileSync(join(homedir(),'.fmode/config/user.json'),'utf8'))?.sessionToken;
+async function prov(id){const r=await fetch(F,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:'RP5Ht55NeX',token:t,params:{agentId:id}})});const j=await r.json();const d=j.result??j;if(!d?.accessToken)throw new Error('provision 失败');return d;}
+const QS=JSON.parse(process.argv[2]||'[]');
+const p1=await prov(T), agent=await prov(T);
+const mx=await import('matrix-js-sdk');
+const c=mx.createClient({baseUrl:p1.homeserver,accessToken:p1.accessToken,userId:p1.userMxId});
+const room=(await c.createRoom({invite:[agent.agentMxId],name:`seq-${Date.now()}`,preset:mx.Preset.TrustedPrivateChat})).room_id;
+const hs=p1.homeserver.replace(/\/$/,''), auth={Authorization:`Bearer ${p1.accessToken}`};
+for(let i=0;i<30;i++){await sleep(3000);const j=await(await fetch(`${hs}/_matrix/client/v3/rooms/${encodeURIComponent(room)}/joined_members`,{headers:auth})).json().catch(()=>({}));if((j.joined??{})[agent.agentMxId])break;}
+const seen=new Set();
+for(const q of QS){
+  console.log(`\n===== 问 =====\n${q}\n`);
+  const s=await c.sendEvent(room,'m.room.message',{msgtype:'m.text',body:q,'m.mentions':{user_ids:[agent.agentMxId]}});
+  seen.add(s.event_id);
+  let buf='',lastNew=Date.now();const stop=Date.now()+420_000;
+  while(Date.now()<stop){await sleep(5000);let added=false;
+    try{const j=await(await fetch(`${hs}/_matrix/client/v3/rooms/${encodeURIComponent(room)}/messages?dir=b&limit=80`,{headers:auth})).json();
+      for(const ev of (j.chunk??[]).reverse()){if(ev.type!=='m.room.message'||seen.has(ev.event_id)||ev.sender===p1.userMxId)continue;seen.add(ev.event_id);buf+=String(ev.content?.body??'')+'\n';added=true;}}catch{}
+    if(added)lastNew=Date.now();else if(buf&&Date.now()-lastNew>30000)break;}
+  console.log(buf.trim()||'(无回复)');
+}
+console.log('\n房间:',room);c.stopClient();

+ 31 - 0
scripts/verify-fmode-kind.mjs

@@ -0,0 +1,31 @@
+#!/usr/bin/env node
+/** 直读 Matrix 事件,确认 content 里真的有 fmode.kind */
+import { readFileSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { join } from 'node:path';
+const F='https://server.fmode.cn/api/functions', T='agent-node-lj-test';
+const sleep=(ms)=>new Promise(r=>setTimeout(r,ms));
+const t=JSON.parse(readFileSync(join(homedir(),'.fmode/config/user.json'),'utf8'))?.sessionToken;
+async function prov(id){const r=await fetch(F,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:'RP5Ht55NeX',token:t,params:{agentId:id}})});const j=await r.json();const d=j.result??j;if(!d?.accessToken)throw new Error('provision 失败');return d;}
+const p1=await prov(T), agent=await prov(T);
+const mx=await import('matrix-js-sdk');
+const c=mx.createClient({baseUrl:p1.homeserver,accessToken:p1.accessToken,userId:p1.userMxId});
+const room=(await c.createRoom({invite:[agent.agentMxId],name:`kind-${Date.now()}`,preset:mx.Preset.TrustedPrivateChat})).room_id;
+const hs=p1.homeserver.replace(/\/$/,''), auth={Authorization:`Bearer ${p1.accessToken}`};
+for(let i=0;i<30;i++){await sleep(3000);const j=await(await fetch(`${hs}/_matrix/client/v3/rooms/${encodeURIComponent(room)}/joined_members`,{headers:auth})).json().catch(()=>({}));if((j.joined??{})[agent.agentMxId])break;}
+console.log('房间:', room);
+await c.sendEvent(room,'m.room.message',{msgtype:'m.text',body:'跑一条命令看看当前目录,然后告诉我结果。','m.mentions':{user_ids:[agent.agentMxId]}});
+console.log('已提问,等待 100 秒收集事件…');
+await sleep(100_000);
+const j=await(await fetch(`${hs}/_matrix/client/v3/rooms/${encodeURIComponent(room)}/messages?dir=b&limit=60`,{headers:auth})).json();
+let found=0, total=0;
+for(const ev of (j.chunk??[]).reverse()){
+  if(ev.type!=='m.room.message'||ev.sender===p1.userMxId) continue;
+  total++;
+  const k=ev.content?.['fmode.kind'];
+  if(k) found++;
+  const body=String(ev.content?.body??'').slice(0,60).replace(/\n/g,' ');
+  console.log(`  [${k ?? '∅'}] ${body}`);
+}
+console.log(`\n汇总:容器消息 ${total} 条,其中带 fmode.kind 的 ${found} 条`);
+c.stopClient();

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

@@ -297,6 +297,26 @@ export interface Message {
    *    回合聚合靠它把回复归到正确的那条提问上。
    */
   replyTo?: string;
+  /**
+   * 容器声明的消息类别(2026-11-11,Matrix `content["fmode.kind"]`)。
+   *
+   * ## 为什么需要
+   *
+   * App 一直靠**文本启发式**区分「工具执行过程」与「正文」
+   * (emoji 前缀、`· 工具名` 行、裸命令行),正文里出现代码块或命令行就会误判
+   * —— 这是「日志吞正文」反复出问题的根源。
+   *
+   * 容器侧(lj-test)核查后确认:网关的 `_interim_send` 标记在 Matrix adapter
+   * **没有被写进消息内容**,所以 App 拿不到任何结构化信号。
+   * 已请容器在 `send()` 里把它落成 `content["fmode.kind"]`。
+   *
+   * ## 语义
+   *
+   * - `'process'`:工具执行/进度行,应进折叠区
+   * - `'final'`:正文/结论,应直接显示
+   * - **缺省**:容器未改造 → App **回退到启发式**(保证老容器照常可用)
+   */
+  fmodeKind?: 'process' | 'final';
 }
 
 // ---------- 成果与项目 ----------

+ 12 - 3
src/app/core/api/parse-api.spec.ts

@@ -45,15 +45,24 @@ describe('ParseApi', () => {
   // ---------- 请求形状(契约 §1.1) ----------
 
   describe('云函数调用协议(API-契约 §1.1)', () => {
-    it('POST 到 /api/functions,带 appId 头与 {id, token, params} 体', async () => {
+    it('POST 到 /api/functions,带 {id, token, params} 体', async () => {
       stubFetch([{ body: { code: 200, agents: [] } }]);
       await api.fetchAgents();
 
       expect(calls[0].url).toContain('/api/functions');
       expect(calls[0].init.method).toBe('POST');
       const headers = calls[0].init.headers as Record<string, string>;
-      expect(headers['X-Parse-Application-Id']).toBe('ncloudmaster');
-      expect(headers['X-Parse-Session-Token']).toBe('r:session-token-abc');
+      /*
+       * 🔴 云函数调用**不能带任何 `X-Parse-*` 头**(2026-11-11 线上 CORS 实测)。
+       *
+       * 服务端网关多实例、预检响应不一致:固定白名单里没有任何 `x-parse-*`,
+       * 命中那台就整条请求被 CORS 拦(实测 10 次里 6 次被拒)。
+       * 第一轮只去掉 app-id,问题立刻在 session-token 上重现 —— 所以全部不带。
+       */
+      const parseHeaders = Object.keys(headers).filter((k) => k.toLowerCase().startsWith('x-parse-'));
+      expect(parseHeaders).withContext('会触发线上 CORS 预检失败').toEqual([]);
+      /* 鉴权改由 body 的 token 承担 */
+      expect(bodyOf()['token']).toBe('r:session-token-abc');
 
       const body = bodyOf();
       expect(body['id']).toBeTruthy();

+ 73 - 10
src/app/core/api/parse-api.ts

@@ -834,20 +834,83 @@ export class ParseApi extends FmodeApi {
     return (await res.json()) as T;
   }
 
+  /**
+   * 云函数调用用的请求头。
+   *
+   * 🔴 **不要带 `X-Parse-Application-Id`**(2026-11-11 线上实测)。
+   *
+   * 线上 `https://ai.fmode.cn` 调用 `server.fmode.cn/api/functions` 时,
+   * 浏览器预检直接被拒:
+   *
+   *   Request header field x-parse-application-id is not allowed
+   *   by Access-Control-Allow-Headers in preflight response.
+   *
+   * 根因在**服务端网关**:预检响应取决于 `Access-Control-Request-Headers`
+   * 里各字段的**大小写**——
+   *   · 全小写 → `Access-Control-Allow-Headers: *`(放行)
+   *   · 首字母大写(浏览器实际发的 `Content-Type, X-Parse-Application-Id`)
+   *     → 回固定白名单,**里面没有 x-parse-application-id** → 被拒
+   *
+   * 而这个头对云函数**完全无用**:鉴权靠 URL 里的函数 `id` + body 里的
+   * `token`(/`X-Parse-Session-Token`)。实测去掉后调用正常返回。
+   *
+   * 开发态之所以没暴露:`environment.ts` 的 `functionsBase` 是相对路径
+   * `/api/functions`,同源、不走预检。
+   */
+  /**
+   * 云函数调用用的请求头 —— **一个 `X-Parse-*` 都不带**。
+   *
+   * ## 为什么(2026-11-11 线上实测,两轮踩坑)
+   *
+   * 线上 `https://ai.fmode.cn` 跨域调 `server.fmode.cn/api/functions`,
+   * 浏览器先发 OPTIONS 预检。**服务端的预检响应不稳定**:
+   *
+   * ```
+   * 连续 10 次 OPTIONS 统计:
+   *   Access-Control-Allow-Headers: *                        → 4 次(放行)
+   *   Access-Control-Allow-Headers: Authorization,DNT,...,Content-Type,Range → 6 次(拒)
+   * ```
+   *
+   * 后端的固定白名单里**没有任何 `x-parse-*`**。网关有多个实例、配置不一致,
+   * 所以表现为「时好时坏」:4/10 成功 → 用户看到部分请求报 CORS,
+   * 但因为请求彼此独立,主流程往往还能走通。
+   *
+   * 第一轮只去掉了 `X-Parse-Application-Id`,问题立刻在
+   * `X-Parse-Session-Token` 上重现 —— 逐个删是治标的。现在**全部不带**。
+   *
+   * ## 不带头怎么鉴权
+   *
+   * 云函数从 body 的 `params.token` 取(`fmode-file-hub.js` 的 `_sessionToken()`
+   * 里 `params.token` 是明确回退路径)。实测只带 body、不带任何头,
+   * 云函数正常返回数据。
+   *
+   * ⚠️ 只对**云函数入口**这样处理;Parse REST / 登录等接口照旧带头,
+   *    不走这条路径。
+   */
   private authHeaders(): Record<string, string> {
-    const h: Record<string, string> = {
-      'Content-Type': 'application/json',
-      'X-Parse-Application-Id': PARSE_APP_ID,
-    };
-    if (this.sessionToken) h['X-Parse-Session-Token'] = this.sessionToken;
-    return h;
+    return { 'Content-Type': 'application/json' };
   }
 
   private rawFetch(url: string, init: RequestInit): Promise<Response> {
-    const headers: Record<string, string> = {
-      'X-Parse-Application-Id': PARSE_APP_ID,
-      ...((init.headers as Record<string, string>) ?? {}),
-    };
+    /*
+     * 🔴 云函数入口**不带任何 `X-Parse-*` 头**(2026-11-11 线上 CORS 实测)。
+     *
+     * 服务端网关有多个实例、预检响应不一致:固定白名单里没有任何 `x-parse-*`,
+     * 命中那一台就把整条请求拦掉(实测 10 次里 6 次被拒)。
+     * 逐个删头是治标 —— 这里统一剥离。
+     *
+     * 云函数鉴权只需 URL 的函数 id + body 里的 `token`(已实测)。
+     * 其它接口(Parse REST、登录等)照旧带 —— 它们确实需要。
+     */
+    const isFn = url === FUNCTION_API;
+    const incoming = (init.headers as Record<string, string>) ?? {};
+    const headers: Record<string, string> = isFn
+      ? {}
+      : { 'X-Parse-Application-Id': PARSE_APP_ID };
+    for (const [k, v] of Object.entries(incoming)) {
+      if (isFn && k.toLowerCase().startsWith('x-parse-')) continue;
+      headers[k] = v;
+    }
     /*
      * 🔴 有 body 的方法必须带 `Content-Type: application/json`。
      *

+ 50 - 0
src/app/core/assistant-turn.spec.ts

@@ -885,3 +885,53 @@ describe('分轮后的用时读数', () => {
     expect(t.blocks[0].startAt).toBe(T0);
   });
 });
+
+/**
+ * 容器声明的消息类别(2026-11-11,`fmode.kind`)。
+ *
+ * 这是根治「日志吞正文」的路径:文本启发式永远有误判
+ * (正文里出现代码块/命令行/英文列表都可能被当成过程)。
+ * 容器改造完成前该字段缺失 → 必须**自动回退**到启发式。
+ */
+describe('fmode.kind 优先于文本启发式', () => {
+  const bodyTextOf = (b: { body: { kind: string; message?: { text?: string } }[] }): string =>
+    b.body
+      .filter((x) => x.kind === 'text')
+      .map((x) => x.message?.text ?? '')
+      .join('\n');
+
+  it("🔴 fmodeKind='final' → 整条进正文,即使内容像命令行", () => {
+    const m = { ...msg('a1', 'agent', 'cd /opt/data && node x.js'), fmodeKind: 'final' as const };
+    const [t] = turnsFromDm([dmTurn([m])], 0, false);
+
+    expect(bodyTextOf(t.blocks[0])).withContext('声明是正文就不能被拆走').toContain('cd /opt/data');
+    expect(t.blocks[0].steps.length).toBe(0);
+  });
+
+  it("🔴 fmodeKind='process' → 整条进过程区,即使含中文结论", () => {
+    const m = { ...msg('a1', 'agent', '正在读取文件,稍等片刻就好。'), fmodeKind: 'process' as const };
+    const [t] = turnsFromDm([dmTurn([m])], 0, false);
+
+    expect(t.blocks[0].steps.length).withContext('声明是过程就整条收起').toBeGreaterThan(0);
+    expect(bodyTextOf(t.blocks[0])).not.toContain('稍等片刻');
+  });
+
+  it('🔴 字段缺失 → 回退启发式(未改造的容器照常工作)', () => {
+    const text = '**已上云(可直接下载)**\n🐍 Running code…\n🐍 Running code…';
+    const [t] = turnsFromDm([dmTurn([msg('a1', 'agent', text)])], 0, false);
+
+    expect(bodyTextOf(t.blocks[0])).withContext('回退后结论仍要在正文').toContain('已上云');
+    expect(t.blocks[0].steps.length).withContext('回退后工具行仍要收起').toBeGreaterThan(0);
+  });
+
+  it('正文与过程混在同一条消息时,按声明各归各位', () => {
+    const msgs = [
+      { ...msg('a1', 'agent', '💻 bash: ls -la'), fmodeKind: 'process' as const },
+      { ...msg('a2', 'agent', '一共 12 个文件。'), fmodeKind: 'final' as const },
+    ];
+    const [t] = turnsFromDm([dmTurn(msgs)], 0, false);
+
+    expect(bodyTextOf(t.blocks[0])).toContain('一共 12 个文件');
+    expect(bodyTextOf(t.blocks[0])).not.toContain('ls -la');
+  });
+});

+ 45 - 0
src/app/core/assistant-turn.ts

@@ -207,6 +207,13 @@ function splitIntoParts(messages: Message[]): {
 } {
   const steps: ProcessStep[] = [];
   const bodies: Message[] = [];
+  /**
+   * 容器声明为 `final` 的消息 id(2026-11-11)。
+   *
+   * 这些消息在 ② 步**不能再被 `divideText` 拆一次** —— 容器已经明确说
+   * 「整条是正文」,正文里出现 `cd /opt/...` 这类行也不该被当成命令挪进过程区。
+   */
+  const forcedFinal = new Set<string>();
 
   for (const m of messages) {
     const text = m.text ?? '';
@@ -228,6 +235,30 @@ function splitIntoParts(messages: Message[]): {
      * 现在交给 `divideText` 逐行判:工具行 / 命令行进过程区,
      * 其余(含结论、markdown 加粗、列表)留在正文。
      */
+
+    /*
+     * 🔴 容器声明了类别就**完全听它的**(2026-11-11)。
+     *
+     * 这是根治「日志吞正文」的路径:文本启发式永远有误判(正文里出现代码块、
+     * 命令行、英文列表都可能被当成过程)。容器自己知道哪条是过程、哪条是结论。
+     *
+     * 容器改造完成前该字段缺失 → 自动落回下面的启发式,行为不变。
+     */
+    if (m.fmodeKind === 'process') {
+      /* 整条进过程区:不再逐行猜,避免把里面的中文切成「正文」 */
+      for (const line of text.split('\n')) {
+        const t = line.trim();
+        if (t) steps.push({ kind: 'tool', label: t });
+      }
+      continue;
+    }
+    if (m.fmodeKind === 'final') {
+      /* 整条是正文:即使里面看起来像命令行,也不拆(②' 步会跳过它) */
+      bodies.push(m);
+      forcedFinal.add(m.id);
+      continue;
+    }
+
     const { steps: s, text: kept } = divideText(text);
     steps.push(...s);
     if (kept.trim()) bodies.push(kept.trim() === text.trim() ? m : { ...m, text: kept.trim() });
@@ -242,6 +273,20 @@ function splitIntoParts(messages: Message[]): {
   const body: TurnBodyBlock[] = [];
 
   for (const mm of merged) {
+    /*
+     * 容器声明为 final 的正文**不做二次拆分**(2026-11-11)。
+     * 合并后仍按 `mergedIds` 判断:只要**所有**参与合并的消息都是 final,
+     * 整段就当正文;否则走启发式(混合情况下以启发式为准更安全)。
+     */
+    const allFinal = mm.mergedIds.length > 0 && mm.mergedIds.every((id) => forcedFinal.has(id));
+    if (allFinal) {
+      if ((mm.text ?? '').trim()) {
+        body.push({ kind: 'text', key: mm.mergedIds[0], message: { ...mm, text: (mm.text ?? '').trim() } });
+      }
+      if (mm.file) body.push({ kind: 'file', key: `${mm.mergedIds[0]}-f`, file: mm.file, message: mm });
+      if (mm.taskId) body.push({ kind: 'task', key: `${mm.mergedIds[0]}-t`, taskId: mm.taskId, message: mm });
+      continue;
+    }
     const { steps: s, text } = divideText(mm.text ?? '');
     steps.push(...s);
 

+ 5 - 0
src/app/core/chat.service.ts

@@ -2435,6 +2435,11 @@ export class ChatService {
         localId: m.localId,
         // 透传「在回复哪一条」:回合聚合据此把回复归到正确的提问(2026-11-10)
         replyTo: m.replyTo,
+        /*
+         * 容器声明的类别(2026-11-11,`content["fmode.kind"]`)。
+         * 有这个字段就**不用再猜**某条消息是过程还是正文;没有则回退启发式。
+         */
+        fmodeKind: m.fmodeKind,
       };
     });
   }

+ 24 - 0
src/app/core/matrix-client.service.ts

@@ -105,6 +105,14 @@ export interface MxMessage {
    *    结果图片那条的回复被算到了文字那条下面。
    */
   replyTo?: string;
+  /**
+   * 容器声明的消息类别(`content["fmode.kind"]`)。
+   *
+   * 2026-11-11:容器侧确认 `_interim_send` 标记没有进 Matrix 内容,
+   * App 只能猜。已请容器把它落成这个自定义键(Matrix 允许 `m.` 以外的键)。
+   * 读不到就是 `undefined` → 调用方回退启发式。
+   */
+  fmodeKind?: 'process' | 'final';
 }
 
 /** 群聊派活元信息(写入 Matrix content 的 `com.fmode.dispatch`) */
@@ -242,6 +250,20 @@ export function extractReplyTo(content: Record<string, unknown> | undefined): st
   return typeof id === 'string' && id ? id : undefined;
 }
 
+/**
+ * 读取容器声明的消息类别(`content["fmode.kind"]`,2026-11-11)。
+ *
+ * 容器改了 Matrix adapter 之后,过程行会带 `"process"`、正文带 `"final"`。
+ * 只认这两个值;其它(含缺失)返回 `undefined` → 调用方回退文本启发式,
+ * 保证**未改造的容器照常工作**。
+ */
+export function extractFmodeKind(
+  content: Record<string, unknown> | undefined,
+): 'process' | 'final' | undefined {
+  const v = content?.['fmode.kind'];
+  return v === 'process' || v === 'final' ? v : undefined;
+}
+
 /**
  * 构造 `m.relates_to`(thread 关系)。
  *
@@ -784,6 +806,7 @@ export class MatrixClientService implements OnDestroy {
         sessionId: extractSessionId(e.getContent()),
         localId: extractLocalId(e.getContent()),
         replyTo: extractReplyTo(e.getContent()),
+        fmodeKind: extractFmodeKind(e.getContent()),
         body: (e.getContent()['body'] as string) ?? '',
         // 原生附件 → 转成 FileCard(mxc:// 在这里转成可访问的 HTTP 地址)
         media: (() => {
@@ -1137,6 +1160,7 @@ export class MatrixClientService implements OnDestroy {
         sessionId: extractSessionId(ev.getContent()),
         localId: extractLocalId(ev.getContent()),
         replyTo: extractReplyTo(ev.getContent()),
+        fmodeKind: extractFmodeKind(ev.getContent()),
         body: (ev.getContent()['body'] as string) ?? '',
         media,
         ts: ev.getTs(),