|
@@ -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 |
|