Преглед изворни кода

fix: 三处状态同步 + 新建会话首条丢失 + 连发合并 + running 过期保护

## 1. 新建会话首条消息不显示(用户实测)
现象:点「新建对话」后立刻输入按回车,文字留在输入框、消息不出现,第二条才正常。
根因:`startBlank()` 内部 `await open()`(provision + Matrix 连接)要几秒,
      期间 `_agentId()` 还是空 → `sendText()` 在入口直接 return,什么都没做。
修法:新增 `_blankInFlight` 记录新建 Promise,`sendText` 检测到在途时**等它完成**再继续。

## 2. 卡片/右上角/左侧状态不同步(用户实测)
现象:正文输出完了,卡片已「已完成」,右上角和左侧还挂着加载动画。
根因:卡片按 ✅ reaction 实时收尾,另两处只认容器 currentSessions(15 秒轮询)。
修法:新增 `turnSettledLocally` 信号,✅ 一到三处同时收尾。

## 3. 左侧会话一直转圈不停
根因:清除本地标记的代码只写在 `describe()`(右侧用),左侧 `sessionRunningOf()`
      根本不走那条路 → 标记永久残留。
修法:左侧改为「容器优先」+ 本地标记加 60 秒 TTL;容器明确 idle 时顺手清标记。

## 4. 容器漏写结束时状态永久卡住
修法:`running` 加 10 分钟保质期(`SESSION_RUNNING_STALE_MS`),
      超期视为过期,不再相信。

## 5. 用户连发消息被拆成两轮(用户裁决)
「提出第一个需求,再提出第二个需求,后续所有回答都在第二个需求下展示」
修法:合并判据从「带附件才合并」改为「上一回合还没收到任何容器回复」;
      新增 `Turn.priorAsks` 保留前置提问供展示。

## 6. 容器接入文档
- 新增 §六点五 DNS 解析卡顿(部署后必测,根因/自测命令/四级修复方案)
- 「最容易做错的事」扩到 7 条
- 提示词补 `currentSessions` 必须放在 `extra` 字段里(顶层会 400)

## 验证
tsc ✅ · 1683 条测试全过 ✅ · 构建 ✅
**未部署**(用户要求先本地测试)

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct пре 14 часа
родитељ
комит
1c8150e56c

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

@@ -22,8 +22,10 @@
 | 4 | **心跳不带 `currentSessions`** | App 拿到的会话状态是过时的;turn 结束后界面迟迟不纠正 | 心跳也要带上;见 §五点三 |
 | 5 | **正文用 `~` 表示数值范围**(`8~16℃`) | 成对的 `~` 被 markdown 解析成 `~~删除线~~`,整段被划掉 | 用全角 `~` 或「至」。App 已兜底,仍建议规范 |
 | 6 | **误以为 App 不支持 Markdown 表格** | 主动降级成「标签: 值」列表,用户拿不到表格 | **App 完整支持**(`ngx-markdown` + 完整表格样式)。直接用 `\| a \| b \|` 即可 |
+| 7 | **没测 DNS 解析耗时** | 每轮卡 5 秒,用户觉得「容器想得很慢」,新会话也一样 | 部署后跑一次 §六点五 的自测命令,>1000ms 就要修 |
 
 > 🔴 第 3、4 条是 2026-11-11 实测发现的高频问题,**直接导致用户投诉「状态串台」**。
+> 🔴 第 7 条是**最容易被误判成「模型慢」**的问题,实际是网络层。
 
 | 3 | 正文用 `~` 表示数值范围(`8~16℃`) | 成对的 `~` 被 markdown 解析成 `~~删除线~~`,整段被划掉 | 用全角 `~` 或「至」。App 侧已兜底,但容器侧也应规范 |
 
@@ -300,6 +302,59 @@ MATRIX_REACTIONS=true    # 默认就是 true,别关
 
 ---
 
+## 六点五、DNS 解析卡顿(**部署后必测,否则响应慢 5 秒/轮**)
+
+### 6.5.1 现象
+
+用户会觉得容器「**思考很久**」,而且**新开会话也一样慢**。
+实际不是模型慢,是**每一轮的网络请求前都要卡 5 秒做 DNS 解析**。
+
+### 6.5.2 根因(实测确认)
+
+glibc 默认把 **A 查询和 AAAA 查询并发塞进同一个 UDP socket** 发出;
+Docker 的 DNS 转发器(如 `192.168.14.1`)处理不了这种并发双包,**丢掉其中一个**,
+glibc 只能干等到 5 秒超时再重试。
+
+**关键:这是全局问题,不只某一个域名** —— 实测 `api.fmode.cn` 和 `baidu.com` 都卡 5036ms。
+
+### 6.5.3 自测命令(每个容器部署后跑一次)
+
+```bash
+python3 -c "import socket,time;t=time.time();socket.getaddrinfo('api.fmode.cn',443,socket.AF_UNSPEC);print('解析耗时=%.0f ms' % ((time.time()-t)*1000))"
+```
+
+| 结果 | 判定 |
+|---|---|
+| **< 100 ms** | ✅ 正常 |
+| **> 1000 ms** | ❌ 命中此问题,必须修 |
+
+### 6.5.4 修复方案(按优先级)
+
+| 方案 | 覆盖范围 | 重启后 | 可行性 |
+|---|---|---|---|
+| **① compose 加 `dns_opt: [single-request]`** | 全部进程 + 全部语言 | ✅ 持久 | 需宿主机/平台侧改 |
+| **② `/etc/resolv.conf` 加 `options single-request`** | 同上 | ⚠️ 容器重启被覆盖 | 文件通常是 root 属主,容器内改不了 |
+| **③ s6 环境变量 `RES_OPTIONS`** | s6 下所有服务 | ✅ 持久 | 待验证 |
+| **④ Python 层 `usercustomize.py`** | 仅 Python 进程 | ✅ 持久,但 Python 升版失效 | 容器内可做 |
+
+**实测效果(方案④)**:
+```
+修复前:5036 / 5020 / 5024 / 5032 / ... ms
+修复后: 34 /  22 /  43 /  66 / ... ms
+实际 API 请求:5164 ms → 98 ms
+```
+
+> ⚠️ 方案④ 的隐患:路径写死 `lib/python3.13/`,Python 升到 3.14 会**静默失效**
+> (变量不注入、不报错、5 秒卡顿原样回归)。
+> 建议写成 `glob('/opt/data/home/.local/lib/python3.*/site-packages')` 遍历版本号。
+
+### 6.5.5 这不是容器业务代码的问题
+
+根因在 **Docker 网络环境配置**,不是 Hermes 或插件。
+**每个容器都要单独修**,且**新开的容器大概率也有同样问题**。
+
+---
+
 ## 七、容器能力边界(决定哪些改造可行)
 
 | 路径 | 权限 | 能做什么 |

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

@@ -41,6 +41,41 @@ hermes plugins list | grep -i typing
 
 ## 检查项 2:`currentSessions` 按会话上报(**最容易做错**)
 
+### 🔴 先记住这条:`currentSessions` 必须放在 `extra` 字段里
+
+云函数 `xlosi3m46q` 的**顶层字段白名单**是固定的:
+
+```
+status currentTask currentAction progress name containerRef host heartbeatAt
+botName botAvatar gender ownerName ownerMobile balance channels extra
+sessionStatus usage usageSessions
+```
+
+实测(2026-11-11):
+
+| 上报方式 | 结果 |
+|---|---|
+| `{ field: "currentSessions", value: "..." }` | ❌ **HTTP 400**:字段不在白名单 |
+| `{ field: "extra", value: "{\"currentSessions\":[...]}" }` | ✅ **HTTP 200** |
+
+所以正确写法是**把整个对象塞进 `extra`**:
+
+```js
+POST /api/functions
+{
+  "id": "xlosi3m46q",
+  "token": "<sessionToken>",
+  "params": {
+    "agentId": "...",
+    "field": "extra",                              // ← 必须是 extra
+    "value": "{\"currentSessions\":[ ... ]}"       // ← 紧凑 JSON 字符串
+  }
+}
+```
+
+> ⚠️ `usageSessions` 是另一个顶层字段,**不要拿它当 `currentSessions` 用** ——
+> App 读的是 `extra.currentSessions`,两者语义不同。
+
 ### 背景
 用户实测投诉:「在其他会话里也能看到当前会话的容器状态」。
 根因:你只报了容器级 `status`,App 只能拿它冒充会话状态。

+ 38 - 12
src/app/components/domain/session-tree/session-tree.ts

@@ -2506,22 +2506,48 @@ export class SessionTreeComponent implements OnDestroy {
     const agent = this.liveSvc.agentById(s.agentId);
     if (!agent) return false;
     /*
-     * 🔴 本地「刚发出」优先(2026-11-11 用户反馈「左侧转动出现较晚」)。
+     * 🔴 当前打开的那条会话,若已收到 ✅ reaction → 立刻停止转圈
+     *    (2026-11-11 用户实测:正文输出完了,左侧还在转)。
      *
-     * 容器上报 `currentSessions` 是 turn 级事件,从「用户点发送」到
-     * 「容器写入 running」之间有几秒空档,这期间左侧列表不转圈 ——
-     * 用户看着消息已发出、列表却没反应。
-     *
-     * 用**会话级**标记(不是容器级):同一容器的其他会话不会跟着转。
+     * 回合卡片按 reaction 实时收尾,但左侧只认 `currentSessions`(15 秒轮询),
+     * 三处说法不一致。这里让左侧也直接用结束信号。
      */
-    const localAt = this.pulseSvc.sessionLocalStartAt(s.id);
-    if (localAt !== undefined) return true;
+    if (this.isCurrentSession(s) && this.chat.turnSettledLocally()) return false;
     /*
-     * 传 `s.roomId` 作为退路:容器上报的 sessionKey 在 DM 通道下常只有 roomId
-     * (`thread_id` 为 NULL,实测 46%),光靠 thread 根匹配不上。
-     * 见 `agent-state.ts::sessionStateOf()` 的三级匹配。
+     * 🔴 顺序很重要(2026-11-11 用户实测:左侧一直转圈不停)。
+     *
+     * **先问容器**:容器明确说这条会话 idle,就是最高优先级的事实 ——
+     * 本地「刚发出」标记不能盖过它。原实现把本地标记排在前面,
+     * 而清除标记的逻辑只写在 `describe()` 里(右侧用),
+     * 左侧根本不走那条路 → 标记一直留着 → 左侧永远转圈、右侧却已正常。
+     *
+     * 现在本地标记只作为「容器还没给结论」时的过渡,且带 TTL。
      */
-    return sessionRunning(agent.currentSessions, s.id, s.roomId) === true;
+    const containerRunning = sessionRunning(
+      agent.currentSessions,
+      s.id,
+      // `s.roomId` 是退路:DM 通道 thread_id 常为 NULL,只能靠房间认
+      s.roomId,
+      this.myMxIdOf(),
+    );
+    if (containerRunning === false) {
+      /* 容器明确空闲 → 顺便清掉可能残留的本地标记 */
+      this.pulseSvc.clearSessionLocalStart(s.id);
+      return false;
+    }
+    if (containerRunning === true) return true;
+    /* 容器没给结论(没上报 / 不知道该查哪条)→ 才看本地「刚发出」标记 */
+    return this.pulseSvc.sessionLocalStartAt(s.id) !== undefined;
+  }
+
+  /** 这条会话是不是用户当前打开的那条 */
+  private isCurrentSession(s: ChatSession): boolean {
+    return this.chat.sessionId() === s.id;
+  }
+
+  /** 当前登录用户的 Matrix mxid(多人共用容器时用于按用户隔离) */
+  private myMxIdOf(): string | null {
+    return this.chat.myMxId();
   }
 
   protected unreadOf(agentId: string): number {

+ 46 - 3
src/app/core/agent-state.spec.ts

@@ -377,11 +377,54 @@ describe('多人共用容器:userId 隔离', () => {
     expect(sessionRunning(sessions, APP_SID, ROOM, '@me:fmode.cn')).toBeTrue();
   });
 
-  it('只有别人的会话 → 过滤后为空,回落到全集,不误报「未知」', () => {
+  it('🔴 只有别人的会话 → 不回落全集,返回 undefined(不知道)', () => {
+    /*
+     * 初版写成「过滤后为空就回落到全集」,结果别人的 running 被判成我在跑,
+     * 隔离等于没做。自动化验证 ⑤ 抓到了这个。
+     *
+     * 正确语义:currentSessions 里没有我这条 = 容器没报我的状态 = 不知道,
+     * 由调用方回落成「在线」,而不是拿别人的状态冒充。
+     */
     const sessions = [
       { sessionKey: '!sameRoom:fmode.cn', userId: '@other:fmode.cn', status: 'running' as const },
     ];
-    // 没有我的会话 → 按全集匹配到别人的 → running(roomId 级兜底,符合设计)
-    expect(sessionRunning(sessions, APP_SID, ROOM, '@me:fmode.cn')).toBeTrue();
+    expect(sessionRunning(sessions, APP_SID, ROOM, '@me:fmode.cn')).toBeUndefined();
+    // 不过滤时仍能命中(老行为,供对比)
+    expect(sessionRunning(sessions, APP_SID, ROOM)).toBeTrue();
+  });
+});
+
+/*
+ * 🔴 `running` 也需要保质期(2026-11-11 用户实测)。
+ *
+ * 现象:容器上报本会话 running,但 turn 早已结束,界面永久「正在处理」+ 左侧一直转圈。
+ * 根因:`currentSessions` 只在 turn 首尾写;漏写结束那一次就永久卡住。
+ */
+describe('running 状态过期保护', () => {
+  const NOW = 1_800_000_000_000;
+  const sid = '$approot_x:fmode.cn';
+
+  it('🔴 updatedAt 超过 10 分钟没变 → 不再相信 running', () => {
+    const sessions = [
+      { sessionKey: '!r', status: 'running' as const, updatedAt: NOW - 11 * 60_000 },
+    ];
+    expect(sessionRunning(sessions, sid, '!r', null, NOW)).withContext('过期就该判不知道').toBeUndefined();
+  });
+
+  it('updatedAt 在 10 分钟内 → 仍然相信', () => {
+    const sessions = [
+      { sessionKey: '!r', status: 'running' as const, updatedAt: NOW - 2 * 60_000 },
+    ];
+    expect(sessionRunning(sessions, sid, '!r', null, NOW)).toBeTrue();
+  });
+
+  it('没有 updatedAt(老容器)→ 不做过期判定,保持原行为', () => {
+    const sessions = [{ sessionKey: '!r', status: 'running' as const }];
+    expect(sessionRunning(sessions, sid, '!r', null, NOW)).toBeTrue();
+  });
+
+  it('idle 不受过期逻辑影响', () => {
+    const sessions = [{ sessionKey: '!r', status: 'idle' as const, updatedAt: NOW - 99 * 60_000 }];
+    expect(sessionRunning(sessions, sid, '!r', null, NOW)).toBeFalse();
   });
 });

+ 41 - 3
src/app/core/agent-state.ts

@@ -378,8 +378,26 @@ export function sessionStateOf(
    *
    * 只在**容器确实上报了 userId** 时才收窄:老容器不上报 → 保持原行为。
    */
-  const mine = myUserId ? sessions.filter((s) => !s.userId || s.userId === myUserId) : sessions;
-  const pool = mine.length ? mine : sessions;
+  /*
+   * 🔴 只在**容器确实上报了 userId** 时才收窄(2026-11-11)。
+   *    老容器完全不上报 userId → 保持原行为,全部参与匹配。
+   */
+  const reportedUsers = sessions.some((s) => !!s.userId);
+  const pool =
+    myUserId && reportedUsers
+      ? sessions.filter((s) => !s.userId || s.userId === myUserId)
+      : sessions;
+  /*
+   * 🔴 过滤后为空 → **直接返回 undefined**,不回落到全集。
+   *
+   * 初版写成 `mine.length ? mine : sessions`:过滤完没剩下就退回全集,
+   * 结果「列表里只有别人的会话」时,别人的 running 照样被判成我在跑 ——
+   * 隔离等于没做(自动化验证 ⑤ 抓到了这个)。
+   *
+   * 语义上这也是对的:`currentSessions` 里**没有我这条**,
+   * 就是「容器没报我的状态」= 不知道,交给调用方回落到「在线」。
+   */
+  if (!pool.length) return undefined;
 
   /* ① 精确:thread 根命中 */
   const byThread = pool.find(
@@ -400,12 +418,32 @@ export function sessionStateOf(
  * 容器没上报会话级状态时返回 `undefined`(**不知道**),
  * 由调用方决定回落到什么 —— 不能当作「空闲」,也不能当作「在跑」。
  */
+/**
+ * `running` 状态的最长信任时长(2026-11-11)。
+ *
+ * 容器只在 turn 首尾写 `currentSessions`,中间不刷新。若它漏写了「结束」那一次,
+ * App 会永久相信这个 `running` —— 界面一直「正在处理」、左侧一直转圈
+ * (用户实测反馈)。超过这个时长就当作**状态过期**,回落到「不知道」。
+ *
+ * 取 10 分钟:与实测的长任务上限一致(容器跑过 3 分钟以上)。
+ * 比它短会把真长任务误判成过期,比它长则卡住的时间太久了。
+ */
+export const SESSION_RUNNING_STALE_MS = 10 * 60_000;
+
 export function sessionRunning(
   sessions: AgentSessionState[] | undefined,
   appSessionId: string | null | undefined,
   roomIdOf?: string | null,
   myUserId?: string | null,
+  now = Date.now(),
 ): boolean | undefined {
   const s = sessionStateOf(sessions, appSessionId, roomIdOf, myUserId);
-  return s ? s.status === 'running' : undefined;
+  if (!s) return undefined;
+  if (s.status !== 'running') return false;
+  /*
+   * `running` 也要有保质期:`updatedAt` 很久没变 → 不可信。
+   * 没有 `updatedAt` 的(老容器)不做过期判定,保持原行为。
+   */
+  if (s.updatedAt && now - s.updatedAt > SESSION_RUNNING_STALE_MS) return undefined;
+  return true;
 }

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

@@ -141,6 +141,19 @@ export interface AgentSessionState {
   status: 'running' | 'idle';
   /** 本轮开始的 epoch 毫秒(idle 时可缺省) */
   turnStartAt?: number;
+  /**
+   * 该条记录最后更新的 epoch 毫秒(2026-11-11 新增)。
+   *
+   * 🔴 用途:**判断 `running` 是否已经过期**。
+   *
+   * 用户实测:容器上报本会话 `running`,但 turn 其实早已结束,
+   * 界面一直挂着「正在处理」+ 左侧一直转圈。根因是 `currentSessions`
+   * 只在 turn 首尾写,中间不刷新;若容器恰好漏了结束那一次,状态就永久卡住。
+   *
+   * App 侧据此兜底:`running` 且 `updatedAt` 已经很久没变 → 视为过期,
+   * 不再相信它。没有这个字段时不做过期判定(老容器不受影响)。
+   */
+  updatedAt?: number;
 }
 
 export interface Person {

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

@@ -1145,11 +1145,14 @@ function mapAgent(raw: Record<string, unknown>): Agent {
         const uid = typeof s['userId'] === 'string' && s['userId'].startsWith('@') ? s['userId'] : undefined;
         /* 会话级任务摘要(2026-11-11):形状不对就省略,顶栏退回通用文案 */
         const task = typeof s['task'] === 'string' && s['task'].trim() ? s['task'].trim() : undefined;
+        /* 最后更新时刻(2026-11-11):用于判断 running 是否已过期 */
+        const upd = typeof s['updatedAt'] === 'number' && Number.isFinite(s['updatedAt']) ? s['updatedAt'] : undefined;
         return [
           {
             sessionKey: key,
             ...(uid ? { userId: uid } : {}),
             ...(task ? { task } : {}),
+            ...(upd ? { updatedAt: upd } : {}),
             status: st as 'running' | 'idle',
             turnStartAt:
               typeof s['turnStartAt'] === 'number' && Number.isFinite(s['turnStartAt'])

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

@@ -143,6 +143,13 @@ export interface TurnMergedBody {
 export interface AssistantTurn {
   id: string;
   userMessage?: Message;
+  /**
+   * 本回合里**早于 `userMessage`** 的提问(2026-11-11)。
+   *
+   * 用户连发多条时并入同一回合:`userMessage` 是最新那条,
+   * 前面几条按时间顺序放这里。渲染层要先展示它们,用户才看得到自己说过什么。
+   */
+  priorAsks?: Message[];
   /** 用户在同回合内额外发的文件(群聊「文字 + 多个文件」时) */
   userFiles: Message[];
   blocks: TurnBlockView[];
@@ -1279,6 +1286,7 @@ export function turnsFromDm(
     return {
       id: t.id,
       userMessage: t.userMessage,
+      priorAsks: t.priorAsks ?? [],
       userFiles: t.userFiles ?? [],
       blocks,
       phase: phaseOf(blocks, t.closed),

+ 69 - 3
src/app/core/chat.service.ts

@@ -299,6 +299,24 @@ export class ChatService {
     return s?.status === 'running' ? s.task : undefined;
   });
 
+  /**
+   * 本轮是否**已在本地收到结束信号**(✅/❌ reaction,2026-11-11)。
+   *
+   * 🔴 这是解决「各处状态不同步」的关键:
+   *
+   * | 界面 | 原来的数据源 | 延迟 |
+   * |---|---|---|
+   * | 回合卡片 | ✅ reaction(实时) | 0 |
+   * | 右上角 / 左侧列表 | 容器 `currentSessions`(15 秒轮询) | 最坏 15 秒 |
+   *
+   * 用户实测:「正文都输出完了,右上角和左侧还挂着加载动画」。
+   * 因为卡片已经按 reaction 收尾,另两处还在等轮询。
+   *
+   * 现在三处共用这一个信号:**reaction 一到,所有界面立刻收尾**,
+   * 不必等容器上报。容器上报到达后自然会确认这个结论,不冲突。
+   */
+  readonly turnSettledLocally = computed(() => this._turnEndedAfter() !== null);
+
   /**
    * 本会话运行态的**原始信号包**(2026-11-11 统一方案)。
    *
@@ -347,6 +365,22 @@ export class ChatService {
    * 直到 `sendText` 的 `commit` 成功(云端确实有了这条会话)才清除。
    */
   private _blankSessionId: string | null = null;
+  /**
+   * 「新建会话」进行中的 Promise(2026-11-11)。
+   *
+   * 🔴 解决「新建后立刻发首条消息,消息不显示」(用户实测)。
+   *
+   * 时间线:
+   *   1. 用户点「新建对话」→ `startBlank()` 开始跑
+   *   2. 里面 `await this.open(agentId)`(provision + Matrix 连接,可能要等几秒)
+   *   3. 用户等不及,直接输入并按回车 → `sendText()`
+   *   4. 此时 `_agentId()` **还没设好** → `sendText` 在入口处 `return`
+   *      → 文字不清空、消息不出现,用户以为「按了没反应」
+   *
+   * 现在:`sendText` 检测到新建在途时**等它跑完再继续**,
+   * 而不是静默丢弃。用户最多多等那一两秒,但消息一定会发出去。
+   */
+  private _blankInFlight: Promise<unknown> | null = null;
   private provision: MatrixProvision | null = null;
   /**
    * 当前登录用户在 Matrix 的 mxid(2026-11-11)。
@@ -537,9 +571,6 @@ export class ChatService {
     });
   }
 
-  /**
-   * 打开会话(路由进入时调用)。
-   *
   /**
    * 打开会话(路由进入时调用)。
    *
@@ -689,6 +720,23 @@ export class ChatService {
    * @returns 创建出的会话;容器未就绪时返回 null
    */
   async startBlank(agentId: string, workspaceId: string | null = null): Promise<ChatSession | null> {
+    /*
+     * 登记「新建在途」的 Promise,让 `sendText` 能等它(2026-11-11)。
+     * 用户在新建立即按回车时,`_agentId` 可能还没设好 —— 见 `_blankInFlight` 注释。
+     */
+    const p = this.doStartBlank(agentId, workspaceId);
+    this._blankInFlight = p;
+    try {
+      return await p;
+    } finally {
+      if (this._blankInFlight === p) this._blankInFlight = null;
+    }
+  }
+
+  private async doStartBlank(
+    agentId: string,
+    workspaceId: string | null = null,
+  ): Promise<ChatSession | null> {
     /*
      * 🔴 先自增 `seq`:`startBlank` 不经过 `openReal()`,不会自带版本号,
      *    而此时可能正好有一次 `openReal()` 在途(切换容器/前一次进入的异步尾段)。
@@ -1435,6 +1483,24 @@ export class ChatService {
      */
     if (!body && this._pendingFiles().length === 0) return;
 
+    /*
+     * 🔴 新建会话正在途中 → **等它完成**再继续(2026-11-11 用户实测)。
+     *
+     * 用户点「新建对话」后立刻输入按回车时,`startBlank()` 内部的
+     * `await open()`(provision + Matrix 连接)还没跑完,`_agentId` 还是空。
+     * 原实现在下面直接 `return` —— 文字不清空、消息不显示,
+     * 用户以为「按了没反应」(实测:文字一直留在输入框里)。
+     *
+     * 现在最多多等那一两秒,但消息一定会发出去。
+     */
+    if (this._blankInFlight) {
+      try {
+        await this._blankInFlight;
+      } catch {
+        /* 新建失败由 startBlank 自己报错,这里继续走正常流程看能否兜住 */
+      }
+    }
+
     const conversationId = this._agentId();
     if (!conversationId) return;
 

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

@@ -259,4 +259,50 @@ describe('TaskPulseService', () => {
       expect(svc.describe(agent({ status: 'idle' })).text).toBe('空闲');
     });
   });
+
+/*
+ * 🔴 容器明确说本会话 idle → 立即「在线」,不受其他会话的消息流干扰
+ *    (2026-11-11 用户实测:回复完了右上角一直挂「正在处理」)。
+ *
+ * 原实现在 `mine !== true` 时还留了 `quietMs < 25秒` 的兜底,
+ * 而 `lastActivity` 跟踪的是**整个容器**的消息流 ——
+ * 别的会话有新消息也会刷新它,导致本会话已经结束,状态却被一直拖着。
+ */
+
+  const ROOM = '!myRoom:fmode.cn';
+  const SID = '$approot_x:fmode.cn';
+
+  it('🔴 本会话 idle + 刚刚有消息进来 → 仍应显示「在线」', () => {
+    const a = agent({
+      status: 'running',
+      currentSessions: [{ sessionKey: ROOM, status: 'idle', updatedAt: Date.now() }],
+    });
+    // 刚发生过消息活动(模拟别的会话在发消息刷新的 lastActivity)
+    tick([a]);
+    svc.noteUserAction(a.id);
+    const line = svc.describe(a, Date.now(), SID, ROOM);
+    expect(line.active).withContext('容器说空闲就该显示在线').toBeFalsy();
+    expect(line.text).toBe('在线');
+  });
+
+  it('本会话 running → 显示在忙', () => {
+    const a = agent({
+      status: 'running',
+      currentSessions: [{ sessionKey: ROOM, status: 'running', updatedAt: Date.now() }],
+    });
+    const line = svc.describe(a, Date.now(), SID, ROOM);
+    expect(line.active).toBeTrue();
+  });
+
+  it('容器报的会话列表里没有本会话 → 保留 25 秒兜底(刚发出时不误判在线)', () => {
+    const a = agent({
+      status: 'running',
+      currentSessions: [{ sessionKey: '!otherRoom:fmode.cn', status: 'running', updatedAt: Date.now() }],
+    });
+    tick([a]);
+    svc.noteUserAction(a.id);
+    const line = svc.describe(a, Date.now(), SID, ROOM);
+    expect(line.active).withContext('刚发出、容器还没写入本会话').toBeTrue();
+  });
 });
+

+ 57 - 23
src/app/core/task-pulse.service.ts

@@ -32,6 +32,18 @@ const FRESH_MS = HEARTBEAT_STALE_SEC * 1000;
  */
 const REPLY_QUIET_MS = QUIET_MS;
 
+/**
+ * 本地「刚发出」标记的有效期(2026-11-11)。
+ *
+ * 它只是**过渡用**:覆盖「用户点发送 → 容器上报 running」之间的空档。
+ * 一旦超过这个时间,就该由容器上报的真实状态说了算 ——
+ * 否则某个界面忘了清理(比如左侧会话树不走 `describe()`)就会永久转圈。
+ *
+ * 取 60 秒:足够覆盖容器首轮响应的最慢情况(DNS 修复后实测首字 2~9 秒),
+ * 又不至于让误留的标记长期作妖。
+ */
+const SESSION_LOCAL_START_TTL_MS = 60_000;
+
 /** 超过这么久的事件视为「历史重放」,不据此判断"正在回复" */
 const STALE_MSG_MS = 60_000;
 
@@ -191,16 +203,30 @@ export class TaskPulseService {
     this._localSessionStart.set(next);
   }
 
-  /** 该会话是否处在「刚发出、容器还没确认」的窗口内 */
-  sessionLocalStartAt = (sessionId: string): number | undefined =>
-    this._localSessionStart().get(sessionId);
+  /**
+   * 该会话是否处在「刚发出、容器还没确认」的窗口内。
+   *
+   * 🔴 **带 TTL**(2026-11-11 用户实测:左侧一直转圈不停)。
+   *
+   * 原实现直接返回标记、不看过期时间。而清除逻辑只写在 `describe()` 里,
+   * **左侧会话树根本不走 `describe()`** —— 它直接调 `sessionRunningOf()`。
+   * 于是标记一直留在 Map 里,左侧永远转圈,右侧却已正常。
+   *
+   * 现在给标记加窗口期:超过它就不再算「刚发出」,
+   * 由容器上报的真实状态接管。这样即使某个界面忘了清,也不会永久卡住。
+   */
+  sessionLocalStartAt = (sessionId: string, now = Date.now()): number | undefined => {
+    const at = this._localSessionStart().get(sessionId);
+    if (at === undefined) return undefined;
+    return now - at < SESSION_LOCAL_START_TTL_MS ? at : undefined;
+  };
 
   /**
    * 容器确认该会话结束后清掉本地标记。
    *
    * 只在「确实存在」时才写信号,避免 compute 里无谓地触发重算。
    */
-  private clearSessionLocalStart(sessionId: string): void {
+  clearSessionLocalStart(sessionId: string): void {
     const cur = this._localSessionStart();
     if (!cur.has(sessionId)) return;
     const next = new Map(cur);
@@ -268,6 +294,27 @@ export class TaskPulseService {
     if (resolved.state === 'offline') {
       return { text: '已离线', tone: 'danger' };
     }
+    /*
+     * 🔴 会话级上报**排在最前**(2026-11-11 第二次修正)。
+     *
+     * 用户实测:回复完了,右上角一直挂「正在处理…」。
+     * 排查发现是 `local && isBusy`(用户刚发消息的本地标记)排在前面,
+     * 它只认「容器整体 status」,不看「**本会话**是否已结束」——
+     * 只要容器还在跑别的活,本地标记就一直让它显示在忙。
+     *
+     * 容器用**会话级**数据说「这条空闲」,这是最高优先级的事实:
+     * 比「用户刚发过消息」这个本地推测更准。
+     */
+    const sessionAware = sessionId !== undefined && sessionId !== null;
+    const hasSessionReport = (agent.currentSessions?.length ?? 0) > 0;
+    const mine = sessionAware
+      ? sessionRunning(agent.currentSessions, sessionId, roomId, myUserId, now)
+      : undefined;
+    if (sessionAware && mine === false) {
+      if (sessionId) this.clearSessionLocalStart(sessionId);
+      return { text: '在线', tone: 'success' };
+    }
+
     /*
      * 🔴 2026-11-09 调整顺序(C3):
      *
@@ -305,33 +352,20 @@ export class TaskPulseService {
      *
      * 用户原话:「右上角仅显示当前会话的进度,如果当前会话没有就显示在线状态」。
      */
-    const sessionAware = sessionId !== undefined && sessionId !== null;
     /*
-     * 容器**有**会话级上报,我才敢拿它下结论。
-     * `currentSessions` 为空 = 老容器从没报过 → `mine` 只能是 undefined,
-     * 此时回落到下面的全局口径,行为与改造前完全一致。
+     * 会话级上报已在函数开头处理(`mine === false` 优先返回「在线」)。
+     * 这里只剩「容器报了列表但没有本会话」这一种情况 ——
+     * 刚发出时容器可能还没写入记录,保留 25 秒兜底。
      */
-    const hasSessionReport = (agent.currentSessions?.length ?? 0) > 0;
-    const mine = sessionAware ? sessionRunning(agent.currentSessions, sessionId, roomId, myUserId) : undefined;
-    if (sessionAware && hasSessionReport && mine !== true) {
+    if (sessionAware && hasSessionReport && mine === undefined) {
       /*
-       * 本会话没在跑 —— 两种情况都归到这里:
-       *   · `mine === false`:容器明确报了本会话 idle
-       *   · `mine === undefined`:容器报了别的会话、**没报本会话**
-       *     (`sessionRunning` 约定 undefined = 不知道;但在「页面已指定会话」
-       *     的语义下,容器报了一堆会话却唯独没有我这条,就是**我没有在跑**)
-       *
-       * 但上报是分钟级的,刚发完消息时容器可能还没更新 —— 用「最近有消息进来」
-       * 兜住,避免正文还在流式输出就显示「在线」。
+       * 容器报了会话列表,但**没有本会话** → 说明本会话不在跑。
+       * 仍保留 25 秒兜底:刚发出时容器可能还没写入记录。
        */
       const quietMs = now - this.lastActivity(agent.id);
       if (quietMs < REPLY_QUIET_MS) {
         return { text: '正在回复…', tone: 'info', active: true };
       }
-      /*
-       * 容器已确认结束 → 清掉本地「刚发出」标记(2026-11-11)。
-       * 不清的话左侧会话树的旋转图标会一直转下去。
-       */
       if (sessionId) this.clearSessionLocalStart(sessionId);
       return { text: '在线', tone: 'success' };
     }

+ 29 - 12
src/app/core/turn-aggregator.spec.ts

@@ -149,18 +149,22 @@ describe('turn-aggregator', () => {
       file: { kind: 'file', name: 'image.png', size: 1, mimeType: 'image/png', url: 'blob:x' },
     });
 
-    it('图(先)+ 文字(后)→ 一个回合,图挂在 userFiles', () => {
+    it('图(先)+ 文字(后)→ 并入同一回合(2026-11-11 连发合并)', () => {
       const turns = buildTurns({
         messages: [img('f1', T0 + 1000), msg('u1', 'me', T0 + 2000, '照这个做页面')],
         typing: false,
         now: T0 + 10_000,
       });
       /*
-       * 图在前、文字在后时,图先开回合,文字是「新的用户发言」→ 仍是 2 个回合。
-       * 这是容器「先收到图」的真实时序,合并显示由 A1(容器等待文字)根治;
-       * App 侧只保证「文字在下、图紧随其后」的多附件情形不分裂。
+       * 旧行为是 2 个回合(文字被当成「纯文字后续发言」另开一轮),
+       * 但图 + 文字本来就是**同一次输入**,应该并在一起。
+       *
+       * 合并后:`userMessage` = 文字(用户最后说的那句),图进 `priorAsks`
+       * 按时间顺序先展示 —— 阅读顺序仍是「图在上、文字在下」。
        */
-      expect(turns.length).toBe(2);
+      expect(turns.length).withContext('同一次输入应合并').toBe(1);
+      expect(turns[0].userMessage?.id).toBe('u1');
+      expect(turns[0].priorAsks?.[0].id).withContext('图保留供展示').toBe('f1');
     });
 
     it('文字(先)+ 图(后)→ 并入同一回合', () => {
@@ -218,13 +222,22 @@ describe('turn-aggregator', () => {
       expect(turns.length).toBe(2);
     });
 
-    it('🔴 纯文字后续发言照常开新回合(不能把两句话并成一轮)', () => {
+    it('🔴 容器未回复时连发纯文字 → 并入同一回合(2026-11-11 用户裁决)', () => {
+      /*
+       * 用户原话:「用户提出第一个需求,然后再提出第二个需求,
+       * 后续所有回答都在第二个需求下展示,而不是每个需求单独展示。」
+       *
+       * 旧行为:纯文字一律开新回合 → 第一张卡永远挂着「已送达,等待回复…」,
+       * 用户看到的是「卡住了」。
+       */
       const turns = buildTurns({
         messages: [msg('u1', 'me', T0, '第一句'), msg('u2', 'me', T0 + 1000, '第二句')],
         typing: false,
         now: T0 + 10_000,
       });
-      expect(turns.length).toBe(2);
+      expect(turns.length).withContext('连发应合并').toBe(1);
+      expect(turns[0].userMessage?.id).withContext('最后一条作为当前提问').toBe('u2');
+      expect(turns[0].priorAsks?.[0].id).withContext('前面的保留供展示').toBe('u1');
     });
 
     it('附件并入后,回合的 userFiles 不为空且保留原消息', () => {
@@ -374,7 +387,13 @@ describe('按 replyTo 归属回复', () => {
     ...(replyTo ? { replyTo } : {}),
   });
 
-  it('🔴 图片的回复指向图片那条 → 归图片回合,不串到文字下面', () => {
+  it('🔴 图 + 文字的回复都归同一回合(2026-11-11 连发合并后)', () => {
+    /*
+     * 用户先发图、再发文字,容器分别回了两次(a1 回图、a2 回文字)。
+     * 合并前会分成两个回合;合并后图与文字属于同一次输入,
+     * 两条回复都进同一回合 —— 用户看到的是一张卡里完整的往来,
+     * 而不是「图片卡 + 文字卡」各挂一半回复。
+     */
     const turns = buildTurns({
       messages: [
         msg('img', 'me', T0),
@@ -385,10 +404,8 @@ describe('按 replyTo 归属回复', () => {
       typing: false,
       now: T0 + 60_000,
     });
-    const imgTurn = turns.find((t) => t.id === 'img')!;
-    const txtTurn = turns.find((t) => t.id === 'txt')!;
-    expect(imgTurn.blocks.map((b) => b.id)).toEqual(['a1']);
-    expect(txtTurn.blocks.map((b) => b.id)).toEqual(['a2']);
+    expect(turns.length).withContext('同一次输入合成一个回合').toBe(1);
+    expect(turns[0].blocks.map((b) => b.id)).toEqual(['a1', 'a2']);
   });
 
   it('回复指向线程根(不是用户消息)→ 走顺序规则', () => {

+ 41 - 11
src/app/core/turn-aggregator.ts

@@ -106,6 +106,14 @@ export interface Turn {
    * 第 N 轮的锚点 = 不晚于该轮首条消息的最后一个用户提问时刻。
    */
   userAskTimes: number[];
+  /**
+   * 本回合里**在最后一条之前**的提问消息(2026-11-11)。
+   *
+   * 用户连发多条(「查天气」+「以表格形式给我」)时,它们并进同一回合:
+   * `userMessage` 存最后一条,前面的按顺序放这里 —— 渲染时全部展示,
+   * 用户才看得到自己发过什么。空数组或省略表示没有前置提问。
+   */
+  priorAsks?: import('./api/models').Message[];
   /** 首条 agent 消息到达的时间(无 blocks 时为 0) */
   startedAt: number;
   /** 最新一条 agent 消息的时间 */
@@ -189,21 +197,33 @@ export function buildTurns({ messages, typing, now }: TurnInput): Turn[] {
       const t = toTime(m.ts);
       const prev = current;
       /*
-       * 🔴 只有「同一批输入里并发的两条」才合并(2026-11-10 第二次修正)。
+       * 🔴 连发的消息并入上一回合(2026-11-11 用户裁决)。
+       *
+       * ## 用户期望
+       *
+       * 「用户提出第一个需求,然后再提出第二个需求,**后续所有回答都在第二个需求下展示**,
+       *  而不是每个需求单独展示。」
+       *
+       * ## 原实现为什么不行
+       *
+       * 原判据要求 `m.file`(带附件)才合并 —— 纯文字一律 `close()` 开新回合。
+       * 于是「查北京天气」+「以表格形式给我」会渲染成两张卡:
+       *   · 第一张卡:只有用户消息 + 永远挂着的「已送达,等待回复…」(它等不到回复了)
+       *   · 第二张卡:第二条用户消息 + 容器回复
+       * 用户看到的是「卡住了」。
+       *
+       * ## 现在的判据
        *
-       * 实测场景:用户发「图 + 文字」,图片先到 → 容器对**图片**回了一句
-       * (此时当前回合里已经有 agent 回复了),文字再到。
+       * 是否带附件不再重要,关键是 **`prev.blocks.length === 0`** ——
+       * 上一回合**还没收到容器任何回复**,说明这条就是紧接着的补充说明,
+       * 并入同一回合(当作同一次提问的延续)。
        *
-       * 如果这时还把文字并进上一回合,会出现两个错误:
-       *   ① 图片那条的容器回复被归到「图片回合」,而文字又把它并走 → 图片回合变空壳「未响应」
-       *   ② 后半句正文落到图片回合里,和它的回复错位
+       * 一旦容器已经回话,后到的消息就是新的独立一轮(保持原有行为)——
+       * 那种情况下用户是真的又提了个新的问题,混在一起反而乱。
        *
-       * 所以要求「当前回合**还没有任何回复**」才允许并入(`blocks.length === 0`)。
-       * 一旦容器已经回话,后到的那条就是独立的一轮 —— 各自成卡,
-       * 与容器的实际处理轮数一致,也与本文件 `rounds` 的切分口径一致。
+       * 时间窗 `USER_MERGE_MS` 仍然保留,避免隔了很久的追问被并进来。
        */
       const mergeIntoPrev =
-        !!m.file &&
         !!prev?.userMessage &&
         prev.blocks.length === 0 &&
         !prev.closed &&
@@ -211,10 +231,20 @@ export function buildTurns({ messages, typing, now }: TurnInput): Turn[] {
         prev.lastUserAt > 0 &&
         t - prev.lastUserAt <= USER_MERGE_MS;
       if (mergeIntoPrev && prev) {
-        (prev.userFiles ??= []).push(m);
+        if (m.file) {
+          (prev.userFiles ??= []).push(m);
+        } else {
+          /*
+           * 纯文字并入:**保留最后一条作为「提问」**,前面那条进 `priorAsks`
+           * 供渲染时按顺序展示(否则用户看不到自己发过什么)。
+           */
+          (prev.priorAsks ??= []).push(prev.userMessage!);
+          prev.userMessage = m;
+        }
         prev.lastUserAt = Math.max(prev.lastUserAt, t);
         // 回合「最后活动」不能往回退:附件事件可能比文字晚到
         if (t > prev.updatedAt) prev.updatedAt = t;
+        prev.userAskTimes = [...(prev.userAskTimes ?? []), t];
         continue;
       }
       close();

+ 20 - 0
src/app/desktop/pages/chat/chat.ts

@@ -2192,6 +2192,18 @@ export class DkChatPage implements OnDestroy {
      * `currentSessions[].task` 里报本轮提问摘要;有它就显示具体任务,
      * 没有就退回「正在处理」——绝不拿容器级 `currentTask` 冒充。
      */
+    /*
+     * 🔴 收到 ✅ reaction → 立刻显示「在线」,不等 15 秒轮询(2026-11-11)。
+     *
+     * 用户实测:「正文都输出完了,右上角和左侧还挂着加载动画」。
+     * 回合卡片按 reaction 实时收尾,但顶栏原本只认容器的 `currentSessions`
+     * (15 秒轮询)—— 于是同一个回合,卡片说完成、顶栏还在转。
+     *
+     * ✅ 是「这一轮确实结束了」的确定信号,顶栏直接用它收尾。
+     */
+    if (this.chat.turnSettledLocally()) {
+      return { text: '在线', tone: 'success' };
+    }
     const line = this.pulse.describe(
       this.live.agentById(agentId),
       Date.now(),
@@ -2282,6 +2294,14 @@ export class DkChatPage implements OnDestroy {
       this.chat.turnEndedAfter(),
     )) {
       // 用户消息:单独一条,保持单聊原有观感
+      /*
+       * 连发的前置提问(2026-11-11):用户连发多条并进同一回合时,
+       * 前面几条按时间顺序先展示,最后一条 `userMessage` 紧随其后 ——
+       * 用户才看得到自己发过什么。没有前置提问时这段是空的。
+       */
+      for (const prevAsk of t.priorAsks ?? []) {
+        out.push({ kind: 'msg', key: prevAsk.id, message: prevAsk });
+      }
       if (t.userMessage) {
         out.push({ kind: 'msg', key: t.userMessage.id, message: t.userMessage });
       }

+ 4 - 0
src/app/mobile/pages/chat/chat.ts

@@ -957,6 +957,10 @@ export class MbChatPage implements OnDestroy {
       /* 容器结束信号(2026-11-11):`m.typing=false` 的本地接收时刻,与桌面同口径 */
       this.chat.turnEndedAfter(),
     )) {
+      /* 连发的前置提问先展示(2026-11-11,与桌面同口径) */
+      for (const prevAsk of t.priorAsks ?? []) {
+        out.push({ kind: 'msg', key: prevAsk.id, message: prevAsk });
+      }
       if (t.userMessage) {
         out.push({ kind: 'msg', key: t.userMessage.id, message: t.userMessage });
       }