task-pulse.service.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406
  1. import { computed, effect, inject, Injectable, signal } from '@angular/core';
  2. import { Agent } from './api/models';
  3. import { LiveService } from './live.service';
  4. import { QUIET_MS } from './turn-aggregator';
  5. import { isReporterSelfTalk, HEARTBEAT_STALE_SEC, resolveAgentState, sessionRunning } from './agent-state';
  6. /**
  7. * 服务端数据「新鲜」窗口(毫秒)。
  8. *
  9. * 🔴 2026-11-10 用户裁决:**与全站统一为外层心跳阈值**(80 分钟)。
  10. *
  11. * 原值是 90 秒。后果:容器一轮对话结束 90 秒后,顶栏就开始显示
  12. * 「状态未同步 · 上次上报 8 分钟前」——而侧栏、通讯录、新建弹窗
  13. * 走的是 `HEARTBEAT_STALE_SEC`(80 分钟),判成「空闲」。
  14. * 同一个容器,顶栏说「未同步」、别处说「在线」,这就是用户反馈的
  15. * 「外层和内层不一致」。
  16. *
  17. * 容器上报是**小时级**的(用户裁决:一小时同步一次,带 0~15 分钟抖动)。
  18. * 对话结束后不需要逼它"证明还活着"——只要没超过外层兜底阈值,就展示正常态。
  19. */
  20. const FRESH_MS = HEARTBEAT_STALE_SEC * 1000;
  21. /**
  22. * 「还在回复」的静默窗口:最近一次消息进来后这么久内,都算正在输出。
  23. *
  24. * 🔴 必须与 `turn-aggregator.QUIET_MS`(回合卡片的 live 判定)**同一口径**
  25. * (2026-11-08 用户反馈「只闪一下『正在回复』就消失」)。
  26. *
  27. * 原先这里是 20 秒、回合那边是 3 秒:顶栏说「正在回复…」,
  28. * 正文区的「正在思考…」却早已消失 —— 两处对同一件事给出相反结论,
  29. * 用户不知道该信哪个。现在两处直接取同一个常量,想改一起改。
  30. */
  31. const REPLY_QUIET_MS = QUIET_MS;
  32. /** 超过这么久的事件视为「历史重放」,不据此判断"正在回复" */
  33. const STALE_MSG_MS = 60_000;
  34. export type PulseKind = 'started' | 'finished';
  35. export interface TaskPulse {
  36. agentId: string;
  37. kind: PulseKind;
  38. /** 观测到翻转的时刻(本地,毫秒) */
  39. at: number;
  40. /** 开始时的任务描述(来自上一轮 currentTask) */
  41. task?: string;
  42. /** 完成时的用时(由两次观测的时刻差估算) */
  43. durationMs?: number;
  44. }
  45. interface Mark {
  46. running: boolean;
  47. at: number;
  48. task?: string;
  49. }
  50. /**
  51. * 运行态脉冲 —— 「当前任务」不要等很久才出现。
  52. *
  53. * ## 解决什么(用户实测反馈)
  54. *
  55. * 容器的上报间隔是分钟级(Hermes turn 开始/结束才写库,再等下一轮上报),
  56. * 所以「发了消息 → 右侧还是空闲」会持续很久,用户以为没发出去。
  57. *
  58. * ## 做法:**三分职责,谁都不越界**
  59. *
  60. * | 信号来源 | 能确定的事 | 谁负责 |
  61. * |---|---|---|
  62. * | 用户在 App 里发消息 / 传附件 | 任务**已经开始** | 前端**立即**置 running |
  63. * | 容器上报 `status=idle` | 任务**已经结束** | 只有容器能说 |
  64. * | 服务端快照 | 上一轮观测到的任务描述 | 轮询对比得出「变化」 |
  65. *
  66. * 🔴 **前端绝不自己翻回 idle** —— 那是编造「完成」。
  67. * 下一条用户消息出现前的一切「忙」,都以容器的 `idle` 为准。
  68. *
  69. * ## 界面该怎么描述
  70. *
  71. * 只描述**已观测到的事实**:
  72. *
  73. * ```
  74. * 刚刚开始 · 正在处理你的消息 ← 用户刚发消息
  75. * 正在执行 · 处理企业微信用户对话 ← 容器上报的任务描述
  76. * 已完成 · 用时 3 分 20 秒 ← 观测到 running → idle
  77. * 空闲 ← 容器说闲 AND 快照新鲜
  78. * 状态未同步 · 等待容器上报 ← 快照超过 90s 没变(不假装在线空闲)
  79. * ```
  80. *
  81. * 不写「先梳理需求 → 再执行」这类**计划**:容器没报计划,写了就是编。
  82. */
  83. @Injectable({ providedIn: 'root' })
  84. export class TaskPulseService {
  85. private readonly live = inject(LiveService);
  86. /** 本地认为「刚开始」的会话(agentId → 发出消息的时刻) */
  87. private readonly _localStart = signal<ReadonlyMap<string, number>>(new Map());
  88. /** 每次轮询观测到的翻转 */
  89. private readonly _pulses = signal<readonly TaskPulse[]>([]);
  90. readonly pulses = this._pulses.asReadonly();
  91. /** 上一轮观测(agentId → 是否在忙 / 时刻 / 任务描述) */
  92. private marks = new Map<string, Mark>();
  93. /** 最近一条消息到达时刻(agentId → 毫秒),用于「正在回复」判定 */
  94. private readonly lastMsgAt = new Map<string, number>();
  95. /** 会话里最后一条消息(含内容),用于识别新消息到达 */
  96. private lastSeenMsgId = '';
  97. /** 首次观测需要先建立基线,不能把「第一次看到 idle」当成「刚完成」 */
  98. private primed = false;
  99. constructor() {
  100. effect(() => {
  101. // 每轮轮询都要观测一次(数据未变时 agents 引用不变,靠 tick 推进)
  102. this.live.tick();
  103. const agents = this.live.agents();
  104. if (agents.length === 0) return;
  105. this.observe(agents);
  106. });
  107. }
  108. /** 用户刚发出消息:本地立刻进入 running(**仅前端展示,不改服务端**) */
  109. noteUserAction(agentId: string): void {
  110. const next = new Map(this._localStart());
  111. next.set(agentId, Date.now());
  112. this._localStart.set(next);
  113. // 本地推测也记进 marks 的「开始时刻」,便于算用时
  114. const prev = this.marks.get(agentId);
  115. if (prev && !prev.running) {
  116. this.push({ agentId, kind: 'started', at: Date.now(), task: prev.task });
  117. }
  118. this.marks.set(agentId, { running: true, at: Date.now(), task: prev?.task });
  119. }
  120. /**
  121. * 记录「有新消息到达」(由 ChatService / 聊天页调用)。
  122. *
  123. * 这是比容器上报**更新鲜**的信号:消息是实时的,运行态是分钟级的。
  124. */
  125. noteMessage(
  126. agentId: string,
  127. msgId: string,
  128. opts?: { live?: boolean; fromAgent?: boolean; ts?: number },
  129. ): void {
  130. if (!agentId || msgId === this.lastSeenMsgId) return;
  131. this.lastSeenMsgId = msgId;
  132. /*
  133. * 🔴 只有「**实时到达的、对方发的**」消息才算「正在回复」。
  134. *
  135. * 上一版只看「尾部 id 变了」,导致**刷新页面时重新灌入历史**也被当成新消息
  136. * → 一打开就显示「正在回复」,20 秒后才好(用户实测)。
  137. *
  138. * 历史重读与实时事件的区分由调用方给(`live`);默认按历史处理,
  139. * 宁可少报「正在回复」,也不能在没有任何回复时假装它在回。
  140. */
  141. if (opts?.live !== true || opts?.fromAgent !== true) return;
  142. /*
  143. * 🔴 还要看**消息自身的时间戳**:matrix-js-sdk 在初始同步时会把已同步的历史
  144. * 事件经由 onMessage 回放一遍 —— 它走的是"实时"回调,但消息其实很旧。
  145. * 只看「回调是不是实时的」会把刷新时的历史重放当成新消息(用户实测:
  146. * 一打开就显示「正在回复」)。
  147. */
  148. const ts = opts.ts ?? Date.now();
  149. if (Date.now() - ts > STALE_MSG_MS) return;
  150. this.lastMsgAt.set(agentId, Date.now());
  151. }
  152. /** 最近一次消息活动的时刻(没有则返回 0,视作很久以前) */
  153. private lastActivity(agentId: string): number {
  154. return this.lastMsgAt.get(agentId) ?? 0;
  155. }
  156. /** 当前会话是否在「本地推测的忙碌」中(容器明确说 idle 时自动解除) */
  157. localStartAt = (agentId: string): number | undefined => this._localStart().get(agentId);
  158. /**
  159. * 是否在忙(供 UI 判定)。
  160. *
  161. * `localStart` 只在下一次上报到来之前有效:容器已在处理时它自然会变成 running,
  162. * 容器报 idle 时**以容器为准**,本地推测立刻作废。
  163. */
  164. isBusy(agent: Agent | undefined, now = Date.now()): boolean {
  165. if (!agent) return false;
  166. if (agent.status === 'running') return true;
  167. if (agent.status === 'idle' || agent.status === 'offline' || agent.status === 'stopped') {
  168. return false;
  169. }
  170. const local = this._localStart().get(agent.id);
  171. return local !== undefined && now - local < REPLY_QUIET_MS;
  172. }
  173. /** 右侧「最近动态」:只列本会话的翻转(用户真正关心的是它在干什么) */
  174. pulsesFor(agentId: string | null): readonly TaskPulse[] {
  175. const all = this._pulses();
  176. return (agentId ? all.filter((p) => p.agentId === agentId) : all).slice(0, 6);
  177. }
  178. /** 「当前任务」的一句话文案(唯一实现,列表/头部/右栏都用它) */
  179. describe(
  180. agent: Agent | undefined,
  181. now = Date.now(),
  182. sessionId?: string | null,
  183. roomId?: string | null,
  184. myUserId?: string | null,
  185. ): TaskLine {
  186. // 依赖这些信号,让调用方在数据更新后重算
  187. this._localStart();
  188. this.live.tick();
  189. return this.describeOf(agent, now, sessionId, roomId, myUserId);
  190. }
  191. private describeOf(
  192. agent: Agent | undefined,
  193. now: number,
  194. sessionId?: string | null,
  195. roomId?: string | null,
  196. myUserId?: string | null,
  197. ): TaskLine {
  198. if (!agent) return { text: '', tone: 'neutral' };
  199. const local = this._localStart().get(agent.id);
  200. /*
  201. * 🔴 不可用优先,且判据与全站同一套(2026-11-08)。
  202. *
  203. * 原先这里只判 `status`,不看心跳。后果:45 天没心跳、`status=running`
  204. * 的僵尸容器在这里显示「状态未同步」或甚至任务名,而移动端会话列表
  205. * 已经把它判成红色「离线」—— 同一个容器两处结论相反。
  206. * 现在直接复用 `resolveAgentState`,先处理不可用分支。
  207. */
  208. const resolved = resolveAgentState({ agent, now });
  209. if (resolved.state === 'error') {
  210. return { text: '运行异常', tone: 'danger' };
  211. }
  212. if (resolved.state === 'offline') {
  213. return { text: '已离线', tone: 'danger' };
  214. }
  215. /*
  216. * 🔴 2026-11-09 调整顺序(C3):
  217. *
  218. * 原顺序是「status=running → 心跳未同步 → 正在回复」,有两个问题:
  219. * ① `local && isBusy`(用户刚发消息)排在 `status=running` 之后,
  220. * 容器报 running 时永远走不到 → 用户发完消息看不到「刚刚开始」;
  221. * ② 心跳过期(>90s)检查排在「最近有消息进来」之前 → 容器正在回话时,
  222. * 顶栏却显示「状态未同步」,与正文的「正在输出…」直接打架。
  223. *
  224. * 正确优先级:**用户动作 / 实时消息流 > 容器自报 > 心跳新鲜度**。
  225. * 理由:消息流是实时的,容器上报是分钟级的;越实时的信号越应该优先。
  226. */
  227. if (local && this.isBusy(agent, now)) {
  228. return {
  229. text: '刚刚开始 · 正在处理你的消息',
  230. tone: 'info',
  231. active: true,
  232. since: local,
  233. };
  234. }
  235. /*
  236. * 🔴 会话页:**只看本会话的进度**(2026-11-11 用户裁决)。
  237. *
  238. * 现象:打开会话 A,页头显示「已完成 · 用时 10 分 16 秒」,而这个数字来自
  239. * 容器**全局**的 `status` / `currentTask` —— 容器同一时刻可能正在跑会话 B,
  240. * 用户看到的却是别人的进度(实测截图)。
  241. *
  242. * 容器在 `pre_llm_call`/`post_llm_call` 里按会话维护 `extra.currentSessions`
  243. * (`{sessionKey,status,turnStartAt}`),App 侧 `sessionRunning()` 已经能按
  244. * `sessionId` 查出本会话状态。所以:
  245. *
  246. * · 本会话 running → 显示任务(容器全局任务名只在**本会话确实在跑**时采信)
  247. * · 本会话明确不在跑 → 不再引用全局 `status`/`lastFinish`,直接「在线」
  248. * · 容器没上报会话级状态 → 返回 undefined,回落到原有全局口径(老容器兜底)
  249. *
  250. * 用户原话:「右上角仅显示当前会话的进度,如果当前会话没有就显示在线状态」。
  251. */
  252. const sessionAware = sessionId !== undefined && sessionId !== null;
  253. /*
  254. * 容器**有**会话级上报,我才敢拿它下结论。
  255. * `currentSessions` 为空 = 老容器从没报过 → `mine` 只能是 undefined,
  256. * 此时回落到下面的全局口径,行为与改造前完全一致。
  257. */
  258. const hasSessionReport = (agent.currentSessions?.length ?? 0) > 0;
  259. const mine = sessionAware ? sessionRunning(agent.currentSessions, sessionId, roomId, myUserId) : undefined;
  260. if (sessionAware && hasSessionReport && mine !== true) {
  261. /*
  262. * 本会话没在跑 —— 两种情况都归到这里:
  263. * · `mine === false`:容器明确报了本会话 idle
  264. * · `mine === undefined`:容器报了别的会话、**没报本会话**
  265. * (`sessionRunning` 约定 undefined = 不知道;但在「页面已指定会话」
  266. * 的语义下,容器报了一堆会话却唯独没有我这条,就是**我没有在跑**)
  267. *
  268. * 但上报是分钟级的,刚发完消息时容器可能还没更新 —— 用「最近有消息进来」
  269. * 兜住,避免正文还在流式输出就显示「在线」。
  270. */
  271. const quietMs = now - this.lastActivity(agent.id);
  272. if (quietMs < REPLY_QUIET_MS) {
  273. return { text: '正在回复…', tone: 'info', active: true };
  274. }
  275. return { text: '在线', tone: 'success' };
  276. }
  277. /*
  278. * 容器已经回 idle,但可能**还在往外发消息**(用户实测:还在回复就显示「已完成」)。
  279. *
  280. * 容器的运行态是分钟级上报的,turn 结束与最后一条消息之间可能差几十秒;
  281. * 而消息流是实时的。所以「最近有消息进来」比「上报说 idle」更新鲜,
  282. * 必须以它为准 —— 否则用户看着还在输出,状态却说完成了。
  283. */
  284. const quietMs = now - this.lastActivity(agent.id);
  285. if (quietMs < REPLY_QUIET_MS) {
  286. return { text: '正在回复…', tone: 'info', active: true };
  287. }
  288. /*
  289. * `mine === true` 表示**本会话确实在跑**,此时才采信容器的任务名;
  290. * 没上报会话级状态(老容器)仍按全局 `status` 判断,行为不变。
  291. */
  292. if ((mine === true || (mine === undefined && agent.status === 'running')) && !isReporterSelfTalk(agent)) {
  293. const task = agent.currentTask?.trim() || agent.currentAction?.trim();
  294. return { text: task || '正在处理', tone: 'success', active: true };
  295. }
  296. // 容器说 idle,但快照可能早就停了 → 分清「真空闲」和「状态没同步」
  297. const beat = agent.heartbeatAt ? Date.parse(agent.heartbeatAt) : NaN;
  298. if (Number.isFinite(beat) && now - beat > FRESH_MS) {
  299. return { text: `状态未同步 · 上次上报 ${agoText(now - beat)}`, tone: 'warning' };
  300. }
  301. const last = this.lastFinish(agent.id);
  302. if (last?.durationMs != null && now - last.at < 10 * 60_000) {
  303. return { text: `已完成 · 用时 ${fmtDur(last.durationMs)}`, tone: 'success' };
  304. }
  305. return { text: '空闲', tone: 'neutral' };
  306. }
  307. private lastFinish(agentId: string): TaskPulse | undefined {
  308. return this._pulses().find((p) => p.agentId === agentId && p.kind === 'finished');
  309. }
  310. private observe(agents: Agent[]): void {
  311. const now = Date.now();
  312. const pulses: TaskPulse[] = [];
  313. for (const a of agents) {
  314. const running = a.status === 'running';
  315. const prev = this.marks.get(a.id);
  316. if (prev) {
  317. if (!prev.running && running) {
  318. pulses.push({ agentId: a.id, kind: 'started', at: now, task: a.currentTask });
  319. } else if (prev.running && !running) {
  320. pulses.push({
  321. agentId: a.id,
  322. kind: 'finished',
  323. at: now,
  324. task: prev.task,
  325. durationMs: Math.max(0, now - prev.at),
  326. });
  327. }
  328. }
  329. /*
  330. * 本地「我刚发出消息」标记的清理:不能只挂在「running → 非 running」
  331. * 这条翻转上(2026-11-09)。
  332. *
  333. * 原因:若容器**从头到尾没进过 running**(比如它自报一直是 idle,
  334. * 或者上报间隔很长错过了整个翻转),`prev.running` 一直是 false,
  335. * 那段清理代码永远走不到,`localStart` 就会一直挂到进程结束。
  336. * 容器明确报非 running 时直接清掉即可,不需要等翻转。
  337. */
  338. if (!running) {
  339. const cur = this._localStart();
  340. if (cur.has(a.id)) {
  341. const next = new Map(cur);
  342. next.delete(a.id);
  343. this._localStart.set(next);
  344. }
  345. }
  346. this.marks.set(a.id, { running, at: running ? prev?.at ?? now : now, task: a.currentTask });
  347. }
  348. if (!this.primed) {
  349. this.primed = true;
  350. return; // 首次只建基线,不报翻转
  351. }
  352. if (pulses.length) this.push(...pulses);
  353. }
  354. private push(...items: TaskPulse[]): void {
  355. this._pulses.update((old) => [...items.slice().reverse(), ...old].slice(0, 40));
  356. }
  357. }
  358. export interface TaskLine {
  359. text: string;
  360. tone: 'success' | 'warning' | 'info' | 'danger' | 'neutral';
  361. /** 是否处于「正在做」状态(UI 加动效) */
  362. active?: boolean;
  363. /** 开始时刻(用来在前端递增显示已用时) */
  364. since?: number;
  365. }
  366. function agoText(ms: number): string {
  367. const sec = Math.max(0, Math.round(ms / 1000));
  368. if (sec < 60) return `${sec} 秒前`;
  369. const min = Math.round(sec / 60);
  370. if (min < 60) return `${min} 分钟前`;
  371. return `${Math.round(min / 60)} 小时前`;
  372. }
  373. function fmtDur(ms: number): string {
  374. const sec = Math.round(ms / 1000);
  375. if (sec < 60) return `${sec} 秒`;
  376. const m = Math.floor(sec / 60);
  377. const s = sec % 60;
  378. return s ? `${m} 分 ${s} 秒` : `${m} 分钟`;
  379. }