cli-claw-kit 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (295) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +245 -0
  3. package/config/default-groups.json +1 -0
  4. package/config/global-agents-md.template.md +37 -0
  5. package/config/mount-allowlist.json +11 -0
  6. package/container/Dockerfile +160 -0
  7. package/container/agent-runner/dist/.tsbuildinfo +1 -0
  8. package/container/agent-runner/dist/agent-definitions.js +22 -0
  9. package/container/agent-runner/dist/channel-prefixes.js +16 -0
  10. package/container/agent-runner/dist/codex-config.js +29 -0
  11. package/container/agent-runner/dist/image-detector.js +96 -0
  12. package/container/agent-runner/dist/index.js +2587 -0
  13. package/container/agent-runner/dist/mcp-tools.js +1076 -0
  14. package/container/agent-runner/dist/stream-event.types.js +5 -0
  15. package/container/agent-runner/dist/stream-processor.js +867 -0
  16. package/container/agent-runner/dist/types.js +6 -0
  17. package/container/agent-runner/dist/utils.js +115 -0
  18. package/container/agent-runner/package.json +36 -0
  19. package/container/agent-runner/prompts/security-rules.md +31 -0
  20. package/container/agent-runner/src/agent-definitions.ts +27 -0
  21. package/container/agent-runner/src/channel-prefixes.ts +16 -0
  22. package/container/agent-runner/src/codex-config.ts +40 -0
  23. package/container/agent-runner/src/image-detector.ts +116 -0
  24. package/container/agent-runner/src/index.ts +3107 -0
  25. package/container/agent-runner/src/mcp-tools.ts +1295 -0
  26. package/container/agent-runner/src/stream-event.types.ts +10 -0
  27. package/container/agent-runner/src/stream-processor.ts +932 -0
  28. package/container/agent-runner/src/types.ts +75 -0
  29. package/container/agent-runner/src/utils.ts +114 -0
  30. package/container/agent-runner/tsconfig.json +17 -0
  31. package/container/build.sh +28 -0
  32. package/container/entrypoint.sh +64 -0
  33. package/container/skills/agent-browser/SKILL.md +159 -0
  34. package/container/skills/install-skill/SKILL.md +64 -0
  35. package/container/skills/post-test-cleanup/SKILL.md +121 -0
  36. package/dist/.tsbuildinfo +1 -0
  37. package/dist/agent-output-parser.js +459 -0
  38. package/dist/app-root.js +52 -0
  39. package/dist/assistant-meta-footer.js +1 -0
  40. package/dist/auth.js +91 -0
  41. package/dist/billing.js +694 -0
  42. package/dist/channel-prefixes.js +16 -0
  43. package/dist/cli.js +86 -0
  44. package/dist/commands.js +79 -0
  45. package/dist/config.js +120 -0
  46. package/dist/container-runner.js +981 -0
  47. package/dist/daily-summary.js +210 -0
  48. package/dist/db.js +3683 -0
  49. package/dist/dingtalk.js +1347 -0
  50. package/dist/feishu-markdown-style.js +97 -0
  51. package/dist/feishu-streaming-card.js +1875 -0
  52. package/dist/feishu.js +1628 -0
  53. package/dist/file-manager.js +270 -0
  54. package/dist/group-queue.js +1070 -0
  55. package/dist/group-runtime.js +35 -0
  56. package/dist/host-workspace-cwd.js +85 -0
  57. package/dist/im-channel.js +384 -0
  58. package/dist/im-command-utils.js +142 -0
  59. package/dist/im-downloader.js +45 -0
  60. package/dist/im-manager.js +527 -0
  61. package/dist/im-utils.js +53 -0
  62. package/dist/image-detector.js +96 -0
  63. package/dist/index.js +5828 -0
  64. package/dist/logger.js +22 -0
  65. package/dist/mcp-utils.js +66 -0
  66. package/dist/message-attachments.js +69 -0
  67. package/dist/message-notifier.js +36 -0
  68. package/dist/middleware/auth.js +85 -0
  69. package/dist/mount-security.js +315 -0
  70. package/dist/permissions.js +67 -0
  71. package/dist/project-memory.js +6 -0
  72. package/dist/provider-pool.js +189 -0
  73. package/dist/qq.js +826 -0
  74. package/dist/reset-admin.js +42 -0
  75. package/dist/routes/admin.js +543 -0
  76. package/dist/routes/agent-definitions.js +241 -0
  77. package/dist/routes/agents.js +533 -0
  78. package/dist/routes/auth.js +675 -0
  79. package/dist/routes/billing.js +490 -0
  80. package/dist/routes/browse.js +210 -0
  81. package/dist/routes/bug-report.js +387 -0
  82. package/dist/routes/config.js +1868 -0
  83. package/dist/routes/files.js +671 -0
  84. package/dist/routes/groups.js +1367 -0
  85. package/dist/routes/mcp-servers.js +320 -0
  86. package/dist/routes/memory.js +523 -0
  87. package/dist/routes/monitor.js +307 -0
  88. package/dist/routes/skills.js +777 -0
  89. package/dist/routes/tasks.js +509 -0
  90. package/dist/routes/usage.js +64 -0
  91. package/dist/routes/workspace-config.js +458 -0
  92. package/dist/runtime-build.js +112 -0
  93. package/dist/runtime-command-handler.js +189 -0
  94. package/dist/runtime-command-registry.js +1 -0
  95. package/dist/runtime-config.js +1777 -0
  96. package/dist/runtime-identity.js +52 -0
  97. package/dist/schemas.js +590 -0
  98. package/dist/script-runner.js +64 -0
  99. package/dist/sdk-query.js +82 -0
  100. package/dist/skill-utils.js +145 -0
  101. package/dist/sqlite-compat.js +19 -0
  102. package/dist/stream-event.types.js +5 -0
  103. package/dist/streaming-runtime-meta.js +29 -0
  104. package/dist/task-scheduler.js +695 -0
  105. package/dist/task-utils.js +13 -0
  106. package/dist/telegram-pairing.js +59 -0
  107. package/dist/telegram.js +897 -0
  108. package/dist/terminal-manager.js +307 -0
  109. package/dist/tool-step-display.js +1 -0
  110. package/dist/types.js +1 -0
  111. package/dist/utils.js +85 -0
  112. package/dist/web-context.js +161 -0
  113. package/dist/web.js +1377 -0
  114. package/dist/wechat-crypto.js +182 -0
  115. package/dist/wechat.js +589 -0
  116. package/dist/workspace-runtime-reset.js +35 -0
  117. package/package.json +107 -0
  118. package/shared/assistant-meta-footer.ts +127 -0
  119. package/shared/channel-prefixes.ts +16 -0
  120. package/shared/dist/assistant-meta-footer.d.ts +29 -0
  121. package/shared/dist/assistant-meta-footer.js +85 -0
  122. package/shared/dist/channel-prefixes.d.ts +4 -0
  123. package/shared/dist/channel-prefixes.js +16 -0
  124. package/shared/dist/image-detector.d.ts +20 -0
  125. package/shared/dist/image-detector.js +96 -0
  126. package/shared/dist/runtime-command-registry.d.ts +38 -0
  127. package/shared/dist/runtime-command-registry.js +185 -0
  128. package/shared/dist/stream-event.d.ts +65 -0
  129. package/shared/dist/stream-event.js +8 -0
  130. package/shared/dist/tool-step-display.d.ts +4 -0
  131. package/shared/dist/tool-step-display.js +11 -0
  132. package/shared/image-detector.ts +116 -0
  133. package/shared/runtime-command-registry.ts +252 -0
  134. package/shared/stream-event.ts +67 -0
  135. package/shared/tool-step-display.ts +21 -0
  136. package/shared/tsconfig.json +24 -0
  137. package/web/dist/assets/BillingPage-B1wBR_o-.js +52 -0
  138. package/web/dist/assets/ChatPage-6GBZ9nXN.css +32 -0
  139. package/web/dist/assets/ChatPage-BOJcXtaj.js +161 -0
  140. package/web/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  141. package/web/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  142. package/web/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  143. package/web/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  144. package/web/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  145. package/web/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  146. package/web/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  147. package/web/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  148. package/web/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  149. package/web/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  150. package/web/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  151. package/web/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  152. package/web/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  153. package/web/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  154. package/web/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  155. package/web/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  156. package/web/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  157. package/web/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  158. package/web/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  159. package/web/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  160. package/web/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  161. package/web/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  162. package/web/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  163. package/web/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  164. package/web/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  165. package/web/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  166. package/web/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  167. package/web/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  168. package/web/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  169. package/web/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  170. package/web/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  171. package/web/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  172. package/web/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  173. package/web/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  174. package/web/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  175. package/web/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  176. package/web/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  177. package/web/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  178. package/web/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  179. package/web/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  180. package/web/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  181. package/web/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  182. package/web/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  183. package/web/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  184. package/web/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  185. package/web/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  186. package/web/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  187. package/web/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  188. package/web/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  189. package/web/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  190. package/web/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  191. package/web/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  192. package/web/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  193. package/web/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  194. package/web/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  195. package/web/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  196. package/web/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  197. package/web/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  198. package/web/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  199. package/web/dist/assets/SettingsPage-DoY7FoZ_.js +153 -0
  200. package/web/dist/assets/ShareImageDialog-C1ga8b7l.js +22 -0
  201. package/web/dist/assets/TasksPage-CRivnNsx.js +14 -0
  202. package/web/dist/assets/_basePickBy-Bf-bSoS9.js +1 -0
  203. package/web/dist/assets/_baseUniq-zAOaCuKw.js +1 -0
  204. package/web/dist/assets/arc-Dm9mVQ9U.js +1 -0
  205. package/web/dist/assets/architectureDiagram-2XIMDMQ5-BLmzX1wr.js +36 -0
  206. package/web/dist/assets/band-CquvqAHh.js +1 -0
  207. package/web/dist/assets/blockDiagram-WCTKOSBZ-B9pcqm3j.js +132 -0
  208. package/web/dist/assets/c4Diagram-IC4MRINW-Cytx1q3b.js +10 -0
  209. package/web/dist/assets/channel-BOVj73LR.js +1 -0
  210. package/web/dist/assets/channel-meta-CQD0Pei-.js +41 -0
  211. package/web/dist/assets/chunk-4BX2VUAB-0ToDr6RE.js +1 -0
  212. package/web/dist/assets/chunk-55IACEB6-DQDjnXfS.js +1 -0
  213. package/web/dist/assets/chunk-FMBD7UC4-Di8ABm6c.js +15 -0
  214. package/web/dist/assets/chunk-JSJVCQXG-BZQN6rnX.js +1 -0
  215. package/web/dist/assets/chunk-KX2RTZJC-zBbcpaN_.js +1 -0
  216. package/web/dist/assets/chunk-NQ4KR5QH-BCrLoU88.js +220 -0
  217. package/web/dist/assets/chunk-QZHKN3VN-Bqk8juan.js +1 -0
  218. package/web/dist/assets/chunk-WL4C6EOR-D2YX-MHY.js +189 -0
  219. package/web/dist/assets/classDiagram-VBA2DB6C-DUUoMyaK.js +1 -0
  220. package/web/dist/assets/classDiagram-v2-RAHNMMFH-DUUoMyaK.js +1 -0
  221. package/web/dist/assets/clone-BmaCesfa.js +1 -0
  222. package/web/dist/assets/cose-bilkent-S5V4N54A-CTsv6qQA.js +1 -0
  223. package/web/dist/assets/cytoscape.esm-BQaXIfA_.js +331 -0
  224. package/web/dist/assets/dagre-KLK3FWXG-Ci4Jh9nu.js +4 -0
  225. package/web/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  226. package/web/dist/assets/diagram-E7M64L7V-BFRnfTI2.js +24 -0
  227. package/web/dist/assets/diagram-IFDJBPK2-B7Zhnp0b.js +43 -0
  228. package/web/dist/assets/diagram-P4PSJMXO-BVyP7nwq.js +24 -0
  229. package/web/dist/assets/erDiagram-INFDFZHY-NorKdTOF.js +70 -0
  230. package/web/dist/assets/error-CGD5mp5f.js +1 -0
  231. package/web/dist/assets/flowDiagram-PKNHOUZH-Ch97nABF.js +162 -0
  232. package/web/dist/assets/ganttDiagram-A5KZAMGK-BQ2pLWsy.js +292 -0
  233. package/web/dist/assets/gitGraphDiagram-K3NZZRJ6-bcvnBsD2.js +65 -0
  234. package/web/dist/assets/graph-CeAEckur.js +1 -0
  235. package/web/dist/assets/index-CPnL1_qC.js +768 -0
  236. package/web/dist/assets/index-DVevCbcO.css +10 -0
  237. package/web/dist/assets/infoDiagram-LFFYTUFH-CcsrFdj-.js +2 -0
  238. package/web/dist/assets/init-Dmth1JHB.js +1 -0
  239. package/web/dist/assets/ishikawaDiagram-PHBUUO56-1upyMfHN.js +70 -0
  240. package/web/dist/assets/journeyDiagram-4ABVD52K-CKUi-V0c.js +139 -0
  241. package/web/dist/assets/kanban-definition-K7BYSVSG-DOnQwXfL.js +89 -0
  242. package/web/dist/assets/layout-BmMMqTnJ.js +1 -0
  243. package/web/dist/assets/linear-DiaJloY5.js +1 -0
  244. package/web/dist/assets/mermaid.core-BWLV1B2v.js +254 -0
  245. package/web/dist/assets/mindmap-definition-YRQLILUH-BeAKHVWP.js +68 -0
  246. package/web/dist/assets/ordinal-DILIJJjt.js +1 -0
  247. package/web/dist/assets/pieDiagram-SKSYHLDU-DfiMSfWo.js +30 -0
  248. package/web/dist/assets/quadrantDiagram-337W2JSQ-wZxZOJxd.js +7 -0
  249. package/web/dist/assets/requirementDiagram-Z7DCOOCP-BK4HHm17.js +73 -0
  250. package/web/dist/assets/sankeyDiagram-WA2Y5GQK-BX6t2avX.js +10 -0
  251. package/web/dist/assets/sequenceDiagram-2WXFIKYE-BPQlkbAa.js +145 -0
  252. package/web/dist/assets/sheet-rI0FfB1g.js +6 -0
  253. package/web/dist/assets/sliders-horizontal-CuijWFNK.js +6 -0
  254. package/web/dist/assets/sparkles-BsMYXJoT.js +11 -0
  255. package/web/dist/assets/square-0CqMX1Q3.js +11 -0
  256. package/web/dist/assets/stateDiagram-RAJIS63D-DxkV0Vwd.js +1 -0
  257. package/web/dist/assets/stateDiagram-v2-FVOUBMTO-qLYoiOPe.js +1 -0
  258. package/web/dist/assets/step-D51IIHGA.js +1 -0
  259. package/web/dist/assets/tasks-D8JjBTwx.js +1 -0
  260. package/web/dist/assets/time-O8zIGux3.js +1 -0
  261. package/web/dist/assets/timeline-definition-YZTLITO2-kNp1DyFc.js +61 -0
  262. package/web/dist/assets/treemap-KZPCXAKY-CkrClVhk.js +162 -0
  263. package/web/dist/assets/utils-KGAn0XTg.js +11 -0
  264. package/web/dist/assets/vennDiagram-LZ73GAT5-CgdzEZz4.js +34 -0
  265. package/web/dist/assets/xychartDiagram-JWTSCODW-DfYGPfNB.js +7 -0
  266. package/web/dist/assets/zap-_hKJYy7J.js +6 -0
  267. package/web/dist/favicon.svg +332 -0
  268. package/web/dist/fonts/AlibabaPuHuiTi-3-55-Regular.woff2 +0 -0
  269. package/web/dist/fonts/AlibabaPuHuiTi-3-65-Medium.woff2 +0 -0
  270. package/web/dist/fonts/AlibabaPuHuiTi-3-75-SemiBold.woff2 +0 -0
  271. package/web/dist/fonts/DMSans-latin-ext.woff2 +0 -0
  272. package/web/dist/fonts/DMSans-latin.woff2 +0 -0
  273. package/web/dist/icons/README.md +20 -0
  274. package/web/dist/icons/apple-touch-icon-180.png +0 -0
  275. package/web/dist/icons/icon-128.png +0 -0
  276. package/web/dist/icons/icon-144.png +0 -0
  277. package/web/dist/icons/icon-152.png +0 -0
  278. package/web/dist/icons/icon-192.png +0 -0
  279. package/web/dist/icons/icon-192.svg +332 -0
  280. package/web/dist/icons/icon-384.png +0 -0
  281. package/web/dist/icons/icon-48.png +0 -0
  282. package/web/dist/icons/icon-512-maskable.png +0 -0
  283. package/web/dist/icons/icon-512.png +0 -0
  284. package/web/dist/icons/icon-512.svg +332 -0
  285. package/web/dist/icons/icon-72.png +0 -0
  286. package/web/dist/icons/icon-96.png +0 -0
  287. package/web/dist/icons/loading-logo.svg +332 -0
  288. package/web/dist/icons/logo-1024.png +0 -0
  289. package/web/dist/icons/logo-icon.svg +332 -0
  290. package/web/dist/icons/logo-text.svg +332 -0
  291. package/web/dist/index.html +30 -0
  292. package/web/dist/manifest.webmanifest +1 -0
  293. package/web/dist/registerSW.js +1 -0
  294. package/web/dist/sw.js +1 -0
  295. package/web/dist/workbox-08d6266a.js +1 -0
@@ -0,0 +1,1875 @@
1
+ import { createHash } from 'crypto';
2
+ import { logger } from './logger.js';
3
+ import { optimizeMarkdownStyle } from './feishu-markdown-style.js';
4
+ import { formatAssistantMetaFooter, } from './assistant-meta-footer.js';
5
+ import { getModelPresets, getReasoningEffortPresets, supportsReasoningEffort, } from './runtime-command-registry.js';
6
+ import { formatToolStepLine } from './tool-step-display.js';
7
+ /**
8
+ * Scan text for fenced code block ranges (``` ... ```).
9
+ */
10
+ function findCodeBlockRanges(text) {
11
+ const ranges = [];
12
+ const regex = /^```(\w*)\s*$/gm;
13
+ let match;
14
+ let openMatch = null;
15
+ let openLang = '';
16
+ while ((match = regex.exec(text)) !== null) {
17
+ if (!openMatch) {
18
+ openMatch = match;
19
+ openLang = match[1] || '';
20
+ }
21
+ else {
22
+ ranges.push({
23
+ open: openMatch.index,
24
+ close: match.index + match[0].length,
25
+ lang: openLang,
26
+ });
27
+ openMatch = null;
28
+ openLang = '';
29
+ }
30
+ }
31
+ // Unclosed code block — treat from open to end of text
32
+ if (openMatch) {
33
+ ranges.push({
34
+ open: openMatch.index,
35
+ close: text.length,
36
+ lang: openLang,
37
+ });
38
+ }
39
+ return ranges;
40
+ }
41
+ /**
42
+ * Check if a position falls inside any code block range.
43
+ * Returns the range if found, null otherwise.
44
+ */
45
+ function findContainingBlock(pos, ranges) {
46
+ for (const r of ranges) {
47
+ if (pos > r.open && pos < r.close)
48
+ return r;
49
+ }
50
+ return null;
51
+ }
52
+ /**
53
+ * Split text respecting fenced code block boundaries.
54
+ * Unlike splitAtParagraphs(), this never truncates inside a code block
55
+ * without properly closing/reopening the fence.
56
+ */
57
+ function splitCodeBlockSafe(text, maxLen) {
58
+ if (text.length <= maxLen)
59
+ return [text];
60
+ const chunks = [];
61
+ let remaining = text;
62
+ while (remaining.length > maxLen) {
63
+ // Recompute ranges on current remaining text each iteration.
64
+ // This handles synthetic reopeners correctly since all positions
65
+ // are relative to `remaining`, not the original text.
66
+ const ranges = findCodeBlockRanges(remaining);
67
+ // Find a split point around maxLen
68
+ let idx = remaining.lastIndexOf('\n\n', maxLen);
69
+ if (idx < maxLen * 0.3)
70
+ idx = remaining.lastIndexOf('\n', maxLen);
71
+ if (idx < maxLen * 0.3)
72
+ idx = maxLen;
73
+ const block = findContainingBlock(idx, ranges);
74
+ if (block) {
75
+ // Split point is inside a code block
76
+ if (block.open > 0 && block.open > maxLen * 0.3) {
77
+ // Retreat to just before the code block opening
78
+ const retreatIdx = remaining.lastIndexOf('\n', block.open);
79
+ idx = retreatIdx > maxLen * 0.3 ? retreatIdx : block.open;
80
+ chunks.push(remaining.slice(0, idx).trimEnd());
81
+ remaining = remaining.slice(idx).replace(/^\n+/, '');
82
+ }
83
+ else {
84
+ // Block starts too early to retreat — split inside but close/reopen fence
85
+ const chunk = remaining.slice(0, idx).trimEnd() + '\n```';
86
+ chunks.push(chunk);
87
+ const reopener = '```' + block.lang + '\n';
88
+ remaining = reopener + remaining.slice(idx).replace(/^\n/, '');
89
+ }
90
+ }
91
+ else {
92
+ chunks.push(remaining.slice(0, idx).trimEnd());
93
+ remaining = remaining.slice(idx).replace(/^\n+/, '');
94
+ }
95
+ }
96
+ if (remaining)
97
+ chunks.push(remaining);
98
+ return chunks;
99
+ }
100
+ const CARD_MD_LIMIT = 4000;
101
+ const CARD_SIZE_LIMIT = 25 * 1024; // Feishu limit ~30KB, 5KB safety margin
102
+ // ─── Legacy Card Builder (Fallback) ──────────────────────────
103
+ function splitAtParagraphs(text, maxLen) {
104
+ if (text.length <= maxLen)
105
+ return [text];
106
+ const chunks = [];
107
+ let remaining = text;
108
+ while (remaining.length > maxLen) {
109
+ let idx = remaining.lastIndexOf('\n\n', maxLen);
110
+ if (idx < maxLen * 0.3)
111
+ idx = remaining.lastIndexOf('\n', maxLen);
112
+ if (idx < maxLen * 0.3)
113
+ idx = maxLen;
114
+ chunks.push(remaining.slice(0, idx).trim());
115
+ remaining = remaining.slice(idx).trim();
116
+ }
117
+ if (remaining)
118
+ chunks.push(remaining);
119
+ return chunks;
120
+ }
121
+ function extractTitleAndBody(text) {
122
+ const lines = text.split('\n');
123
+ let title = '';
124
+ let bodyStartIdx = 0;
125
+ for (let i = 0; i < lines.length; i++) {
126
+ if (!lines[i].trim())
127
+ continue;
128
+ if (/^#{1,3}\s+/.test(lines[i])) {
129
+ title = lines[i].replace(/^#+\s*/, '').trim();
130
+ bodyStartIdx = i + 1;
131
+ }
132
+ break;
133
+ }
134
+ const body = lines.slice(bodyStartIdx).join('\n').trim();
135
+ if (!title) {
136
+ const firstLine = (lines.find((l) => l.trim()) || '')
137
+ .replace(/[*_`#\[\]]/g, '')
138
+ .trim();
139
+ title =
140
+ firstLine.length > 40
141
+ ? firstLine.slice(0, 37) + '...'
142
+ : firstLine || 'Reply';
143
+ }
144
+ return { title, body };
145
+ }
146
+ /**
147
+ * Build the content elements shared by both Legacy and Schema 2.0 card builders.
148
+ * Splits long text, handles `---` section dividers, and extracts the title.
149
+ * Applies optimizeMarkdownStyle() for proper Feishu rendering.
150
+ */
151
+ function buildCardContent(text, splitFn, overrideTitle) {
152
+ const { title: extractedTitle, body } = extractTitleAndBody(text);
153
+ const title = overrideTitle || extractedTitle;
154
+ // Apply Markdown optimization for Feishu card rendering
155
+ const rawContent = body || text.trim();
156
+ const contentToRender = optimizeMarkdownStyle(rawContent, 2);
157
+ const elements = [];
158
+ if (contentToRender.length > CARD_MD_LIMIT) {
159
+ for (const chunk of splitFn(contentToRender, CARD_MD_LIMIT)) {
160
+ elements.push({
161
+ tag: 'markdown',
162
+ content: chunk,
163
+ text_size: 'normal_text',
164
+ });
165
+ }
166
+ }
167
+ else if (contentToRender) {
168
+ // Keep --- as markdown content instead of using { tag: 'hr' }
169
+ // because Schema 2.0 (CardKit) does not support the hr tag.
170
+ elements.push({
171
+ tag: 'markdown',
172
+ content: contentToRender,
173
+ text_size: 'normal_text',
174
+ });
175
+ }
176
+ if (elements.length === 0) {
177
+ elements.push({
178
+ tag: 'markdown',
179
+ content: text.trim() || '...',
180
+ text_size: 'normal_text',
181
+ });
182
+ }
183
+ return { title, contentElements: elements };
184
+ }
185
+ // ─── Interrupt Button Element ────────────────────────────────
186
+ /** Schema 1.0: `action` container wrapping a button (used by legacy message.patch path) */
187
+ const INTERRUPT_BUTTON = {
188
+ tag: 'action',
189
+ actions: [
190
+ {
191
+ tag: 'button',
192
+ text: { tag: 'plain_text', content: '⏹ 中断回复' },
193
+ type: 'danger',
194
+ value: { action: 'interrupt_stream' },
195
+ },
196
+ ],
197
+ };
198
+ /** Schema 2.0: standalone button (CardKit rejects `tag: 'action'` in v2 cards) */
199
+ const INTERRUPT_BUTTON_V2 = {
200
+ tag: 'button',
201
+ text: { tag: 'plain_text', content: '⏹ 中断回复' },
202
+ type: 'danger',
203
+ value: { action: 'interrupt_stream' },
204
+ };
205
+ function buildRuntimeSelectElement(options) {
206
+ return {
207
+ tag: 'select_static',
208
+ placeholder: {
209
+ tag: 'plain_text',
210
+ content: options.placeholder,
211
+ },
212
+ value: {
213
+ action: options.action,
214
+ },
215
+ options: options.values.map((value) => ({
216
+ text: {
217
+ tag: 'plain_text',
218
+ content: value,
219
+ },
220
+ value,
221
+ })),
222
+ };
223
+ }
224
+ function buildRuntimeControlElements(runtimeIdentity) {
225
+ const agentType = runtimeIdentity?.agentType;
226
+ if (agentType !== 'claude' && agentType !== 'codex')
227
+ return [];
228
+ const modelPlaceholder = runtimeIdentity?.model
229
+ ? `模型: ${runtimeIdentity.model}`
230
+ : '切换模型';
231
+ const elements = [
232
+ buildRuntimeSelectElement({
233
+ action: 'set_runtime_model',
234
+ placeholder: modelPlaceholder,
235
+ values: getModelPresets(agentType),
236
+ }),
237
+ ];
238
+ if (supportsReasoningEffort(agentType)) {
239
+ elements.push(buildRuntimeSelectElement({
240
+ action: 'set_runtime_effort',
241
+ placeholder: runtimeIdentity?.reasoningEffort
242
+ ? `思考强度: ${runtimeIdentity.reasoningEffort}`
243
+ : '切换思考强度',
244
+ values: getReasoningEffortPresets(),
245
+ }));
246
+ }
247
+ else {
248
+ elements.push({
249
+ tag: 'markdown',
250
+ content: '思考强度: 当前 runtime 不支持',
251
+ text_size: 'notation',
252
+ });
253
+ }
254
+ return elements;
255
+ }
256
+ // ─── Streaming Mode Constants ─────────────────────────────────
257
+ const ELEMENT_IDS = {
258
+ AUX_BEFORE: 'aux_before',
259
+ MAIN_CONTENT: 'main_content',
260
+ AUX_AFTER: 'aux_after',
261
+ INTERRUPT_BTN: 'interrupt_btn',
262
+ STATUS_NOTE: 'status_note',
263
+ };
264
+ const STREAMING_CONFIG = {
265
+ print_frequency_ms: { default: 50 },
266
+ print_step: { default: 2 },
267
+ print_strategy: 'fast',
268
+ };
269
+ const MAX_STREAMING_CONTENT = 100000; // cardElement.content() supports 100K chars
270
+ // ─── Auxiliary State & Builder ────────────────────────────────
271
+ const MAX_THINKING_CHARS = 800;
272
+ const MAX_RECENT_EVENTS = 5;
273
+ const MAX_TOOL_DISPLAY = 5;
274
+ const MAX_TODO_DISPLAY = 10;
275
+ const MAX_TOOL_SUMMARY_CHARS = 60;
276
+ const MAX_ELEMENT_CHARS = 4000;
277
+ const MAX_COMPLETED_TOOL_AGE = 30000; // 30s — purge completed tools after this
278
+ function buildCollapsiblePanel(title, body) {
279
+ return {
280
+ tag: 'collapsible_panel',
281
+ expanded: false,
282
+ border: { color: 'grey-300', corner_radius: '6px' },
283
+ header: {
284
+ title: {
285
+ tag: 'plain_text',
286
+ text_color: 'grey',
287
+ text_size: 'notation',
288
+ content: title,
289
+ },
290
+ icon: { tag: 'standard_icon', token: 'right_outlined', color: 'grey' },
291
+ icon_position: 'right',
292
+ icon_expanded_angle: 90,
293
+ },
294
+ elements: [{ tag: 'markdown', content: body, text_size: 'notation' }],
295
+ };
296
+ }
297
+ /**
298
+ * Build auxiliary markdown elements for the streaming card.
299
+ * Returns elements to insert before and after the main text content.
300
+ */
301
+ function buildAuxiliaryElements(aux) {
302
+ return buildAuxiliaryElementsForState(aux, 'streaming');
303
+ }
304
+ function buildAuxiliaryElementsForState(aux, state) {
305
+ const before = [];
306
+ const after = [];
307
+ const isStreamingLayout = state === 'streaming';
308
+ // ① System Status
309
+ if (aux.systemStatus) {
310
+ before.push({
311
+ tag: 'markdown',
312
+ content: `⏳ ${aux.systemStatus}`.slice(0, MAX_ELEMENT_CHARS),
313
+ text_size: 'notation',
314
+ });
315
+ }
316
+ // ② Thinking
317
+ if (aux.thinkingText) {
318
+ const truncated = aux.thinkingText.length > MAX_THINKING_CHARS
319
+ ? '...' + aux.thinkingText.slice(-(MAX_THINKING_CHARS - 3))
320
+ : aux.thinkingText;
321
+ if (isStreamingLayout) {
322
+ const quoted = truncated
323
+ .split('\n')
324
+ .map((l) => `> ${l}`)
325
+ .join('\n');
326
+ before.push({
327
+ tag: 'markdown',
328
+ content: `💭 **${aux.isThinking ? 'Reasoning...' : 'Reasoning'}**\n${quoted}`.slice(0, MAX_ELEMENT_CHARS),
329
+ text_size: 'notation',
330
+ });
331
+ }
332
+ else {
333
+ before.push(buildCollapsiblePanel('💭 Thinking', truncated.slice(0, MAX_ELEMENT_CHARS)));
334
+ }
335
+ }
336
+ else if (aux.isThinking) {
337
+ before.push({
338
+ tag: 'markdown',
339
+ content: '💭 **Thinking...**',
340
+ text_size: 'notation',
341
+ });
342
+ }
343
+ // ③ Active Tools (running first, then recent completed, max MAX_TOOL_DISPLAY)
344
+ const running = [];
345
+ const completed = [];
346
+ for (const [id, tc] of aux.toolCalls) {
347
+ if (tc.status === 'running')
348
+ running.push([id, tc]);
349
+ else
350
+ completed.push([id, tc]);
351
+ }
352
+ // Show running tools first, fill remaining slots with latest completed
353
+ const display = [
354
+ ...running,
355
+ ...completed.slice(-Math.max(0, MAX_TOOL_DISPLAY - running.length)),
356
+ ].slice(0, MAX_TOOL_DISPLAY);
357
+ if (display.length > 0) {
358
+ const lines = display.map(([, tc]) => {
359
+ const summary = tc.toolInputSummary
360
+ ? tc.toolInputSummary.length > MAX_TOOL_SUMMARY_CHARS
361
+ ? tc.toolInputSummary.slice(0, MAX_TOOL_SUMMARY_CHARS) + '...'
362
+ : tc.toolInputSummary
363
+ : undefined;
364
+ return formatToolStepLine(tc.name, summary);
365
+ });
366
+ if (isStreamingLayout) {
367
+ before.push({
368
+ tag: 'markdown',
369
+ content: lines.join('\n').slice(0, MAX_ELEMENT_CHARS),
370
+ text_size: 'notation',
371
+ });
372
+ }
373
+ else {
374
+ before.push(buildCollapsiblePanel(`${display.length} steps`, lines.join('\n').slice(0, MAX_ELEMENT_CHARS)));
375
+ }
376
+ }
377
+ // ④ Hook Status
378
+ if (aux.activeHook) {
379
+ before.push({
380
+ tag: 'markdown',
381
+ content: `🔗 Hook: ${aux.activeHook.hookName || aux.activeHook.hookEvent}`,
382
+ text_size: 'notation',
383
+ });
384
+ }
385
+ // ⑤ Todo Progress
386
+ if (aux.todos && aux.todos.length > 0) {
387
+ const total = aux.todos.length;
388
+ const done = aux.todos.filter((t) => t.status === 'completed').length;
389
+ const pct = total > 0 ? Math.round((done / total) * 100) : 0;
390
+ const header = `📋 **${done}/${total} (${pct}%)**`;
391
+ const items = aux.todos.slice(0, MAX_TODO_DISPLAY).map((t) => {
392
+ const icon = t.status === 'completed'
393
+ ? '✅'
394
+ : t.status === 'in_progress'
395
+ ? '⏳'
396
+ : '○';
397
+ return `${icon} ${t.content}`;
398
+ });
399
+ const extra = total > MAX_TODO_DISPLAY ? `\n... +${total - MAX_TODO_DISPLAY} 项` : '';
400
+ before.push({
401
+ tag: 'markdown',
402
+ content: `${header}\n${items.join('\n')}${extra}`.slice(0, MAX_ELEMENT_CHARS),
403
+ text_size: 'notation',
404
+ });
405
+ }
406
+ return { before, after };
407
+ }
408
+ // ─── Legacy Card Builder (Fallback) ──────────────────────────
409
+ function buildStreamingCard(text, state, footerNote, runtimeIdentity) {
410
+ const { title, contentElements: elements } = buildCardContent(text, splitAtParagraphs);
411
+ const noteMap = {
412
+ streaming: '⏳ 生成中...',
413
+ completed: '',
414
+ aborted: '⚠️ 已中断',
415
+ };
416
+ const headerTemplate = {
417
+ streaming: 'wathet',
418
+ completed: 'indigo',
419
+ aborted: 'orange',
420
+ };
421
+ if (state === 'streaming') {
422
+ elements.push(INTERRUPT_BUTTON);
423
+ }
424
+ elements.push(...buildRuntimeControlElements(runtimeIdentity));
425
+ if (noteMap[state]) {
426
+ elements.push({
427
+ tag: 'note',
428
+ elements: [{ tag: 'plain_text', content: noteMap[state] }],
429
+ });
430
+ }
431
+ if (footerNote) {
432
+ elements.push({
433
+ tag: 'note',
434
+ elements: [{ tag: 'plain_text', content: footerNote }],
435
+ });
436
+ }
437
+ return {
438
+ config: { wide_screen_mode: true },
439
+ header: {
440
+ title: { tag: 'plain_text', content: title },
441
+ template: headerTemplate[state],
442
+ },
443
+ elements,
444
+ };
445
+ }
446
+ const SCHEMA2_NOTE_MAP = {
447
+ streaming: '⏳ 生成中...',
448
+ completed: '',
449
+ aborted: '⚠️ 已中断',
450
+ frozen: '',
451
+ };
452
+ function buildSchema2Card(text, state, titlePrefix = '', overrideTitle, auxiliaryState, footerNote, runtimeIdentity) {
453
+ const { title, contentElements } = buildCardContent(text, splitCodeBlockSafe, overrideTitle);
454
+ const displayTitle = titlePrefix ? `${titlePrefix}${title}` : title;
455
+ // Build final elements array with auxiliary sections
456
+ const elements = [];
457
+ if (auxiliaryState) {
458
+ const { before, after } = buildAuxiliaryElementsForState(auxiliaryState, state);
459
+ elements.push(...before);
460
+ elements.push(...contentElements);
461
+ elements.push(...after);
462
+ }
463
+ else {
464
+ elements.push(...contentElements);
465
+ }
466
+ if (state === 'streaming') {
467
+ elements.push(INTERRUPT_BUTTON_V2);
468
+ }
469
+ elements.push(...buildRuntimeControlElements(runtimeIdentity));
470
+ if (SCHEMA2_NOTE_MAP[state]) {
471
+ elements.push({
472
+ tag: 'markdown',
473
+ content: SCHEMA2_NOTE_MAP[state],
474
+ text_size: 'notation',
475
+ });
476
+ }
477
+ if (footerNote) {
478
+ elements.push({
479
+ tag: 'markdown',
480
+ content: '---',
481
+ text_size: 'notation',
482
+ });
483
+ elements.push({
484
+ tag: 'markdown',
485
+ content: `*${footerNote}*`,
486
+ text_size: 'notation',
487
+ });
488
+ }
489
+ return {
490
+ schema: '2.0',
491
+ config: {
492
+ wide_screen_mode: true,
493
+ summary: { content: displayTitle },
494
+ },
495
+ body: { elements },
496
+ };
497
+ }
498
+ // ─── Usage Note Formatter ─────────────────────────────────────
499
+ // ─── Streaming Mode Card Builder ──────────────────────────────
500
+ function buildStreamingModeCard(initialText, runtimeIdentity) {
501
+ const { title } = extractTitleAndBody(initialText);
502
+ const displayTitle = title || '...';
503
+ return {
504
+ schema: '2.0',
505
+ config: {
506
+ wide_screen_mode: true,
507
+ summary: { content: displayTitle },
508
+ streaming_mode: true,
509
+ streaming_config: STREAMING_CONFIG,
510
+ },
511
+ body: {
512
+ elements: [
513
+ {
514
+ tag: 'markdown',
515
+ content: '',
516
+ element_id: ELEMENT_IDS.AUX_BEFORE,
517
+ text_size: 'notation',
518
+ },
519
+ {
520
+ tag: 'markdown',
521
+ content: initialText || '...',
522
+ element_id: ELEMENT_IDS.MAIN_CONTENT,
523
+ text_size: 'normal_text',
524
+ },
525
+ {
526
+ tag: 'markdown',
527
+ content: '',
528
+ element_id: ELEMENT_IDS.AUX_AFTER,
529
+ text_size: 'notation',
530
+ },
531
+ {
532
+ tag: 'button',
533
+ text: { tag: 'plain_text', content: '⏹ 中断回复' },
534
+ type: 'danger',
535
+ value: { action: 'interrupt_stream' },
536
+ element_id: ELEMENT_IDS.INTERRUPT_BTN,
537
+ },
538
+ ...buildRuntimeControlElements(runtimeIdentity),
539
+ {
540
+ tag: 'markdown',
541
+ content: '⏳ 生成中...',
542
+ element_id: ELEMENT_IDS.STATUS_NOTE,
543
+ text_size: 'notation',
544
+ },
545
+ ],
546
+ },
547
+ };
548
+ }
549
+ /**
550
+ * Serialize auxiliary element array into a single markdown string.
551
+ * Reuses output from buildAuxiliaryElements().
552
+ */
553
+ function serializeAuxContent(elements) {
554
+ return elements
555
+ .map((e) => e.content || '')
556
+ .filter(Boolean)
557
+ .join('\n\n');
558
+ }
559
+ // ─── Flush Controller ─────────────────────────────────────────
560
+ class FlushController {
561
+ timer = null;
562
+ lastFlushTime = 0;
563
+ lastFlushedLength = 0;
564
+ pendingFlush = null;
565
+ /** Minimum interval between flushes (ms) */
566
+ minInterval;
567
+ /** Minimum text change to trigger a flush (chars) */
568
+ minDelta;
569
+ constructor(minInterval = 1200, minDelta = 50) {
570
+ this.minInterval = minInterval;
571
+ this.minDelta = minDelta;
572
+ }
573
+ /**
574
+ * Schedule a flush. If a flush is already pending, replace it.
575
+ * The flush function will be called after the minimum interval.
576
+ */
577
+ schedule(currentLength, flushFn) {
578
+ // Check text change threshold
579
+ if (currentLength - this.lastFlushedLength < this.minDelta) {
580
+ // Still schedule in case no more text comes (ensure eventual flush)
581
+ if (!this.timer) {
582
+ this.pendingFlush = flushFn;
583
+ this.timer = setTimeout(() => {
584
+ this.timer = null;
585
+ this.executeFlush();
586
+ }, this.minInterval);
587
+ }
588
+ else {
589
+ this.pendingFlush = flushFn;
590
+ }
591
+ return;
592
+ }
593
+ // Enough text change — schedule or execute
594
+ this.pendingFlush = flushFn;
595
+ const elapsed = Date.now() - this.lastFlushTime;
596
+ if (elapsed >= this.minInterval) {
597
+ // Can flush immediately
598
+ this.clearTimer();
599
+ this.executeFlush();
600
+ }
601
+ else if (!this.timer) {
602
+ // Schedule for remaining interval
603
+ this.timer = setTimeout(() => {
604
+ this.timer = null;
605
+ this.executeFlush();
606
+ }, this.minInterval - elapsed);
607
+ }
608
+ // else: timer already running, will pick up pendingFlush
609
+ }
610
+ /** Force flush immediately (for complete/abort) */
611
+ async forceFlush(flushFn) {
612
+ this.clearTimer();
613
+ this.pendingFlush = flushFn;
614
+ await this.executeFlush();
615
+ }
616
+ async executeFlush() {
617
+ const fn = this.pendingFlush;
618
+ this.pendingFlush = null;
619
+ if (!fn)
620
+ return;
621
+ this.lastFlushTime = Date.now();
622
+ try {
623
+ await fn();
624
+ }
625
+ catch (err) {
626
+ logger.debug({ err }, 'FlushController: flush failed');
627
+ }
628
+ }
629
+ markFlushed(length) {
630
+ this.lastFlushedLength = length;
631
+ }
632
+ clearTimer() {
633
+ if (this.timer) {
634
+ clearTimeout(this.timer);
635
+ this.timer = null;
636
+ }
637
+ }
638
+ dispose() {
639
+ this.clearTimer();
640
+ this.pendingFlush = null;
641
+ }
642
+ }
643
+ // ─── CardKit Backend ──────────────────────────────────────────
644
+ function quickHash(data) {
645
+ return createHash('md5').update(data).digest('hex');
646
+ }
647
+ class CardKitBackend {
648
+ cardId = null;
649
+ _messageId = null;
650
+ sequence = 0;
651
+ lastContentHash = '';
652
+ client;
653
+ constructor(client) {
654
+ this.client = client;
655
+ }
656
+ get messageId() {
657
+ return this._messageId;
658
+ }
659
+ /**
660
+ * Create a CardKit card instance.
661
+ * Returns the card_id for subsequent updates.
662
+ */
663
+ async createCard(cardJson) {
664
+ const resp = await this.client.cardkit.v1.card.create({
665
+ data: {
666
+ type: 'card_json',
667
+ data: JSON.stringify(cardJson),
668
+ },
669
+ });
670
+ const cardId = resp?.data?.card_id;
671
+ if (!cardId) {
672
+ const code = resp?.code;
673
+ const msg = resp?.msg;
674
+ throw new Error(`CardKit card.create returned no card_id (code=${code}, msg=${msg})`);
675
+ }
676
+ this.cardId = cardId;
677
+ this.sequence = 1;
678
+ this.lastContentHash = quickHash(JSON.stringify(cardJson));
679
+ logger.debug({ cardId }, 'CardKit card created');
680
+ return cardId;
681
+ }
682
+ /**
683
+ * Send the card as a message (referencing card_id).
684
+ * Returns the message_id.
685
+ */
686
+ async sendCard(chatId, replyToMsgId) {
687
+ if (!this.cardId) {
688
+ throw new Error('Cannot sendCard before createCard');
689
+ }
690
+ const content = JSON.stringify({
691
+ type: 'card',
692
+ data: { card_id: this.cardId },
693
+ });
694
+ let resp;
695
+ if (replyToMsgId) {
696
+ resp = await this.client.im.message.reply({
697
+ path: { message_id: replyToMsgId },
698
+ data: { content, msg_type: 'interactive' },
699
+ });
700
+ }
701
+ else {
702
+ resp = await this.client.im.v1.message.create({
703
+ params: { receive_id_type: 'chat_id' },
704
+ data: {
705
+ receive_id: chatId,
706
+ msg_type: 'interactive',
707
+ content,
708
+ },
709
+ });
710
+ }
711
+ const messageId = resp?.data?.message_id;
712
+ if (!messageId) {
713
+ throw new Error('No message_id in sendCard response');
714
+ }
715
+ this._messageId = messageId;
716
+ return messageId;
717
+ }
718
+ /**
719
+ * Update the card via CardKit card.update with sequence-based optimistic locking.
720
+ * Skips if content hash is unchanged.
721
+ */
722
+ async updateCard(cardJson) {
723
+ if (!this.cardId)
724
+ return;
725
+ const dataStr = JSON.stringify(cardJson);
726
+ const hash = quickHash(dataStr);
727
+ if (hash === this.lastContentHash)
728
+ return; // no change
729
+ this.sequence++;
730
+ await this.client.cardkit.v1.card.update({
731
+ path: { card_id: this.cardId },
732
+ data: {
733
+ card: { type: 'card_json', data: dataStr },
734
+ sequence: this.sequence,
735
+ },
736
+ });
737
+ this.lastContentHash = hash;
738
+ }
739
+ /**
740
+ * Adopt an existing card_id + messageId (for degradation from streaming mode).
741
+ */
742
+ adoptCard(cardId, messageId, sequence) {
743
+ this.cardId = cardId;
744
+ this._messageId = messageId;
745
+ this.sequence = sequence;
746
+ }
747
+ }
748
+ // ─── Streaming Mode Backend ───────────────────────────────────
749
+ class StreamingModeBackend {
750
+ cardId = null;
751
+ _messageId = null;
752
+ sequence = 0;
753
+ lastMainHash = '';
754
+ lastAuxBeforeHash = '';
755
+ lastAuxAfterHash = '';
756
+ client;
757
+ constructor(client) {
758
+ this.client = client;
759
+ }
760
+ get messageId() {
761
+ return this._messageId;
762
+ }
763
+ getCardId() {
764
+ return this.cardId;
765
+ }
766
+ getSequence() {
767
+ return this.sequence;
768
+ }
769
+ nextSequence() {
770
+ return ++this.sequence;
771
+ }
772
+ /**
773
+ * Create a CardKit card instance with streaming_mode enabled.
774
+ */
775
+ async createCard(cardJson) {
776
+ const resp = await this.client.cardkit.v1.card.create({
777
+ data: {
778
+ type: 'card_json',
779
+ data: JSON.stringify(cardJson),
780
+ },
781
+ });
782
+ const cardId = resp?.data?.card_id;
783
+ if (!cardId) {
784
+ const code = resp?.code;
785
+ const msg = resp?.msg;
786
+ throw new Error(`Streaming card.create returned no card_id (code=${code}, msg=${msg})`);
787
+ }
788
+ this.cardId = cardId;
789
+ this.sequence = 1;
790
+ logger.debug({ cardId }, 'Streaming mode card created');
791
+ return cardId;
792
+ }
793
+ /**
794
+ * Send the card as a message. Returns message_id.
795
+ */
796
+ async sendCard(chatId, replyToMsgId) {
797
+ if (!this.cardId)
798
+ throw new Error('Cannot sendCard before createCard');
799
+ const content = JSON.stringify({
800
+ type: 'card',
801
+ data: { card_id: this.cardId },
802
+ });
803
+ let resp;
804
+ if (replyToMsgId) {
805
+ resp = await this.client.im.message.reply({
806
+ path: { message_id: replyToMsgId },
807
+ data: { content, msg_type: 'interactive' },
808
+ });
809
+ }
810
+ else {
811
+ resp = await this.client.im.v1.message.create({
812
+ params: { receive_id_type: 'chat_id' },
813
+ data: { receive_id: chatId, msg_type: 'interactive', content },
814
+ });
815
+ }
816
+ const messageId = resp?.data?.message_id;
817
+ if (!messageId)
818
+ throw new Error('No message_id in streaming sendCard response');
819
+ this._messageId = messageId;
820
+ return messageId;
821
+ }
822
+ /**
823
+ * Stream text content via cardElement.content() — platform renders typewriter effect.
824
+ * MD5 dedup to avoid redundant pushes.
825
+ * Auto-retries once on streaming timeout/closed errors.
826
+ */
827
+ async streamContent(text) {
828
+ if (!this.cardId)
829
+ return;
830
+ // Truncate at 100K char limit (hint at end, slice adjusted for hint length)
831
+ const truncHint = `\n\n> ⚠️ 输出已截断(超过 ${MAX_STREAMING_CONTENT} 字符)`;
832
+ const content = text.length > MAX_STREAMING_CONTENT
833
+ ? text.slice(0, MAX_STREAMING_CONTENT - truncHint.length) + truncHint
834
+ : text;
835
+ const hash = quickHash(content);
836
+ if (hash === this.lastMainHash)
837
+ return;
838
+ try {
839
+ await this.client.cardkit.v1.cardElement.content({
840
+ path: { card_id: this.cardId, element_id: ELEMENT_IDS.MAIN_CONTENT },
841
+ data: { content, sequence: this.nextSequence() },
842
+ });
843
+ this.lastMainHash = hash;
844
+ }
845
+ catch (err) {
846
+ const code = err?.code ?? err?.response?.data?.code;
847
+ // 200850 = streaming timeout, 300309 = streaming closed
848
+ if (code === 200850 || code === 300309) {
849
+ logger.info({ code, cardId: this.cardId }, 'Streaming mode expired, re-enabling');
850
+ await this.enableStreamingMode();
851
+ // Retry once
852
+ await this.client.cardkit.v1.cardElement.content({
853
+ path: { card_id: this.cardId, element_id: ELEMENT_IDS.MAIN_CONTENT },
854
+ data: { content, sequence: this.nextSequence() },
855
+ });
856
+ this.lastMainHash = hash;
857
+ }
858
+ else {
859
+ throw err;
860
+ }
861
+ }
862
+ }
863
+ /**
864
+ * Update an auxiliary element via cardElement.update() — instant replacement.
865
+ */
866
+ async updateAuxiliary(elementId, content) {
867
+ if (!this.cardId)
868
+ return;
869
+ const hash = quickHash(content);
870
+ const hashField = elementId === ELEMENT_IDS.AUX_BEFORE
871
+ ? 'lastAuxBeforeHash'
872
+ : 'lastAuxAfterHash';
873
+ if (hash === this[hashField])
874
+ return;
875
+ const element = JSON.stringify({
876
+ tag: 'markdown',
877
+ content,
878
+ element_id: elementId,
879
+ text_size: 'notation',
880
+ });
881
+ await this.client.cardkit.v1.cardElement.update({
882
+ path: { card_id: this.cardId, element_id: elementId },
883
+ data: { element, sequence: this.nextSequence() },
884
+ });
885
+ this[hashField] = hash;
886
+ }
887
+ /**
888
+ * Enable streaming mode via card.settings().
889
+ */
890
+ async enableStreamingMode() {
891
+ if (!this.cardId)
892
+ return;
893
+ await this.client.cardkit.v1.card.settings({
894
+ path: { card_id: this.cardId },
895
+ data: {
896
+ settings: JSON.stringify({
897
+ config: {
898
+ streaming_mode: true,
899
+ streaming_config: STREAMING_CONFIG,
900
+ },
901
+ }),
902
+ sequence: this.nextSequence(),
903
+ },
904
+ });
905
+ }
906
+ /**
907
+ * Disable streaming mode via card.settings().
908
+ */
909
+ async disableStreamingMode() {
910
+ if (!this.cardId)
911
+ return;
912
+ await this.client.cardkit.v1.card.settings({
913
+ path: { card_id: this.cardId },
914
+ data: {
915
+ settings: JSON.stringify({
916
+ config: { streaming_mode: false },
917
+ }),
918
+ sequence: this.nextSequence(),
919
+ },
920
+ });
921
+ }
922
+ /**
923
+ * Full card update (used for final state after disabling streaming).
924
+ */
925
+ async updateCardFull(cardJson) {
926
+ if (!this.cardId)
927
+ return;
928
+ await this.client.cardkit.v1.card.update({
929
+ path: { card_id: this.cardId },
930
+ data: {
931
+ card: { type: 'card_json', data: JSON.stringify(cardJson) },
932
+ sequence: this.nextSequence(),
933
+ },
934
+ });
935
+ }
936
+ }
937
+ // ─── Multi-Card Manager ───────────────────────────────────────
938
+ class MultiCardManager {
939
+ cards = [];
940
+ client;
941
+ chatId;
942
+ replyToMsgId;
943
+ onCardCreated;
944
+ cardIndex = 0;
945
+ MAX_ELEMENTS = 45; // safety margin (Feishu limit ~50)
946
+ constructor(client, chatId, replyToMsgId, onCardCreated) {
947
+ this.client = client;
948
+ this.chatId = chatId;
949
+ this.replyToMsgId = replyToMsgId;
950
+ this.onCardCreated = onCardCreated;
951
+ }
952
+ getCardCount() {
953
+ return this.cards.length;
954
+ }
955
+ /**
956
+ * Create the first card and send it as a message.
957
+ * Returns the initial messageId.
958
+ */
959
+ async initialize(initialText, runtimeIdentity) {
960
+ const card = new CardKitBackend(this.client);
961
+ const cardJson = buildSchema2Card(initialText, 'streaming', '', undefined, undefined, undefined, runtimeIdentity);
962
+ await card.createCard(cardJson);
963
+ const messageId = await card.sendCard(this.chatId, this.replyToMsgId);
964
+ this.cards.push(card);
965
+ this.cardIndex = 0;
966
+ return messageId;
967
+ }
968
+ /**
969
+ * Adopt an existing card (for degradation from streaming mode, avoids creating a new message).
970
+ */
971
+ adoptExistingCard(card) {
972
+ this.cards.push(card);
973
+ this.cardIndex = 0;
974
+ }
975
+ /**
976
+ * Commit content: update the current card, auto-splitting if needed.
977
+ */
978
+ async commitContent(text, state, auxiliaryState, footerNote, runtimeIdentity) {
979
+ const titlePrefix = this.cardIndex > 0 ? '(续) ' : '';
980
+ // Estimate element count: content + auxiliary + fixed elements
981
+ const { contentElements } = buildCardContent(text, splitCodeBlockSafe);
982
+ const auxCount = auxiliaryState
983
+ ? (() => {
984
+ const { before, after } = buildAuxiliaryElementsForState(auxiliaryState, state);
985
+ return before.length + after.length;
986
+ })()
987
+ : 0;
988
+ const runtimeControlCount = buildRuntimeControlElements(runtimeIdentity).length;
989
+ const fixedCount = (state === 'streaming' ? 1 : 0) + // button
990
+ runtimeControlCount + // runtime controls
991
+ (SCHEMA2_NOTE_MAP[state] ? 1 : 0) + // note
992
+ (footerNote ? 1 : 0); // footer
993
+ const totalElements = contentElements.length + auxCount + fixedCount;
994
+ if (totalElements > this.MAX_ELEMENTS && state === 'streaming') {
995
+ // Need to split: freeze current card and create a new one
996
+ await this.splitToNewCard(text, runtimeIdentity);
997
+ return;
998
+ }
999
+ // Normal update on current card
1000
+ const currentCard = this.cards[this.cards.length - 1];
1001
+ if (!currentCard)
1002
+ return;
1003
+ const cardJson = buildSchema2Card(text, state, titlePrefix, undefined, auxiliaryState, footerNote, runtimeIdentity);
1004
+ // Byte size check (Feishu limit ~30KB, use 25KB safety margin)
1005
+ const cardSize = Buffer.byteLength(JSON.stringify(cardJson), 'utf-8');
1006
+ if (cardSize > CARD_SIZE_LIMIT && state === 'streaming') {
1007
+ await this.splitToNewCard(text, runtimeIdentity);
1008
+ return;
1009
+ }
1010
+ await currentCard.updateCard(cardJson);
1011
+ }
1012
+ /**
1013
+ * Split content across cards when element limit is reached.
1014
+ */
1015
+ async splitToNewCard(text, runtimeIdentity) {
1016
+ const currentCard = this.cards[this.cards.length - 1];
1017
+ if (!currentCard)
1018
+ return;
1019
+ // Extract title once so all sub-cards share the same title
1020
+ const { title: consistentTitle } = extractTitleAndBody(text);
1021
+ // Determine how much content the current card can hold
1022
+ const maxChunksPerCard = this.MAX_ELEMENTS - 3; // reserve for fixed elements
1023
+ const chunks = splitCodeBlockSafe(text, CARD_MD_LIMIT);
1024
+ // Content for the current (frozen) card
1025
+ const frozenChunks = chunks.slice(0, maxChunksPerCard);
1026
+ const frozenText = frozenChunks.join('\n\n');
1027
+ const titlePrefix = this.cardIndex > 0 ? '(续) ' : '';
1028
+ // Freeze current card with consistent title
1029
+ const frozenCard = buildSchema2Card(frozenText, 'frozen', titlePrefix, consistentTitle, undefined, undefined, runtimeIdentity);
1030
+ await currentCard.updateCard(frozenCard);
1031
+ // Create new card for remaining content
1032
+ this.cardIndex++;
1033
+ const newTitlePrefix = '(续) ';
1034
+ const remainingChunks = chunks.slice(maxChunksPerCard);
1035
+ const remainingText = remainingChunks.join('\n\n');
1036
+ const newCard = new CardKitBackend(this.client);
1037
+ const newCardJson = buildSchema2Card(remainingText || '...', 'streaming', newTitlePrefix, consistentTitle, undefined, undefined, runtimeIdentity);
1038
+ await newCard.createCard(newCardJson);
1039
+ // New card is sent as a fresh message (not reply)
1040
+ const newMessageId = await newCard.sendCard(this.chatId);
1041
+ this.cards.push(newCard);
1042
+ // Register the new card's messageId for interrupt button routing
1043
+ this.onCardCreated?.(newMessageId);
1044
+ }
1045
+ getAllMessageIds() {
1046
+ return this.cards
1047
+ .map((c) => c.messageId)
1048
+ .filter((id) => id !== null);
1049
+ }
1050
+ getLatestMessageId() {
1051
+ for (let i = this.cards.length - 1; i >= 0; i--) {
1052
+ if (this.cards[i].messageId)
1053
+ return this.cards[i].messageId;
1054
+ }
1055
+ return null;
1056
+ }
1057
+ }
1058
+ // ─── Streaming Card Controller ────────────────────────────────
1059
+ export class StreamingCardController {
1060
+ state = 'idle';
1061
+ messageId = null;
1062
+ accumulatedText = '';
1063
+ flushCtrl;
1064
+ patchFailCount = 0;
1065
+ maxPatchFailures = 2;
1066
+ client;
1067
+ chatId;
1068
+ replyToMsgId;
1069
+ onFallback;
1070
+ onCardCreated;
1071
+ // CardKit mode
1072
+ useCardKit = false;
1073
+ multiCard = null;
1074
+ // Streaming mode (Level 0)
1075
+ streamingBackend = null;
1076
+ textFlushCtrl = null;
1077
+ auxFlushCtrl = null;
1078
+ lastAuxSnapshot = '';
1079
+ // Streaming state
1080
+ thinking = false;
1081
+ thinkingText = '';
1082
+ toolCalls = new Map();
1083
+ startTime = 0;
1084
+ backendMode = 'v1';
1085
+ // Auxiliary display state
1086
+ systemStatus = null;
1087
+ activeHook = null;
1088
+ todos = null;
1089
+ recentEvents = [];
1090
+ stateVersion = 0;
1091
+ footerRuntimeIdentity = null;
1092
+ footerTokenUsage = null;
1093
+ constructor(opts) {
1094
+ this.client = opts.client;
1095
+ this.chatId = opts.chatId;
1096
+ this.replyToMsgId = opts.replyToMsgId;
1097
+ this.onFallback = opts.onFallback;
1098
+ this.onCardCreated = opts.onCardCreated;
1099
+ this.flushCtrl = new FlushController();
1100
+ }
1101
+ get currentState() {
1102
+ return this.state;
1103
+ }
1104
+ get currentMessageId() {
1105
+ if (this.streamingBackend)
1106
+ return this.streamingBackend.messageId;
1107
+ if (this.multiCard)
1108
+ return this.multiCard.getLatestMessageId();
1109
+ return this.messageId;
1110
+ }
1111
+ isActive() {
1112
+ return this.state === 'streaming' || this.state === 'creating';
1113
+ }
1114
+ /**
1115
+ * Get all messageIds across all cards (for multi-card cleanup).
1116
+ */
1117
+ getAllMessageIds() {
1118
+ if (this.streamingBackend?.messageId)
1119
+ return [this.streamingBackend.messageId];
1120
+ if (this.multiCard)
1121
+ return this.multiCard.getAllMessageIds();
1122
+ return this.messageId ? [this.messageId] : [];
1123
+ }
1124
+ /**
1125
+ * Signal that the agent is in thinking state (before text arrives).
1126
+ */
1127
+ setThinking() {
1128
+ this.thinking = true;
1129
+ if (this.state === 'idle') {
1130
+ // Create card immediately with thinking placeholder
1131
+ this.state = 'creating';
1132
+ this.createInitialCard().catch((err) => {
1133
+ logger.warn({ err, chatId: this.chatId }, 'Streaming card: initial create failed (thinking), will use fallback');
1134
+ this.state = 'error';
1135
+ this.onFallback?.();
1136
+ });
1137
+ }
1138
+ }
1139
+ /**
1140
+ * Signal that a tool has started executing.
1141
+ */
1142
+ startTool(toolId, toolName) {
1143
+ this.toolCalls.set(toolId, {
1144
+ name: toolName,
1145
+ status: 'running',
1146
+ startTime: Date.now(),
1147
+ });
1148
+ this.stateVersion++;
1149
+ if (this.state === 'streaming') {
1150
+ this.backendMode === 'streaming'
1151
+ ? this.scheduleAuxFlush()
1152
+ : this.schedulePatch();
1153
+ }
1154
+ }
1155
+ /**
1156
+ * Signal that a tool has finished executing.
1157
+ */
1158
+ endTool(toolId, isError) {
1159
+ const tc = this.toolCalls.get(toolId);
1160
+ if (tc) {
1161
+ tc.status = isError ? 'error' : 'complete';
1162
+ this.stateVersion++;
1163
+ this.purgeOldTools();
1164
+ if (this.state === 'streaming') {
1165
+ this.backendMode === 'streaming'
1166
+ ? this.scheduleAuxFlush()
1167
+ : this.schedulePatch();
1168
+ }
1169
+ }
1170
+ }
1171
+ /**
1172
+ * Purge completed/error tools older than MAX_COMPLETED_TOOL_AGE to prevent unbounded growth.
1173
+ */
1174
+ purgeOldTools() {
1175
+ const cutoff = Date.now() - MAX_COMPLETED_TOOL_AGE;
1176
+ for (const [id, tc] of this.toolCalls) {
1177
+ if (tc.status !== 'running' && tc.startTime < cutoff) {
1178
+ this.toolCalls.delete(id);
1179
+ }
1180
+ }
1181
+ }
1182
+ /**
1183
+ * Append thinking text (accumulated, tail-truncated at MAX_THINKING_CHARS).
1184
+ */
1185
+ appendThinking(text) {
1186
+ this.thinkingText += text;
1187
+ if (this.thinkingText.length > MAX_THINKING_CHARS) {
1188
+ this.thinkingText =
1189
+ '...' + this.thinkingText.slice(-(MAX_THINKING_CHARS - 3));
1190
+ }
1191
+ this.thinking = true;
1192
+ this.stateVersion++;
1193
+ if (this.state === 'idle') {
1194
+ this.state = 'creating';
1195
+ this.createInitialCard().catch((err) => {
1196
+ logger.warn({ err, chatId: this.chatId }, 'Streaming card: initial create failed (thinking), will use fallback');
1197
+ this.state = 'error';
1198
+ this.onFallback?.();
1199
+ });
1200
+ }
1201
+ else if (this.state === 'streaming') {
1202
+ this.backendMode === 'streaming'
1203
+ ? this.scheduleAuxFlush()
1204
+ : this.schedulePatch();
1205
+ }
1206
+ }
1207
+ /**
1208
+ * Set or clear system status text (e.g. "上下文压缩中").
1209
+ */
1210
+ setSystemStatus(status) {
1211
+ this.systemStatus = status;
1212
+ this.stateVersion++;
1213
+ if (this.state === 'streaming') {
1214
+ this.backendMode === 'streaming'
1215
+ ? this.scheduleAuxFlush()
1216
+ : this.schedulePatch();
1217
+ }
1218
+ }
1219
+ /**
1220
+ * Set or clear active hook state.
1221
+ */
1222
+ setHook(hook) {
1223
+ this.activeHook = hook;
1224
+ this.stateVersion++;
1225
+ if (this.state === 'streaming') {
1226
+ this.backendMode === 'streaming'
1227
+ ? this.scheduleAuxFlush()
1228
+ : this.schedulePatch();
1229
+ }
1230
+ }
1231
+ /**
1232
+ * Set the todo list for progress panel display.
1233
+ */
1234
+ setTodos(todos) {
1235
+ this.todos = todos;
1236
+ this.stateVersion++;
1237
+ if (this.state === 'streaming') {
1238
+ this.backendMode === 'streaming'
1239
+ ? this.scheduleAuxFlush()
1240
+ : this.schedulePatch();
1241
+ }
1242
+ }
1243
+ /**
1244
+ * Push a recent event to the call trace log (FIFO, max MAX_RECENT_EVENTS).
1245
+ * Does NOT trigger schedulePatch — piggybacks on other events.
1246
+ */
1247
+ pushRecentEvent(text) {
1248
+ this.recentEvents.push({ text });
1249
+ if (this.recentEvents.length > MAX_RECENT_EVENTS) {
1250
+ this.recentEvents = this.recentEvents.slice(-MAX_RECENT_EVENTS);
1251
+ }
1252
+ }
1253
+ setRuntimeIdentity(identity) {
1254
+ const nextIdentity = identity ?? null;
1255
+ const unchanged = this.footerRuntimeIdentity?.agentType === nextIdentity?.agentType &&
1256
+ this.footerRuntimeIdentity?.model === nextIdentity?.model &&
1257
+ this.footerRuntimeIdentity?.reasoningEffort ===
1258
+ nextIdentity?.reasoningEffort &&
1259
+ this.footerRuntimeIdentity?.supportsReasoningEffort ===
1260
+ nextIdentity?.supportsReasoningEffort;
1261
+ if (unchanged)
1262
+ return;
1263
+ this.footerRuntimeIdentity = nextIdentity;
1264
+ this.stateVersion++;
1265
+ if (this.state === 'streaming') {
1266
+ this.backendMode === 'streaming'
1267
+ ? this.scheduleAuxFlush()
1268
+ : this.schedulePatch();
1269
+ }
1270
+ }
1271
+ /**
1272
+ * Update a tool's input summary (displayed as parameter hint).
1273
+ */
1274
+ updateToolSummary(toolId, summary) {
1275
+ const tc = this.toolCalls.get(toolId);
1276
+ if (tc) {
1277
+ tc.toolInputSummary = summary;
1278
+ this.stateVersion++;
1279
+ if (this.state === 'streaming') {
1280
+ this.backendMode === 'streaming'
1281
+ ? this.scheduleAuxFlush()
1282
+ : this.schedulePatch();
1283
+ }
1284
+ }
1285
+ }
1286
+ /**
1287
+ * Get tool info by ID (for building call trace text).
1288
+ */
1289
+ getToolInfo(toolId) {
1290
+ const tc = this.toolCalls.get(toolId);
1291
+ return tc ? { name: tc.name } : undefined;
1292
+ }
1293
+ /**
1294
+ * Append text to the streaming card.
1295
+ * Creates the card on first call, then patches on subsequent calls.
1296
+ */
1297
+ append(text) {
1298
+ this.accumulatedText = text;
1299
+ this.thinking = false; // Text arrived, no longer just thinking
1300
+ if (this.state === 'idle') {
1301
+ this.state = 'creating';
1302
+ this.createInitialCard().catch((err) => {
1303
+ logger.warn({ err, chatId: this.chatId }, 'Streaming card: initial create failed, will use fallback');
1304
+ this.state = 'error';
1305
+ this.onFallback?.();
1306
+ });
1307
+ return;
1308
+ }
1309
+ if (this.state === 'streaming') {
1310
+ this.backendMode === 'streaming'
1311
+ ? this.scheduleTextFlush()
1312
+ : this.schedulePatch();
1313
+ }
1314
+ // If 'creating', the text will be picked up after creation completes
1315
+ }
1316
+ /**
1317
+ * Complete the streaming card with final text.
1318
+ */
1319
+ async complete(finalText) {
1320
+ await this.finalize(finalText, 'completed');
1321
+ }
1322
+ /**
1323
+ * Finalize the streaming card in an error / aborted state while preserving
1324
+ * the final text as the visible body content.
1325
+ */
1326
+ async fail(finalText) {
1327
+ await this.finalize(finalText, 'aborted');
1328
+ }
1329
+ async finalize(finalText, finalState) {
1330
+ if (this.state !== 'streaming' && this.state !== 'creating')
1331
+ return;
1332
+ const prevState = this.state;
1333
+ this.accumulatedText = finalText;
1334
+ this.state = finalState;
1335
+ this.flushCtrl.dispose();
1336
+ this.textFlushCtrl?.dispose();
1337
+ this.auxFlushCtrl?.dispose();
1338
+ try {
1339
+ if (this.backendMode === 'streaming' && this.streamingBackend) {
1340
+ await this.finalizeStreamingCard(finalState);
1341
+ }
1342
+ else if (this.messageId || this.multiCard) {
1343
+ await this.patchCard(finalState);
1344
+ }
1345
+ }
1346
+ catch (err) {
1347
+ // Revert state so abort() doesn't bail on the terminal-state check
1348
+ this.state = prevState;
1349
+ throw err;
1350
+ }
1351
+ }
1352
+ /**
1353
+ * Patch a finalized card to append a usage note at the bottom.
1354
+ * Called AFTER complete()/fail() because agent-runner may emit usage after
1355
+ * the visible final text has already been rendered.
1356
+ */
1357
+ async patchUsageNote(usage) {
1358
+ const nextUsage = {
1359
+ inputTokens: usage.inputTokens,
1360
+ outputTokens: usage.outputTokens,
1361
+ costUSD: usage.costUSD,
1362
+ durationMs: usage.durationMs,
1363
+ numTurns: usage.numTurns,
1364
+ };
1365
+ const unchanged = this.footerTokenUsage?.inputTokens === nextUsage.inputTokens &&
1366
+ this.footerTokenUsage?.outputTokens === nextUsage.outputTokens &&
1367
+ this.footerTokenUsage?.costUSD === nextUsage.costUSD &&
1368
+ this.footerTokenUsage?.durationMs === nextUsage.durationMs &&
1369
+ this.footerTokenUsage?.numTurns === nextUsage.numTurns;
1370
+ if (unchanged)
1371
+ return;
1372
+ this.footerTokenUsage = nextUsage;
1373
+ // Some runtimes emit usage before the final completed/aborted card patch.
1374
+ // Cache the usage immediately so complete()/finalize() can still render
1375
+ // the footer on the finished card.
1376
+ if (this.state !== 'completed' && this.state !== 'aborted')
1377
+ return;
1378
+ const finalState = this.state;
1379
+ try {
1380
+ if (this.backendMode === 'streaming' && this.streamingBackend) {
1381
+ const cardJson = buildSchema2Card(this.accumulatedText, finalState, '', undefined, this.getAuxiliaryState(), this.getFooterNote(), this.footerRuntimeIdentity);
1382
+ // Skip if card was split during finalization — rebuilding a single card
1383
+ // would overwrite the first card with full text while continuation cards remain.
1384
+ const cardSize = Buffer.byteLength(JSON.stringify(cardJson), 'utf-8');
1385
+ if (cardSize > CARD_SIZE_LIMIT)
1386
+ return;
1387
+ await this.streamingBackend.updateCardFull(cardJson);
1388
+ }
1389
+ else if (this.messageId || this.multiCard) {
1390
+ // For CardKit v1 / legacy: skip if multiCard has split content
1391
+ if (this.multiCard && this.multiCard.getCardCount() > 1)
1392
+ return;
1393
+ await this.patchCard(finalState);
1394
+ }
1395
+ }
1396
+ catch (err) {
1397
+ logger.debug({ err, chatId: this.chatId }, 'Streaming card: patchUsageNote failed (non-fatal)');
1398
+ }
1399
+ }
1400
+ /**
1401
+ * Abort the streaming card (e.g., user interrupted).
1402
+ */
1403
+ async abort(reason) {
1404
+ if (this.state === 'completed' || this.state === 'aborted')
1405
+ return;
1406
+ const wasActive = this.isActive();
1407
+ this.state = 'aborted';
1408
+ this.flushCtrl.dispose();
1409
+ this.textFlushCtrl?.dispose();
1410
+ this.auxFlushCtrl?.dispose();
1411
+ if (reason) {
1412
+ this.accumulatedText += `\n\n---\n*${reason}*`;
1413
+ }
1414
+ if (this.backendMode === 'streaming' &&
1415
+ this.streamingBackend &&
1416
+ wasActive) {
1417
+ try {
1418
+ await this.finalizeStreamingCard('aborted');
1419
+ }
1420
+ catch (err) {
1421
+ logger.debug({ err, chatId: this.chatId }, 'Streaming card: abort finalize failed');
1422
+ }
1423
+ }
1424
+ else if ((this.messageId || this.multiCard) && wasActive) {
1425
+ try {
1426
+ await this.patchCard('aborted');
1427
+ }
1428
+ catch (err) {
1429
+ logger.debug({ err, chatId: this.chatId }, 'Streaming card: abort patch failed');
1430
+ }
1431
+ }
1432
+ }
1433
+ dispose() {
1434
+ this.flushCtrl.dispose();
1435
+ this.textFlushCtrl?.dispose();
1436
+ this.auxFlushCtrl?.dispose();
1437
+ }
1438
+ // ─── Internal Methods ──────────────────────────────────
1439
+ async createInitialCard() {
1440
+ const initialText = this.accumulatedText || (this.thinking ? '' : '...');
1441
+ // ── Level 0: Try streaming mode (cardElement.content typewriter) ──
1442
+ try {
1443
+ const backend = new StreamingModeBackend(this.client);
1444
+ const cardJson = buildStreamingModeCard(initialText, this.footerRuntimeIdentity);
1445
+ await backend.createCard(cardJson);
1446
+ const messageId = await backend.sendCard(this.chatId, this.replyToMsgId);
1447
+ this.streamingBackend = backend;
1448
+ this.messageId = messageId;
1449
+ this.backendMode = 'streaming';
1450
+ this.useCardKit = true;
1451
+ this.startTime = Date.now();
1452
+ // Streaming mode: 300ms text flush, 800ms aux flush
1453
+ this.textFlushCtrl = new FlushController(300, 30);
1454
+ this.auxFlushCtrl = new FlushController(800, 0);
1455
+ this.maxPatchFailures = 3;
1456
+ logger.debug({ chatId: this.chatId, messageId, mode: 'streaming' }, 'Streaming card created via streaming mode');
1457
+ this.finishCardCreation();
1458
+ return;
1459
+ }
1460
+ catch (streamingErr) {
1461
+ logger.info({ err: streamingErr, chatId: this.chatId }, 'Streaming mode unavailable, falling back to CardKit v1');
1462
+ this.streamingBackend = null;
1463
+ }
1464
+ // ── Level 1: Try CardKit v1 full-update (card.update with full JSON) ──
1465
+ try {
1466
+ this.multiCard = new MultiCardManager(this.client, this.chatId, this.replyToMsgId, this.onCardCreated);
1467
+ const messageId = await this.multiCard.initialize(initialText, this.footerRuntimeIdentity);
1468
+ this.messageId = messageId;
1469
+ this.backendMode = 'v1';
1470
+ this.useCardKit = true;
1471
+ this.startTime = Date.now();
1472
+ // CardKit v1 mode: 1000ms interval, bump failure tolerance
1473
+ this.flushCtrl.dispose();
1474
+ this.flushCtrl = new FlushController(1000, 50);
1475
+ this.maxPatchFailures = 3;
1476
+ logger.debug({ chatId: this.chatId, messageId, mode: 'cardkit-v1' }, 'Streaming card created via CardKit v1');
1477
+ }
1478
+ catch (v1Err) {
1479
+ // ── Level 2: Legacy message.create + message.patch ──
1480
+ logger.info({ err: v1Err, chatId: this.chatId }, 'CardKit full-update unavailable, falling back to message.patch');
1481
+ this.multiCard = null;
1482
+ this.useCardKit = false;
1483
+ this.backendMode = 'legacy';
1484
+ this.startTime = Date.now();
1485
+ await this.createLegacyCard(initialText);
1486
+ return;
1487
+ }
1488
+ // Handle state changes during await (same logic for both paths)
1489
+ this.finishCardCreation();
1490
+ }
1491
+ async createLegacyCard(initialText) {
1492
+ const card = buildStreamingCard(initialText, 'streaming', undefined, this.footerRuntimeIdentity);
1493
+ const content = JSON.stringify(card);
1494
+ try {
1495
+ let resp;
1496
+ if (this.replyToMsgId) {
1497
+ resp = await this.client.im.message.reply({
1498
+ path: { message_id: this.replyToMsgId },
1499
+ data: { content, msg_type: 'interactive' },
1500
+ });
1501
+ }
1502
+ else {
1503
+ resp = await this.client.im.v1.message.create({
1504
+ params: { receive_id_type: 'chat_id' },
1505
+ data: {
1506
+ receive_id: this.chatId,
1507
+ msg_type: 'interactive',
1508
+ content,
1509
+ },
1510
+ });
1511
+ }
1512
+ this.messageId = resp?.data?.message_id || null;
1513
+ if (!this.messageId) {
1514
+ throw new Error('No message_id in response');
1515
+ }
1516
+ logger.debug({ chatId: this.chatId, messageId: this.messageId, mode: 'legacy' }, 'Streaming card created via legacy path');
1517
+ this.finishCardCreation();
1518
+ }
1519
+ catch (err) {
1520
+ this.state = 'error';
1521
+ throw err;
1522
+ }
1523
+ }
1524
+ finishCardCreation() {
1525
+ // Check if state changed while we were awaiting the API call.
1526
+ if (this.state !== 'creating') {
1527
+ const finalState = this.state;
1528
+ logger.debug({ chatId: this.chatId, messageId: this.messageId, finalState }, 'Streaming card created but state already changed, patching to final');
1529
+ if (this.backendMode === 'streaming' && this.streamingBackend) {
1530
+ this.finalizeStreamingCard(finalState).catch((err) => {
1531
+ logger.debug({ err, chatId: this.chatId }, 'Failed to finalize streaming card after late creation');
1532
+ });
1533
+ }
1534
+ else {
1535
+ this.patchCard(finalState).catch((err) => {
1536
+ logger.debug({ err, chatId: this.chatId }, 'Failed to patch to final state after late creation');
1537
+ });
1538
+ }
1539
+ return;
1540
+ }
1541
+ this.state = 'streaming';
1542
+ if (this.messageId) {
1543
+ this.onCardCreated?.(this.messageId);
1544
+ }
1545
+ // If text accumulated while creating, schedule a flush/patch
1546
+ if (this.accumulatedText.length > 3) {
1547
+ this.backendMode === 'streaming'
1548
+ ? this.scheduleTextFlush()
1549
+ : this.schedulePatch();
1550
+ }
1551
+ }
1552
+ schedulePatch() {
1553
+ if (this.patchFailCount >= this.maxPatchFailures) {
1554
+ logger.info({ chatId: this.chatId, useCardKit: this.useCardKit }, 'Streaming card: too many patch failures, falling back');
1555
+ this.state = 'error';
1556
+ this.flushCtrl.dispose();
1557
+ this.onFallback?.();
1558
+ return;
1559
+ }
1560
+ // Use effectiveLength so FlushController detects non-text state changes
1561
+ // (thinking, tool status, system status, etc.)
1562
+ const effectiveLength = this.accumulatedText.length + this.stateVersion * 1000;
1563
+ this.flushCtrl.schedule(effectiveLength, async () => {
1564
+ await this.patchCard('streaming');
1565
+ });
1566
+ }
1567
+ getFooterNote() {
1568
+ return (formatAssistantMetaFooter({
1569
+ runtimeIdentity: this.footerRuntimeIdentity,
1570
+ tokenUsage: this.footerTokenUsage,
1571
+ }) || undefined);
1572
+ }
1573
+ getAuxiliaryState() {
1574
+ return {
1575
+ thinkingText: this.thinkingText,
1576
+ isThinking: this.thinking,
1577
+ toolCalls: this.toolCalls,
1578
+ systemStatus: this.systemStatus,
1579
+ activeHook: this.activeHook,
1580
+ todos: this.todos,
1581
+ recentEvents: this.recentEvents,
1582
+ };
1583
+ }
1584
+ // ─── Streaming Mode Methods ──────────────────────────────
1585
+ /**
1586
+ * Schedule a text content flush for streaming mode.
1587
+ * Falls back to schedulePatch() if streaming backend is not available.
1588
+ */
1589
+ scheduleTextFlush() {
1590
+ if (!this.streamingBackend || !this.textFlushCtrl) {
1591
+ this.schedulePatch();
1592
+ return;
1593
+ }
1594
+ this.textFlushCtrl.schedule(this.accumulatedText.length, async () => {
1595
+ try {
1596
+ await this.streamingBackend.streamContent(this.accumulatedText);
1597
+ this.textFlushCtrl.markFlushed(this.accumulatedText.length);
1598
+ this.patchFailCount = 0;
1599
+ }
1600
+ catch (err) {
1601
+ this.patchFailCount++;
1602
+ logger.debug({
1603
+ err,
1604
+ chatId: this.chatId,
1605
+ failCount: this.patchFailCount,
1606
+ mode: 'streaming',
1607
+ }, 'Streaming content push failed');
1608
+ if (this.patchFailCount >= this.maxPatchFailures) {
1609
+ this.degradeToV1();
1610
+ }
1611
+ }
1612
+ });
1613
+ }
1614
+ /**
1615
+ * Schedule an auxiliary content flush for streaming mode.
1616
+ * Falls back to schedulePatch() if streaming backend is not available.
1617
+ */
1618
+ scheduleAuxFlush() {
1619
+ if (!this.streamingBackend || !this.auxFlushCtrl) {
1620
+ this.schedulePatch();
1621
+ return;
1622
+ }
1623
+ this.auxFlushCtrl.schedule(this.stateVersion * 1000, async () => {
1624
+ // Recalculate aux state inside callback to avoid stale closures
1625
+ const auxState = this.getAuxiliaryState();
1626
+ const { before, after } = buildAuxiliaryElements(auxState);
1627
+ const auxBefore = serializeAuxContent(before);
1628
+ const auxAfter = serializeAuxContent(after);
1629
+ const snapshot = auxBefore + '||' + auxAfter;
1630
+ if (snapshot === this.lastAuxSnapshot)
1631
+ return;
1632
+ try {
1633
+ await this.streamingBackend.updateAuxiliary(ELEMENT_IDS.AUX_BEFORE, auxBefore);
1634
+ await this.streamingBackend.updateAuxiliary(ELEMENT_IDS.AUX_AFTER, auxAfter);
1635
+ this.lastAuxSnapshot = snapshot;
1636
+ }
1637
+ catch (err) {
1638
+ // Auxiliary update failures do NOT count toward degradation
1639
+ logger.debug({ err, chatId: this.chatId, mode: 'streaming' }, 'Streaming auxiliary update failed (non-critical)');
1640
+ }
1641
+ });
1642
+ }
1643
+ /**
1644
+ * Degrade from streaming mode to v1 full-update mode.
1645
+ */
1646
+ degradeToV1() {
1647
+ logger.warn({ chatId: this.chatId }, 'Streaming mode: degrading to v1 full-update');
1648
+ // Save card_id and sequence from streaming backend before clearing
1649
+ const existingCardId = this.streamingBackend.getCardId();
1650
+ const existingSeq = this.streamingBackend.getSequence();
1651
+ // Try to disable streaming mode gracefully (fire and forget)
1652
+ this.streamingBackend?.disableStreamingMode().catch(() => { });
1653
+ this.backendMode = 'v1';
1654
+ this.streamingBackend = null;
1655
+ this.textFlushCtrl?.dispose();
1656
+ this.textFlushCtrl = null;
1657
+ this.auxFlushCtrl?.dispose();
1658
+ this.auxFlushCtrl = null;
1659
+ this.patchFailCount = 0;
1660
+ // Set up v1 flush controller
1661
+ this.flushCtrl.dispose();
1662
+ this.flushCtrl = new FlushController(1000, 50);
1663
+ // Adopt the existing streaming card into a CardKitBackend (reuses card_id, no new message)
1664
+ const adoptedCard = new CardKitBackend(this.client);
1665
+ adoptedCard.adoptCard(existingCardId, this.messageId, existingSeq);
1666
+ this.multiCard = new MultiCardManager(this.client, this.chatId, this.replyToMsgId, this.onCardCreated);
1667
+ this.multiCard.adoptExistingCard(adoptedCard);
1668
+ // Schedule an immediate patch to sync the current state
1669
+ this.schedulePatch();
1670
+ }
1671
+ /**
1672
+ * Finalize a streaming card: disable streaming mode, then set final state.
1673
+ */
1674
+ async finalizeStreamingCard(finalState) {
1675
+ const backend = this.streamingBackend;
1676
+ try {
1677
+ // 1. Disable streaming mode (allows header/button changes)
1678
+ await backend.disableStreamingMode();
1679
+ // 2. Build final card with optimizeMarkdownStyle
1680
+ const footerNote = this.getFooterNote();
1681
+ const auxiliaryState = this.getAuxiliaryState();
1682
+ const cardJson = buildSchema2Card(this.accumulatedText, finalState, '', undefined, auxiliaryState, footerNote);
1683
+ const cardSize = Buffer.byteLength(JSON.stringify(cardJson), 'utf-8');
1684
+ if (cardSize <= CARD_SIZE_LIMIT) {
1685
+ // 3a. Single card fits
1686
+ await backend.updateCardFull(cardJson);
1687
+ }
1688
+ else {
1689
+ // 3b. Too large for single card — split on finalize
1690
+ await this.splitOnFinalize(finalState);
1691
+ }
1692
+ }
1693
+ catch (err) {
1694
+ logger.debug({ err, chatId: this.chatId }, 'Streaming finalize failed, trying truncated fallback');
1695
+ // Fallback: truncate and try once more
1696
+ try {
1697
+ const truncated = this.accumulatedText.slice(0, 20000);
1698
+ const fallbackCard = buildSchema2Card(truncated + '\n\n> ⚠️ 输出已截断', finalState, '', undefined, this.getAuxiliaryState(), this.getFooterNote(), this.footerRuntimeIdentity);
1699
+ await backend.updateCardFull(fallbackCard);
1700
+ }
1701
+ catch (fallbackErr) {
1702
+ logger.debug({ err: fallbackErr, chatId: this.chatId }, 'Streaming finalize truncated fallback also failed');
1703
+ }
1704
+ }
1705
+ }
1706
+ /**
1707
+ * Split content into multiple cards on finalize (only when streaming card content exceeds CARD_SIZE_LIMIT).
1708
+ * The first card (existing streaming card) gets frozen, subsequent cards are new.
1709
+ */
1710
+ async splitOnFinalize(finalState) {
1711
+ const backend = this.streamingBackend;
1712
+ const { title } = extractTitleAndBody(this.accumulatedText);
1713
+ const chunks = splitCodeBlockSafe(this.accumulatedText, CARD_MD_LIMIT);
1714
+ const footerNote = this.getFooterNote();
1715
+ const auxiliaryState = this.getAuxiliaryState();
1716
+ // How many chunks fit in the first card?
1717
+ const MAX_ELEMENTS_PER_CARD = 45;
1718
+ const fixedElements = 2; // note + margin
1719
+ const maxChunksFirst = MAX_ELEMENTS_PER_CARD - fixedElements;
1720
+ const firstChunks = chunks.slice(0, maxChunksFirst);
1721
+ const firstText = firstChunks.join('\n\n');
1722
+ // Use finalState if all content fits in the first card, otherwise freeze
1723
+ const firstCardState = chunks.length <= maxChunksFirst ? finalState : 'frozen';
1724
+ const frozenCard = buildSchema2Card(firstText, firstCardState, '', title, auxiliaryState, chunks.length <= maxChunksFirst ? footerNote : undefined, this.footerRuntimeIdentity);
1725
+ await backend.updateCardFull(frozenCard);
1726
+ // Create continuation cards
1727
+ let remaining = chunks.slice(maxChunksFirst);
1728
+ let includeAuxiliaryInContinuation = false;
1729
+ while (remaining.length > 0) {
1730
+ const batch = remaining.slice(0, maxChunksFirst);
1731
+ remaining = remaining.slice(maxChunksFirst);
1732
+ const batchText = batch.join('\n\n');
1733
+ const state = remaining.length === 0 ? finalState : 'frozen';
1734
+ const contCard = new CardKitBackend(this.client);
1735
+ const contCardJson = buildSchema2Card(batchText, state, '(续) ', title, includeAuxiliaryInContinuation ? auxiliaryState : undefined, remaining.length === 0 ? footerNote : undefined, this.footerRuntimeIdentity);
1736
+ await contCard.createCard(contCardJson);
1737
+ const newMsgId = await contCard.sendCard(this.chatId);
1738
+ this.onCardCreated?.(newMsgId);
1739
+ includeAuxiliaryInContinuation = false;
1740
+ }
1741
+ }
1742
+ async patchCard(displayState, footerNote) {
1743
+ if (this.useCardKit && this.multiCard) {
1744
+ // CardKit v1 path — pass auxiliary state for rich display
1745
+ const auxState = this.getAuxiliaryState();
1746
+ try {
1747
+ await this.multiCard.commitContent(this.accumulatedText, displayState, auxState, footerNote || this.getFooterNote(), this.footerRuntimeIdentity);
1748
+ this.flushCtrl.markFlushed(this.accumulatedText.length);
1749
+ this.patchFailCount = 0;
1750
+ }
1751
+ catch (err) {
1752
+ this.patchFailCount++;
1753
+ logger.debug({
1754
+ err,
1755
+ chatId: this.chatId,
1756
+ failCount: this.patchFailCount,
1757
+ mode: 'cardkit',
1758
+ }, 'CardKit card update failed');
1759
+ throw err;
1760
+ }
1761
+ }
1762
+ else {
1763
+ // Legacy message.patch path (no auxiliary content)
1764
+ if (!this.messageId)
1765
+ return;
1766
+ const card = buildStreamingCard(this.accumulatedText, displayState, footerNote || this.getFooterNote(), this.footerRuntimeIdentity);
1767
+ const content = JSON.stringify(card);
1768
+ try {
1769
+ await this.client.im.v1.message.patch({
1770
+ path: { message_id: this.messageId },
1771
+ data: { content },
1772
+ });
1773
+ this.flushCtrl.markFlushed(this.accumulatedText.length);
1774
+ this.patchFailCount = 0;
1775
+ }
1776
+ catch (err) {
1777
+ this.patchFailCount++;
1778
+ logger.debug({
1779
+ err,
1780
+ chatId: this.chatId,
1781
+ failCount: this.patchFailCount,
1782
+ mode: 'legacy',
1783
+ }, 'Streaming card patch failed');
1784
+ throw err;
1785
+ }
1786
+ }
1787
+ }
1788
+ }
1789
+ // ─── MessageId → ChatJid Mapping ─────────────────────────────
1790
+ // Reverse lookup for card callback: given a Feishu messageId from a button click,
1791
+ // find which chatJid (streaming session) it belongs to.
1792
+ const messageIdToChatJid = new Map();
1793
+ /**
1794
+ * Register a messageId → chatJid mapping for card callback routing.
1795
+ */
1796
+ export function registerMessageIdMapping(messageId, chatJid) {
1797
+ messageIdToChatJid.set(messageId, chatJid);
1798
+ }
1799
+ /**
1800
+ * Resolve a chatJid from a Feishu messageId.
1801
+ */
1802
+ export function resolveJidByMessageId(messageId) {
1803
+ return messageIdToChatJid.get(messageId);
1804
+ }
1805
+ /**
1806
+ * Remove a messageId mapping.
1807
+ */
1808
+ export function unregisterMessageId(messageId) {
1809
+ messageIdToChatJid.delete(messageId);
1810
+ }
1811
+ // ─── Streaming Session Registry ───────────────────────────────
1812
+ // Global registry for tracking active streaming sessions.
1813
+ // Used by shutdown hooks to abort all active sessions.
1814
+ const activeSessions = new Map();
1815
+ /**
1816
+ * Register a streaming session for a chatJid.
1817
+ * Replaces any existing session for the same chatJid.
1818
+ */
1819
+ export function registerStreamingSession(chatJid, session) {
1820
+ const existing = activeSessions.get(chatJid);
1821
+ if (existing && existing.isActive()) {
1822
+ // Abort (not just dispose) so the old card shows "已中断" instead of stuck "生成中..."
1823
+ existing.abort('新的回复已开始').catch(() => { });
1824
+ }
1825
+ activeSessions.set(chatJid, session);
1826
+ }
1827
+ /**
1828
+ * Remove a streaming session from the registry.
1829
+ * Also cleans up all messageId → chatJid mappings (including multi-card).
1830
+ */
1831
+ export function unregisterStreamingSession(chatJid) {
1832
+ const session = activeSessions.get(chatJid);
1833
+ if (session) {
1834
+ for (const msgId of session.getAllMessageIds()) {
1835
+ unregisterMessageId(msgId);
1836
+ }
1837
+ }
1838
+ activeSessions.delete(chatJid);
1839
+ }
1840
+ /**
1841
+ * Get the active streaming session for a chatJid.
1842
+ */
1843
+ export function getStreamingSession(chatJid) {
1844
+ return activeSessions.get(chatJid);
1845
+ }
1846
+ /**
1847
+ * Check if there's an active streaming session for a chatJid.
1848
+ */
1849
+ export function hasActiveStreamingSession(chatJid) {
1850
+ const session = activeSessions.get(chatJid);
1851
+ return session?.isActive() ?? false;
1852
+ }
1853
+ /**
1854
+ * Abort all active streaming sessions.
1855
+ * Called during graceful shutdown.
1856
+ */
1857
+ export async function abortAllStreamingSessions(reason = '服务维护中') {
1858
+ const promises = [];
1859
+ for (const [chatJid, session] of activeSessions.entries()) {
1860
+ if (session.isActive()) {
1861
+ promises.push(session.abort(reason).catch((err) => {
1862
+ logger.debug({ err, chatJid }, 'Failed to abort streaming session during shutdown');
1863
+ }));
1864
+ }
1865
+ }
1866
+ await Promise.allSettled(promises);
1867
+ // Clean up messageId → chatJid mappings before clearing sessions
1868
+ for (const session of activeSessions.values()) {
1869
+ for (const msgId of session.getAllMessageIds()) {
1870
+ unregisterMessageId(msgId);
1871
+ }
1872
+ }
1873
+ activeSessions.clear();
1874
+ logger.info({ count: promises.length }, 'All streaming sessions aborted');
1875
+ }