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

perf(state): 状态同步改为事件驱动 + 连发消息合并为一轮

## 1. 同步延迟优化(用户反馈「左侧右侧不同步,不够及时」)
- ✅ reaction 到达时**主动刷新**容器状态,不再等 15 秒轮询
  根因:LiveService 每 15 秒轮询;✅ 到达时容器刚写完 idle,最坏要等 15 秒
- 左侧会话树新增**会话级**本地标记:点发送瞬间就转圈,不等容器上报
  (用会话级而非容器级,避免同一容器其他会话跟着转)
  容器确认该会话 idle 后自动清除标记

## 2. 连发消息合并为一轮(用户裁决)
「用户提出第一个需求,再提出第二个需求,后续所有回答都在第二个需求下展示」
- 判据从「带附件才合并」改为「上一回合还没收到任何容器回复」
- 新增 Turn.priorAsks:前面的提问保留展示,最后一条作为当前 userMessage
- 双端渲染先展示 priorAsks 再展示 userMessage

## 3. 文档
- 容器接入指南:六件最容易做错的事;currentSessions 六个字段逐一说明
- 新增「提示词-容器自我优化」:发给容器,6 项自查 + 交付格式 + 三条纪律

## 验证
tsc ✅ · 1676 条测试全过 ✅ · 构建 ✅
**未推送、未部署**

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

+ 56 - 9
docs/03-interface/容器接入完整指南-v2-20261111.md

@@ -10,14 +10,21 @@
 
 
 ---
 ---
 
 
-## 零、先读这一节:三件最容易做错的事
+## 零、先读这一节:最容易做错的六件事
 
 
-做错这三件,接入**看起来通了但体验很差**,而且很难排查。
+做错这些,接入**看起来通了但体验很差**,而且很难排查。
 
 
 | # | 错误做法 | 后果 | 正确做法 |
 | # | 错误做法 | 后果 | 正确做法 |
 |---|---|---|---|
 |---|---|---|---|
 | 1 | 用 `m.typing=false` 当「本轮结束」信号 | 实测早于正文 0.6 秒 → App 在正文到达前就收尾,用户看到「已完成」但没内容 | **用 ✅ reaction**(Hermes 内建,无需改造) |
 | 1 | 用 `m.typing=false` 当「本轮结束」信号 | 实测早于正文 0.6 秒 → App 在正文到达前就收尾,用户看到「已完成」但没内容 | **用 ✅ reaction**(Hermes 内建,无需改造) |
 | 2 | 回复不挂 thread,也不做会话归属 | App 判成新会话,对话碎片化 | 沿用 App 给的 thread;详见 §三 |
 | 2 | 回复不挂 thread,也不做会话归属 | App 判成新会话,对话碎片化 | 沿用 App 给的 thread;详见 §三 |
+| 3 | **`currentSessions` 只报容器级、不区分会话** | **容器在跑会话 A,用户在会话 B 也看到 A 的进度(串台)** | 必须按会话上报,并带 `userId` + `task`;见 §五点三 |
+| 4 | **心跳不带 `currentSessions`** | App 拿到的会话状态是过时的;turn 结束后界面迟迟不纠正 | 心跳也要带上;见 §五点三 |
+| 5 | **正文用 `~` 表示数值范围**(`8~16℃`) | 成对的 `~` 被 markdown 解析成 `~~删除线~~`,整段被划掉 | 用全角 `~` 或「至」。App 已兜底,仍建议规范 |
+| 6 | **误以为 App 不支持 Markdown 表格** | 主动降级成「标签: 值」列表,用户拿不到表格 | **App 完整支持**(`ngx-markdown` + 完整表格样式)。直接用 `\| a \| b \|` 即可 |
+
+> 🔴 第 3、4 条是 2026-11-11 实测发现的高频问题,**直接导致用户投诉「状态串台」**。
+
 | 3 | 正文用 `~` 表示数值范围(`8~16℃`) | 成对的 `~` 被 markdown 解析成 `~~删除线~~`,整段被划掉 | 用全角 `~` 或「至」。App 侧已兜底,但容器侧也应规范 |
 | 3 | 正文用 `~` 表示数值范围(`8~16℃`) | 成对的 `~` 被 markdown 解析成 `~~删除线~~`,整段被划掉 | 用全角 `~` 或「至」。App 侧已兜底,但容器侧也应规范 |
 
 
 ---
 ---
@@ -185,20 +192,60 @@ POST https://server.fmode.cn/api/functions
 **App 侧离线判定阈值:80 分钟**(`core/agent-liveness.ts` `HEARTBEAT_STALE_SEC`)。
 **App 侧离线判定阈值:80 分钟**(`core/agent-liveness.ts` `HEARTBEAT_STALE_SEC`)。
 心跳周期必须小于它。
 心跳周期必须小于它。
 
 
-### 5.3 `extra.currentSessions`(会话级运行态)
+### 5.3 `extra.currentSessions`(会话级运行态)**——接入第二条必做**
+
+> 🔴 这是「状态串台」的根治点。**不做这一条,App 只能拿容器全局状态冒充会话状态,
+> 用户会看到「打开会话 A,显示的却是会话 B 的进度」。**
+
+#### 数据结构(6 个字段,每个都有用)
 
 
 ```json
 ```json
 { "currentSessions": [
 { "currentSessions": [
-    { "sessionKey": "agent:main:matrix:dm:!roomId:threadId", "status": "running", "turnStartAt": 1699999999000 }
+    {
+      "sessionKey": "!roomId 或 $approot_xxx",
+      "userId": "@用户的mxid:fmode.cn",
+      "status": "running",
+      "task": "本轮用户提问的首行,截断 60 字",
+      "turnStartAt": 1699999999000,
+      "updatedAt": 1699999999000,
+      "ttlMs": 600000
+    }
 ]}
 ]}
 ```
 ```
 
 
-**App 靠它区分「这条会话在跑」还是「别的会话在跑」**。没有它,会话页只能显示容器全局状态
-→ 用户会看到「打开会话 A,右上角显示会话 B 的进度」。
+| 字段 | 用途 | 不做会怎样 |
+|---|---|---|
+| `sessionKey` | App 用它匹配「这条是不是当前打开的会话」 | 完全对不上,状态永远不显示 |
+| `userId` | 多人共用容器时区分「谁在跑」 | A 在跑时 B 也显示「正在回复」 |
+| `status` | running / idle | 无法判断 |
+| `task` | 顶栏显示「**本会话**在做什么」 | 只能显示「正在处理」通用文案 |
+| `turnStartAt` / `updatedAt` / `ttlMs` | 时效与清理 | 长任务可能被误判过期 |
+
+#### `sessionKey` 的取值优先级(**实测关键**)
+
+```
+① thread_id(形如 $approot_xxx 或 $event_id)  ← 最精确,一个房间多会话也不串
+② chat_id(roomId,形如 !xxx)                 ← 退路:DM 通道 thread_id 常为 NULL
+③ Hermes 内部 id(如 20261111_195708_xxx)     ← 兜底,App 匹配不上
+```
+
+实测数据(`state.db` 219 条会话):matrix 会话里 `thread_id` **有值 117 条(54%)**,
+其中 `$approot_` 前缀 75 条。所以**优先报 thread_id,拿不到再退 roomId**。
+
+#### 三条硬性要求
 
 
-> ⚠️ **lj-test 当前的问题(实测)**:`currentSessions` **只在 turn 活动时写**,心跳不带它;
-> 且状态上报 cron(`*/2`)的 `enabled=False`,实际已停摆(最后一次成功 11-07 22:42)。
-> 新容器接入时**务必让心跳也带上它**,否则 App 会话列表状态会不全。
+1. **每个会话单独一条记录**,不要只报一个容器级状态
+2. **心跳也要带上** `currentSessions`(lj-test 初版只在 turn 首尾写,导致状态滞后)
+3. **`idle` 记录要有清理机制**(lj-test 用 10 分钟 TTL),否则列表无限增长
+
+#### 常见错法
+
+| 错法 | 后果 |
+|---|---|
+| 只在上报时写容器级 `status` / `currentTask` | App 回落到全局 → 串台 |
+| `sessionKey` 报 Hermes 内部 id | App 用 `$approot_xxx` 匹配不上 → 永远「不知道」 |
+| 不带 `userId` | 多人共用时互相干扰 |
+| 心跳不带(只在 turn 时写) | turn 结束后界面迟迟不纠正,最长等 15 秒轮询 |
 
 
 ---
 ---
 
 

+ 190 - 0
docs/03-interface/提示词-容器自我优化-v2-20261111.md

@@ -0,0 +1,190 @@
+# 提示词:让容器按 Fmode App 最新规范自我优化
+
+> **用法**:整份发给容器,它会自己对照检查、改代码、验证、回报。
+> **依据**:全部来自 2026-11-11 对 `agent-node-lj-test` 的实测与代码取证。
+> **配套文档**:`docs/03-interface/容器接入完整指南-v2-20261111.md`
+
+---
+
+## 你的任务
+
+对照下面 **6 条检查项**逐条自查你当前的实现。
+每条给出:**当前状态 + 证据(文件:行号 或 实测输出)+ 需要改什么**。
+
+**做不到的如实说「做不到 + 原因」,不要声称已完成。** App 侧会回读 Matrix 事件和云函数数据核实。
+
+---
+
+## 检查项 1:本轮结束信号 —— 用 ✅ reaction,不要用 typing
+
+### 背景(实测数据)
+```
++6.7s   typing=true
++14.8s  typing=false        ← 早于正文 0.6 秒 ✗
++15.3s  消息「3 乘以 7 等于 21。」
++15.4s  reaction ✅         ← 晚于正文 ✓
+```
+
+### 要求
+- [ ] `MATRIX_REACTIONS=true`(别关)
+- [ ] 每轮结束时你会打出 ✅(失败打 ❌)
+- [ ] **不要**用 `m.typing=false` 当结束信号(时序天然是反的)
+- [ ] 如果之前装过发 typing 的插件,**禁用掉**
+
+### 自查命令
+```bash
+grep -n "MATRIX_REACTIONS" /opt/data/.env
+hermes plugins list | grep -i typing
+```
+
+---
+
+## 检查项 2:`currentSessions` 按会话上报(**最容易做错**)
+
+### 背景
+用户实测投诉:「在其他会话里也能看到当前会话的容器状态」。
+根因:你只报了容器级 `status`,App 只能拿它冒充会话状态。
+
+### 要求
+每条 `currentSessions` 记录必须包含:
+
+```json
+{
+  "sessionKey": "!roomId 或 $approot_xxx",
+  "userId": "@用户的mxid:fmode.cn",
+  "status": "running",
+  "task": "本轮用户提问首行,60 字内",
+  "turnStartAt": 1699999999000,
+  "updatedAt": 1699999999000,
+  "ttlMs": 600000
+}
+```
+
+| 字段 | 要求 |
+|---|---|
+| `sessionKey` | 优先 `thread_id`(`$approot_xxx`),拿不到退 `chat_id`(`!xxx`) |
+| `userId` | 从 `state.db.sessions.user_id` 查,查不到就省略(别写空串) |
+| `task` | `pre_llm_call` 的 `user_message` 首行,60 字;**只在 running 时有值** |
+| `status` | running / idle |
+
+### 自查命令
+```bash
+python3 -c "
+import sqlite3,json
+c=sqlite3.connect('file:/opt/data/state.db?mode=ro',uri=True)
+for r in c.execute('SELECT id,thread_id,chat_id,user_id FROM sessions ORDER BY started_at DESC LIMIT 5'):
+    print(r)
+"
+```
+
+回答:你现在上报的 `sessionKey` 用的是哪个字段?`userId` 和 `task` 有没有?
+
+---
+
+## 检查项 3:心跳必须带 `currentSessions`
+
+### 背景
+只在 turn 首尾写 `currentSessions` 的话,turn 结束后状态会滞后,
+App 最长要等 15 秒轮询才纠正。
+
+### 要求
+- [ ] 心跳(每 3600s + 0~900s 抖动)**也要带上** `currentSessions`
+- [ ] `idle` 记录有 TTL 清理(建议 10 分钟)
+
+### 自查
+回答:你的心跳上报函数在哪(文件:行号)?有没有带 `currentSessions`?
+
+---
+
+## 检查项 4:正文排版规范
+
+### 4.1 不要用 `~` 表示数值范围
+```
+❌ 全天 8~16℃        ← 两个 ~ 会被 markdown 解析成 ~~删除线~~,整段被划掉
+✅ 全天 8~16℃ 或 全天 8 至 16℃
+```
+App 已做兜底(转成 HTML 实体),但仍建议容器侧规范。
+
+### 4.2 **App 完整支持 Markdown 表格**(重要)
+实测:`marked.parse('| a | b |\n|---|---|')` → 正常输出 `<table>`,
+且 `md-render.ts:212` 有完整表格样式(边框、内边距、首列宽度)。
+
+**所以不要因为「Matrix 客户端不支持表格」就降级成「标签: 值」列表。**
+直接写标准 Markdown 表格即可:
+```markdown
+| 城市 | AQI | 等级 |
+|---|---|---|
+| 北京 | 95 | 良 |
+```
+
+### 4.3 过程行独占一行
+工具进度行以固定 emoji 开头(`💻` `🔍` `📖` `✍️` `🐍` `⚙️`),
+**不要和中文结论混在同一行**。
+
+### 自查
+回答:你最近一轮回复里,有没有出现「因为客户端不支持表格所以改用列表」这种降级?
+如果有,改成表格。
+
+---
+
+## 检查项 5:会话归属(thread)
+
+### 要求
+- [ ] 回复**沿用 App 给的 thread 根**,不自己新开
+- [ ] 如果收到带 `m.relates_to{rel_type:m.thread, event_id:$approot_xxx}` 的消息,
+      回复也要带同样的 thread 关系
+
+### 自查
+```bash
+python3 -c "
+import sqlite3
+c=sqlite3.connect('file:/opt/data/state.db?mode=ro',uri=True)
+q='SELECT COUNT(*), SUM(thread_id IS NOT NULL AND thread_id!=\"\") FROM sessions WHERE source=\"matrix\"'
+print('matrix 总数 / 有 thread:', c.execute(q).fetchone())
+"
+```
+回答:你的 matrix 会话里 thread 覆盖率多少?没有 thread 的那些是什么来源?
+
+---
+
+## 检查项 6:能力边界(决定哪些能改)
+
+```bash
+ls -ld /opt/hermes /opt/data
+test -w /opt/data/plugins && echo "plugins 可写" || echo "不可写"
+```
+
+回答:
+- `/opt/hermes` 你能写吗?
+- `/opt/data` 你能写吗?
+- 插件放哪、怎么启停、怎么让配置生效?
+
+---
+
+## 交付格式
+
+| 检查项 | 当前状态 | 证据 | 要改什么 |
+|---|---|---|---|
+| 1. 结束信号 | | 文件:行号 / 实测 | |
+| 2. currentSessions | | | |
+| 3. 心跳带会话 | | | |
+| 4. 排版规范 | | | |
+| 5. thread 归属 | | | |
+| 6. 能力边界 | | | |
+
+最后给一段结论:**哪些已满足、哪些要改、哪些做不到及原因**。
+
+---
+
+## ⚠️ 三条纪律
+
+1. **不要声称「已完成」而不给证据** —— App 侧会回读 Matrix 事件和云函数数据核实。
+   之前有容器说「改完了」,实际改的是只读文件,一个字没生效。
+2. **做不到就说做不到** —— `/opt/hermes` 无写权限是环境限制,不是你的错。
+   如实说明比假装完成有用得多。
+3. **改完必须实测回读** —— 用 `/sync` 看真实事件,或直接回读云函数 `extra`。
+   不要只看日志、看自己发的文本。
+
+---
+
+*本提示词基于 2026-11-11 的实测与代码取证编写。所有结论均可验证,未做推测。*

+ 13 - 0
src/app/components/domain/session-tree/session-tree.ts

@@ -6,6 +6,7 @@ import { UiAvatar, UiButton, UiEmpty, UiInput, UiModal, UiSkeleton, UiSwitch, 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';
 import { ChatSessionService } from '../../../core/chat-session.service';
 import { ChatSessionService } from '../../../core/chat-session.service';
+import { TaskPulseService } from '../../../core/task-pulse.service';
 import { Workspace, WorkspaceService } from '../../../core/workspace.service';
 import { Workspace, WorkspaceService } from '../../../core/workspace.service';
 import { PromptModalService } from '../prompt-modal/prompt-modal.service';
 import { PromptModalService } from '../prompt-modal/prompt-modal.service';
 import { NavMenuService } from '../../../core/nav-menu.service';
 import { NavMenuService } from '../../../core/nav-menu.service';
@@ -1836,6 +1837,7 @@ export class SessionTreeComponent implements OnDestroy {
   /** 新建对话弹窗(群聊分组头的「+」直达群聊标签) */
   /** 新建对话弹窗(群聊分组头的「+」直达群聊标签) */
   private readonly newSessionModal = inject(NewSessionModalService);
   private readonly newSessionModal = inject(NewSessionModalService);
   private readonly liveSvc = inject(LiveService);
   private readonly liveSvc = inject(LiveService);
+  private readonly pulseSvc = inject(TaskPulseService);
   private readonly unreadSvc = inject(UnreadService);
   private readonly unreadSvc = inject(UnreadService);
   private readonly session = inject(SessionService);
   private readonly session = inject(SessionService);
   private readonly router = inject(Router);
   private readonly router = inject(Router);
@@ -2503,6 +2505,17 @@ export class SessionTreeComponent implements OnDestroy {
   protected sessionRunningOf(s: ChatSession): boolean {
   protected sessionRunningOf(s: ChatSession): boolean {
     const agent = this.liveSvc.agentById(s.agentId);
     const agent = this.liveSvc.agentById(s.agentId);
     if (!agent) return false;
     if (!agent) return false;
+    /*
+     * 🔴 本地「刚发出」优先(2026-11-11 用户反馈「左侧转动出现较晚」)。
+     *
+     * 容器上报 `currentSessions` 是 turn 级事件,从「用户点发送」到
+     * 「容器写入 running」之间有几秒空档,这期间左侧列表不转圈 ——
+     * 用户看着消息已发出、列表却没反应。
+     *
+     * 用**会话级**标记(不是容器级):同一容器的其他会话不会跟着转。
+     */
+    const localAt = this.pulseSvc.sessionLocalStartAt(s.id);
+    if (localAt !== undefined) return true;
     /*
     /*
      * 传 `s.roomId` 作为退路:容器上报的 sessionKey 在 DM 通道下常只有 roomId
      * 传 `s.roomId` 作为退路:容器上报的 sessionKey 在 DM 通道下常只有 roomId
      * (`thread_id` 为 NULL,实测 46%),光靠 thread 根匹配不上。
      * (`thread_id` 为 NULL,实测 46%),光靠 thread 根匹配不上。

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

@@ -891,6 +891,18 @@ export class ChatService {
         if (my !== this.seq) return;
         if (my !== this.seq) return;
         this._turnEndedAt.set(Date.now());
         this._turnEndedAt.set(Date.now());
         this._turnEndedAfter.set(this.lastAgentMessageId());
         this._turnEndedAfter.set(this.lastAgentMessageId());
+        /*
+         * 🔴 立刻拉一次容器状态,不等 15 秒轮询(2026-11-11 用户反馈)。
+         *
+         * 用户实测:「容器已经回复完了,右上角还显示『正在处理』」。
+         * 根因:`LiveService` 每 15 秒轮询一次容器状态,
+         * 而 ✅ 到达时容器**刚写完** idle —— 最坏要等 15 秒才同步到界面。
+         *
+         * ✅ 是「这一轮真的结束了」的确定信号,此刻刷新一次最准。
+         * `load(true)` 是静默刷新(不改 loading 态、不变引用则不动 UI),
+         * 不会造成闪烁;一轮只多一次请求,开销可忽略。
+         */
+        void this.live.load(true);
       };
       };
       setTimeout(stamp, 300);
       setTimeout(stamp, 300);
     }) ?? null;
     }) ?? null;
@@ -1529,6 +1541,12 @@ export class ChatService {
      * 只有容器报 `idle` 才能把它翻回去(前端绝不自己说「完成」)。
      * 只有容器报 `idle` 才能把它翻回去(前端绝不自己说「完成」)。
      */
      */
     this.pulse.noteUserAction(conversationId);
     this.pulse.noteUserAction(conversationId);
+    /*
+     * 会话级标记(2026-11-11):左侧会话树的旋转图标也靠它立刻转起来。
+     * 容器级标记会让同行所有会话一起转 —— 那正是要避免的串台。
+     */
+    const currentSessionId = this._sessionId();
+    if (currentSessionId) this.pulse.noteSessionUserAction(currentSessionId);
 
 
     try {
     try {
       /*
       /*

+ 41 - 0
src/app/core/task-pulse.service.ts

@@ -172,6 +172,42 @@ export class TaskPulseService {
   /** 当前会话是否在「本地推测的忙碌」中(容器明确说 idle 时自动解除) */
   /** 当前会话是否在「本地推测的忙碌」中(容器明确说 idle 时自动解除) */
   localStartAt = (agentId: string): number | undefined => this._localStart().get(agentId);
   localStartAt = (agentId: string): number | undefined => this._localStart().get(agentId);
 
 
+  /**
+   * **会话级**的「用户刚在这里发了消息」标记(2026-11-11)。
+   *
+   * 🔴 为什么不能用 `localStartAt`(容器级):同一个容器下有多个会话,
+   *    容器级标记会让**所有会话**同时转圈 —— 正是用户抱怨的「串台」。
+   *
+   * 这里按**会话 id** 记录,只有真正发出消息的那条会话才转。
+   * 生命周期与容器级一致:容器明确说该会话 idle 时自动失效(见 `describe`)。
+   */
+  private readonly _localSessionStart = signal<ReadonlyMap<string, number>>(new Map());
+
+  /** 用户在某条会话里刚发出消息(仅前端展示,不改服务端) */
+  noteSessionUserAction(sessionId: string): void {
+    if (!sessionId) return;
+    const next = new Map(this._localSessionStart());
+    next.set(sessionId, Date.now());
+    this._localSessionStart.set(next);
+  }
+
+  /** 该会话是否处在「刚发出、容器还没确认」的窗口内 */
+  sessionLocalStartAt = (sessionId: string): number | undefined =>
+    this._localSessionStart().get(sessionId);
+
+  /**
+   * 容器确认该会话结束后清掉本地标记。
+   *
+   * 只在「确实存在」时才写信号,避免 compute 里无谓地触发重算。
+   */
+  private clearSessionLocalStart(sessionId: string): void {
+    const cur = this._localSessionStart();
+    if (!cur.has(sessionId)) return;
+    const next = new Map(cur);
+    next.delete(sessionId);
+    this._localSessionStart.set(next);
+  }
+
   /**
   /**
    * 是否在忙(供 UI 判定)。
    * 是否在忙(供 UI 判定)。
    *
    *
@@ -292,6 +328,11 @@ export class TaskPulseService {
       if (quietMs < REPLY_QUIET_MS) {
       if (quietMs < REPLY_QUIET_MS) {
         return { text: '正在回复…', tone: 'info', active: true };
         return { text: '正在回复…', tone: 'info', active: true };
       }
       }
+      /*
+       * 容器已确认结束 → 清掉本地「刚发出」标记(2026-11-11)。
+       * 不清的话左侧会话树的旋转图标会一直转下去。
+       */
+      if (sessionId) this.clearSessionLocalStart(sessionId);
       return { text: '在线', tone: 'success' };
       return { text: '在线', tone: 'success' };
     }
     }
     /*
     /*