@fraziym/axoniz 0.1.0

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 (512) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +76 -0
  3. package/dist/agents/definition.d.ts +15 -0
  4. package/dist/agents/definition.d.ts.map +1 -0
  5. package/dist/agents/definition.js +41 -0
  6. package/dist/agents/definition.js.map +1 -0
  7. package/dist/agents/index.d.ts +12 -0
  8. package/dist/agents/index.d.ts.map +1 -0
  9. package/dist/agents/index.js +11 -0
  10. package/dist/agents/index.js.map +1 -0
  11. package/dist/agents/registry.d.ts +17 -0
  12. package/dist/agents/registry.d.ts.map +1 -0
  13. package/dist/agents/registry.js +23 -0
  14. package/dist/agents/registry.js.map +1 -0
  15. package/dist/agents/specialized.d.ts +31 -0
  16. package/dist/agents/specialized.d.ts.map +1 -0
  17. package/dist/agents/specialized.js +75 -0
  18. package/dist/agents/specialized.js.map +1 -0
  19. package/dist/awareness/index.d.ts +14 -0
  20. package/dist/awareness/index.d.ts.map +1 -0
  21. package/dist/awareness/index.js +22 -0
  22. package/dist/awareness/index.js.map +1 -0
  23. package/dist/awareness/service.d.ts +180 -0
  24. package/dist/awareness/service.d.ts.map +1 -0
  25. package/dist/awareness/service.js +789 -0
  26. package/dist/awareness/service.js.map +1 -0
  27. package/dist/cli/entry.d.ts +13 -0
  28. package/dist/cli/entry.d.ts.map +1 -0
  29. package/dist/cli/entry.js +35 -0
  30. package/dist/cli/entry.js.map +1 -0
  31. package/dist/comms/telegram.d.ts +41 -0
  32. package/dist/comms/telegram.d.ts.map +1 -0
  33. package/dist/comms/telegram.js +332 -0
  34. package/dist/comms/telegram.js.map +1 -0
  35. package/dist/core/agent.d.ts +252 -0
  36. package/dist/core/agent.d.ts.map +1 -0
  37. package/dist/core/agent.js +1399 -0
  38. package/dist/core/agent.js.map +1 -0
  39. package/dist/core/auth.d.ts +23 -0
  40. package/dist/core/auth.d.ts.map +1 -0
  41. package/dist/core/auth.js +113 -0
  42. package/dist/core/auth.js.map +1 -0
  43. package/dist/core/authority.d.ts +118 -0
  44. package/dist/core/authority.d.ts.map +1 -0
  45. package/dist/core/authority.js +402 -0
  46. package/dist/core/authority.js.map +1 -0
  47. package/dist/core/backend/async_queue.d.ts +17 -0
  48. package/dist/core/backend/async_queue.d.ts.map +1 -0
  49. package/dist/core/backend/async_queue.js +66 -0
  50. package/dist/core/backend/async_queue.js.map +1 -0
  51. package/dist/core/backend/index.d.ts +24 -0
  52. package/dist/core/backend/index.d.ts.map +1 -0
  53. package/dist/core/backend/index.js +128 -0
  54. package/dist/core/backend/index.js.map +1 -0
  55. package/dist/core/backend/llama_server.d.ts +39 -0
  56. package/dist/core/backend/llama_server.d.ts.map +1 -0
  57. package/dist/core/backend/llama_server.js +182 -0
  58. package/dist/core/backend/llama_server.js.map +1 -0
  59. package/dist/core/backend/llamacpp.d.ts +40 -0
  60. package/dist/core/backend/llamacpp.d.ts.map +1 -0
  61. package/dist/core/backend/llamacpp.js +200 -0
  62. package/dist/core/backend/llamacpp.js.map +1 -0
  63. package/dist/core/backend/lmstudio.d.ts +53 -0
  64. package/dist/core/backend/lmstudio.d.ts.map +1 -0
  65. package/dist/core/backend/lmstudio.js +274 -0
  66. package/dist/core/backend/lmstudio.js.map +1 -0
  67. package/dist/core/backend/ollama.d.ts +25 -0
  68. package/dist/core/backend/ollama.d.ts.map +1 -0
  69. package/dist/core/backend/ollama.js +86 -0
  70. package/dist/core/backend/ollama.js.map +1 -0
  71. package/dist/core/backend/openai.d.ts +27 -0
  72. package/dist/core/backend/openai.d.ts.map +1 -0
  73. package/dist/core/backend/openai.js +84 -0
  74. package/dist/core/backend/openai.js.map +1 -0
  75. package/dist/core/backend/types.d.ts +73 -0
  76. package/dist/core/backend/types.d.ts.map +1 -0
  77. package/dist/core/backend/types.js +137 -0
  78. package/dist/core/backend/types.js.map +1 -0
  79. package/dist/core/chat_store.d.ts +77 -0
  80. package/dist/core/chat_store.d.ts.map +1 -0
  81. package/dist/core/chat_store.js +332 -0
  82. package/dist/core/chat_store.js.map +1 -0
  83. package/dist/core/cli.d.ts +82 -0
  84. package/dist/core/cli.d.ts.map +1 -0
  85. package/dist/core/cli.js +602 -0
  86. package/dist/core/cli.js.map +1 -0
  87. package/dist/core/config.d.ts +127 -0
  88. package/dist/core/config.d.ts.map +1 -0
  89. package/dist/core/config.js +295 -0
  90. package/dist/core/config.js.map +1 -0
  91. package/dist/core/debug.d.ts +8 -0
  92. package/dist/core/debug.d.ts.map +1 -0
  93. package/dist/core/debug.js +8 -0
  94. package/dist/core/debug.js.map +1 -0
  95. package/dist/core/downloader.d.ts +31 -0
  96. package/dist/core/downloader.d.ts.map +1 -0
  97. package/dist/core/downloader.js +180 -0
  98. package/dist/core/downloader.js.map +1 -0
  99. package/dist/core/errors.d.ts +80 -0
  100. package/dist/core/errors.d.ts.map +1 -0
  101. package/dist/core/errors.js +134 -0
  102. package/dist/core/errors.js.map +1 -0
  103. package/dist/core/extras.d.ts +160 -0
  104. package/dist/core/extras.d.ts.map +1 -0
  105. package/dist/core/extras.js +553 -0
  106. package/dist/core/extras.js.map +1 -0
  107. package/dist/core/health.d.ts +27 -0
  108. package/dist/core/health.d.ts.map +1 -0
  109. package/dist/core/health.js +96 -0
  110. package/dist/core/health.js.map +1 -0
  111. package/dist/core/history.d.ts +50 -0
  112. package/dist/core/history.d.ts.map +1 -0
  113. package/dist/core/history.js +276 -0
  114. package/dist/core/history.js.map +1 -0
  115. package/dist/core/index.d.ts +6 -0
  116. package/dist/core/index.d.ts.map +1 -0
  117. package/dist/core/index.js +6 -0
  118. package/dist/core/index.js.map +1 -0
  119. package/dist/core/intelligence/ast_index.d.ts +255 -0
  120. package/dist/core/intelligence/ast_index.d.ts.map +1 -0
  121. package/dist/core/intelligence/ast_index.js +1369 -0
  122. package/dist/core/intelligence/ast_index.js.map +1 -0
  123. package/dist/core/intelligence/confidence.d.ts +113 -0
  124. package/dist/core/intelligence/confidence.d.ts.map +1 -0
  125. package/dist/core/intelligence/confidence.js +298 -0
  126. package/dist/core/intelligence/confidence.js.map +1 -0
  127. package/dist/core/intelligence/context_compressor.d.ts +80 -0
  128. package/dist/core/intelligence/context_compressor.d.ts.map +1 -0
  129. package/dist/core/intelligence/context_compressor.js +245 -0
  130. package/dist/core/intelligence/context_compressor.js.map +1 -0
  131. package/dist/core/intelligence/daemon.d.ts +247 -0
  132. package/dist/core/intelligence/daemon.d.ts.map +1 -0
  133. package/dist/core/intelligence/daemon.js +842 -0
  134. package/dist/core/intelligence/daemon.js.map +1 -0
  135. package/dist/core/intelligence/index.d.ts +60 -0
  136. package/dist/core/intelligence/index.d.ts.map +1 -0
  137. package/dist/core/intelligence/index.js +101 -0
  138. package/dist/core/intelligence/index.js.map +1 -0
  139. package/dist/core/intelligence/predictor.d.ts +246 -0
  140. package/dist/core/intelligence/predictor.d.ts.map +1 -0
  141. package/dist/core/intelligence/predictor.js +590 -0
  142. package/dist/core/intelligence/predictor.js.map +1 -0
  143. package/dist/core/intelligence/reflex.d.ts +84 -0
  144. package/dist/core/intelligence/reflex.d.ts.map +1 -0
  145. package/dist/core/intelligence/reflex.js +344 -0
  146. package/dist/core/intelligence/reflex.js.map +1 -0
  147. package/dist/core/intelligence/self_correction.d.ts +121 -0
  148. package/dist/core/intelligence/self_correction.d.ts.map +1 -0
  149. package/dist/core/intelligence/self_correction.js +327 -0
  150. package/dist/core/intelligence/self_correction.js.map +1 -0
  151. package/dist/core/intelligence/skill_distiller.d.ts +100 -0
  152. package/dist/core/intelligence/skill_distiller.d.ts.map +1 -0
  153. package/dist/core/intelligence/skill_distiller.js +246 -0
  154. package/dist/core/intelligence/skill_distiller.js.map +1 -0
  155. package/dist/core/intelligence/swarm.d.ts +330 -0
  156. package/dist/core/intelligence/swarm.d.ts.map +1 -0
  157. package/dist/core/intelligence/swarm.js +1064 -0
  158. package/dist/core/intelligence/swarm.js.map +1 -0
  159. package/dist/core/intelligence/trajectory.d.ts +107 -0
  160. package/dist/core/intelligence/trajectory.d.ts.map +1 -0
  161. package/dist/core/intelligence/trajectory.js +330 -0
  162. package/dist/core/intelligence/trajectory.js.map +1 -0
  163. package/dist/core/logger.d.ts +111 -0
  164. package/dist/core/logger.d.ts.map +1 -0
  165. package/dist/core/logger.js +640 -0
  166. package/dist/core/logger.js.map +1 -0
  167. package/dist/core/loop.d.ts +123 -0
  168. package/dist/core/loop.d.ts.map +1 -0
  169. package/dist/core/loop.js +423 -0
  170. package/dist/core/loop.js.map +1 -0
  171. package/dist/core/memory.d.ts +81 -0
  172. package/dist/core/memory.d.ts.map +1 -0
  173. package/dist/core/memory.js +328 -0
  174. package/dist/core/memory.js.map +1 -0
  175. package/dist/core/metrics.d.ts +29 -0
  176. package/dist/core/metrics.d.ts.map +1 -0
  177. package/dist/core/metrics.js +109 -0
  178. package/dist/core/metrics.js.map +1 -0
  179. package/dist/core/models.d.ts +51 -0
  180. package/dist/core/models.d.ts.map +1 -0
  181. package/dist/core/models.js +146 -0
  182. package/dist/core/models.js.map +1 -0
  183. package/dist/core/optimizer.d.ts +34 -0
  184. package/dist/core/optimizer.d.ts.map +1 -0
  185. package/dist/core/optimizer.js +131 -0
  186. package/dist/core/optimizer.js.map +1 -0
  187. package/dist/core/persona.d.ts +35 -0
  188. package/dist/core/persona.d.ts.map +1 -0
  189. package/dist/core/persona.js +126 -0
  190. package/dist/core/persona.js.map +1 -0
  191. package/dist/core/rate_limit.d.ts +23 -0
  192. package/dist/core/rate_limit.d.ts.map +1 -0
  193. package/dist/core/rate_limit.js +62 -0
  194. package/dist/core/rate_limit.js.map +1 -0
  195. package/dist/core/runner.d.ts +63 -0
  196. package/dist/core/runner.d.ts.map +1 -0
  197. package/dist/core/runner.js +671 -0
  198. package/dist/core/runner.js.map +1 -0
  199. package/dist/core/stream_parser.d.ts +43 -0
  200. package/dist/core/stream_parser.d.ts.map +1 -0
  201. package/dist/core/stream_parser.js +140 -0
  202. package/dist/core/stream_parser.js.map +1 -0
  203. package/dist/core/tool_schemas.d.ts +26 -0
  204. package/dist/core/tool_schemas.d.ts.map +1 -0
  205. package/dist/core/tool_schemas.js +124 -0
  206. package/dist/core/tool_schemas.js.map +1 -0
  207. package/dist/core/which.d.ts +2 -0
  208. package/dist/core/which.d.ts.map +1 -0
  209. package/dist/core/which.js +35 -0
  210. package/dist/core/which.js.map +1 -0
  211. package/dist/goals/index.d.ts +9 -0
  212. package/dist/goals/index.d.ts.map +1 -0
  213. package/dist/goals/index.js +17 -0
  214. package/dist/goals/index.js.map +1 -0
  215. package/dist/goals/service.d.ts +115 -0
  216. package/dist/goals/service.d.ts.map +1 -0
  217. package/dist/goals/service.js +415 -0
  218. package/dist/goals/service.js.map +1 -0
  219. package/dist/goals/types.d.ts +47 -0
  220. package/dist/goals/types.d.ts.map +1 -0
  221. package/dist/goals/types.js +66 -0
  222. package/dist/goals/types.js.map +1 -0
  223. package/dist/index.d.ts +22 -0
  224. package/dist/index.d.ts.map +1 -0
  225. package/dist/index.js +22 -0
  226. package/dist/index.js.map +1 -0
  227. package/dist/integrations/autonomous_startup.d.ts +51 -0
  228. package/dist/integrations/autonomous_startup.d.ts.map +1 -0
  229. package/dist/integrations/autonomous_startup.js +419 -0
  230. package/dist/integrations/autonomous_startup.js.map +1 -0
  231. package/dist/integrations/goose.d.ts +105 -0
  232. package/dist/integrations/goose.d.ts.map +1 -0
  233. package/dist/integrations/goose.js +408 -0
  234. package/dist/integrations/goose.js.map +1 -0
  235. package/dist/integrations/index.d.ts +21 -0
  236. package/dist/integrations/index.d.ts.map +1 -0
  237. package/dist/integrations/index.js +17 -0
  238. package/dist/integrations/index.js.map +1 -0
  239. package/dist/integrations/mcp.d.ts +73 -0
  240. package/dist/integrations/mcp.d.ts.map +1 -0
  241. package/dist/integrations/mcp.js +307 -0
  242. package/dist/integrations/mcp.js.map +1 -0
  243. package/dist/integrations/mempalace.d.ts +18 -0
  244. package/dist/integrations/mempalace.d.ts.map +1 -0
  245. package/dist/integrations/mempalace.js +25 -0
  246. package/dist/integrations/mempalace.js.map +1 -0
  247. package/dist/integrations/multi_agent.d.ts +82 -0
  248. package/dist/integrations/multi_agent.d.ts.map +1 -0
  249. package/dist/integrations/multi_agent.js +280 -0
  250. package/dist/integrations/multi_agent.js.map +1 -0
  251. package/dist/integrations/unified_memory.d.ts +415 -0
  252. package/dist/integrations/unified_memory.d.ts.map +1 -0
  253. package/dist/integrations/unified_memory.js +1489 -0
  254. package/dist/integrations/unified_memory.js.map +1 -0
  255. package/dist/roles/beru.yaml +48 -0
  256. package/dist/sidecar/client.d.ts +63 -0
  257. package/dist/sidecar/client.d.ts.map +1 -0
  258. package/dist/sidecar/client.js +222 -0
  259. package/dist/sidecar/client.js.map +1 -0
  260. package/dist/sidecar/index.d.ts +7 -0
  261. package/dist/sidecar/index.d.ts.map +1 -0
  262. package/dist/sidecar/index.js +6 -0
  263. package/dist/sidecar/index.js.map +1 -0
  264. package/dist/startup.d.ts +22 -0
  265. package/dist/startup.d.ts.map +1 -0
  266. package/dist/startup.js +194 -0
  267. package/dist/startup.js.map +1 -0
  268. package/dist/tools/_internal.d.ts +90 -0
  269. package/dist/tools/_internal.d.ts.map +1 -0
  270. package/dist/tools/_internal.js +375 -0
  271. package/dist/tools/_internal.js.map +1 -0
  272. package/dist/tools/_smoke.d.ts +2 -0
  273. package/dist/tools/_smoke.d.ts.map +1 -0
  274. package/dist/tools/_smoke.js +203 -0
  275. package/dist/tools/_smoke.js.map +1 -0
  276. package/dist/tools/axodex_tools.d.ts +51 -0
  277. package/dist/tools/axodex_tools.d.ts.map +1 -0
  278. package/dist/tools/axodex_tools.js +181 -0
  279. package/dist/tools/axodex_tools.js.map +1 -0
  280. package/dist/tools/code_tools.d.ts +33 -0
  281. package/dist/tools/code_tools.d.ts.map +1 -0
  282. package/dist/tools/code_tools.js +191 -0
  283. package/dist/tools/code_tools.js.map +1 -0
  284. package/dist/tools/computer_tools.d.ts +59 -0
  285. package/dist/tools/computer_tools.d.ts.map +1 -0
  286. package/dist/tools/computer_tools.js +348 -0
  287. package/dist/tools/computer_tools.js.map +1 -0
  288. package/dist/tools/file_tools.d.ts +51 -0
  289. package/dist/tools/file_tools.d.ts.map +1 -0
  290. package/dist/tools/file_tools.js +424 -0
  291. package/dist/tools/file_tools.js.map +1 -0
  292. package/dist/tools/index.d.ts +13 -0
  293. package/dist/tools/index.d.ts.map +1 -0
  294. package/dist/tools/index.js +13 -0
  295. package/dist/tools/index.js.map +1 -0
  296. package/dist/tools/shell_tools.d.ts +39 -0
  297. package/dist/tools/shell_tools.d.ts.map +1 -0
  298. package/dist/tools/shell_tools.js +163 -0
  299. package/dist/tools/shell_tools.js.map +1 -0
  300. package/dist/tools/web_tools.d.ts +31 -0
  301. package/dist/tools/web_tools.d.ts.map +1 -0
  302. package/dist/tools/web_tools.js +117 -0
  303. package/dist/tools/web_tools.js.map +1 -0
  304. package/dist/voice/index.d.ts +41 -0
  305. package/dist/voice/index.d.ts.map +1 -0
  306. package/dist/voice/index.js +58 -0
  307. package/dist/voice/index.js.map +1 -0
  308. package/dist/voice/light_daemon.d.ts +197 -0
  309. package/dist/voice/light_daemon.d.ts.map +1 -0
  310. package/dist/voice/light_daemon.js +746 -0
  311. package/dist/voice/light_daemon.js.map +1 -0
  312. package/dist/voice/setup.d.ts +63 -0
  313. package/dist/voice/setup.d.ts.map +1 -0
  314. package/dist/voice/setup.js +443 -0
  315. package/dist/voice/setup.js.map +1 -0
  316. package/dist/voice/stt.d.ts +148 -0
  317. package/dist/voice/stt.d.ts.map +1 -0
  318. package/dist/voice/stt.js +361 -0
  319. package/dist/voice/stt.js.map +1 -0
  320. package/dist/voice/tts.d.ts +168 -0
  321. package/dist/voice/tts.d.ts.map +1 -0
  322. package/dist/voice/tts.js +698 -0
  323. package/dist/voice/tts.js.map +1 -0
  324. package/dist/voice/voice_loop.d.ts +101 -0
  325. package/dist/voice/voice_loop.d.ts.map +1 -0
  326. package/dist/voice/voice_loop.js +463 -0
  327. package/dist/voice/voice_loop.js.map +1 -0
  328. package/dist/voice/wake_word.d.ts +153 -0
  329. package/dist/voice/wake_word.d.ts.map +1 -0
  330. package/dist/voice/wake_word.js +458 -0
  331. package/dist/voice/wake_word.js.map +1 -0
  332. package/dist/voice/wav.d.ts +99 -0
  333. package/dist/voice/wav.d.ts.map +1 -0
  334. package/dist/voice/wav.js +306 -0
  335. package/dist/voice/wav.js.map +1 -0
  336. package/dist/web/broker.d.ts +33 -0
  337. package/dist/web/broker.d.ts.map +1 -0
  338. package/dist/web/broker.js +58 -0
  339. package/dist/web/broker.js.map +1 -0
  340. package/dist/web/open_browser.d.ts +2 -0
  341. package/dist/web/open_browser.d.ts.map +1 -0
  342. package/dist/web/open_browser.js +42 -0
  343. package/dist/web/open_browser.js.map +1 -0
  344. package/dist/web/server.d.ts +54 -0
  345. package/dist/web/server.d.ts.map +1 -0
  346. package/dist/web/server.js +831 -0
  347. package/dist/web/server.js.map +1 -0
  348. package/dist/web/static/app.js +4121 -0
  349. package/dist/web/static/index.html +2281 -0
  350. package/dist/web/static/js/MODULE_SUMMARY.md +124 -0
  351. package/dist/web/static/js/a11y.js +165 -0
  352. package/dist/web/static/js/admin.js +2145 -0
  353. package/dist/web/static/js/assistant.js +475 -0
  354. package/dist/web/static/js/calendar/reminders.js +114 -0
  355. package/dist/web/static/js/calendar/utils.js +166 -0
  356. package/dist/web/static/js/calendar.js +3348 -0
  357. package/dist/web/static/js/censor.js +356 -0
  358. package/dist/web/static/js/chat.js +4546 -0
  359. package/dist/web/static/js/chatRenderer.js +2294 -0
  360. package/dist/web/static/js/chatStream.js +276 -0
  361. package/dist/web/static/js/codeRunner.js +398 -0
  362. package/dist/web/static/js/color/hex.js +14 -0
  363. package/dist/web/static/js/colorPicker.js +453 -0
  364. package/dist/web/static/js/compare/icons.js +77 -0
  365. package/dist/web/static/js/compare/index.js +1482 -0
  366. package/dist/web/static/js/compare/models.js +104 -0
  367. package/dist/web/static/js/compare/panes.js +817 -0
  368. package/dist/web/static/js/compare/probe.js +78 -0
  369. package/dist/web/static/js/compare/scoreboard.js +223 -0
  370. package/dist/web/static/js/compare/selector.js +1335 -0
  371. package/dist/web/static/js/compare/state.js +58 -0
  372. package/dist/web/static/js/compare/stream.js +695 -0
  373. package/dist/web/static/js/compare/vote.js +254 -0
  374. package/dist/web/static/js/cookbook-diagnosis.js +694 -0
  375. package/dist/web/static/js/cookbook-hwfit.js +1888 -0
  376. package/dist/web/static/js/cookbook.js +2093 -0
  377. package/dist/web/static/js/cookbookDownload.js +633 -0
  378. package/dist/web/static/js/cookbookProgressSignal.js +29 -0
  379. package/dist/web/static/js/cookbookRunning.js +3506 -0
  380. package/dist/web/static/js/cookbookServe.js +2128 -0
  381. package/dist/web/static/js/document.js +9731 -0
  382. package/dist/web/static/js/documentLibrary.js +3376 -0
  383. package/dist/web/static/js/dragSort.js +265 -0
  384. package/dist/web/static/js/editor/ai-inpaint.js +374 -0
  385. package/dist/web/static/js/editor/ai-models.js +273 -0
  386. package/dist/web/static/js/editor/ai-rembg.js +230 -0
  387. package/dist/web/static/js/editor/ai-tool-runner.js +147 -0
  388. package/dist/web/static/js/editor/ai-tools-misc.js +216 -0
  389. package/dist/web/static/js/editor/build/controls.js +366 -0
  390. package/dist/web/static/js/editor/build/popups.js +112 -0
  391. package/dist/web/static/js/editor/build/right-panel.js +200 -0
  392. package/dist/web/static/js/editor/build/toolbar.js +73 -0
  393. package/dist/web/static/js/editor/build/topbar.js +131 -0
  394. package/dist/web/static/js/editor/build/transform-popup.js +109 -0
  395. package/dist/web/static/js/editor/canvas-coords.js +21 -0
  396. package/dist/web/static/js/editor/canvas-events.js +197 -0
  397. package/dist/web/static/js/editor/canvas-transforms.js +132 -0
  398. package/dist/web/static/js/editor/checkerboard.js +24 -0
  399. package/dist/web/static/js/editor/clipboard-and-drop.js +133 -0
  400. package/dist/web/static/js/editor/composite-helpers.js +83 -0
  401. package/dist/web/static/js/editor/filters/blur.js +118 -0
  402. package/dist/web/static/js/editor/filters/edge-feather.js +70 -0
  403. package/dist/web/static/js/editor/fx/adj-popup.js +677 -0
  404. package/dist/web/static/js/editor/fx/filter-string.js +38 -0
  405. package/dist/web/static/js/editor/fx/histogram.js +67 -0
  406. package/dist/web/static/js/editor/fx/pixel-pass.js +249 -0
  407. package/dist/web/static/js/editor/harmonize-masks.js +116 -0
  408. package/dist/web/static/js/editor/history-panel.js +176 -0
  409. package/dist/web/static/js/editor/keyboard-shortcuts.js +271 -0
  410. package/dist/web/static/js/editor/layer-helpers.js +131 -0
  411. package/dist/web/static/js/editor/layer-panel.js +600 -0
  412. package/dist/web/static/js/editor/mask-utils.js +91 -0
  413. package/dist/web/static/js/editor/shortcuts-popover.js +149 -0
  414. package/dist/web/static/js/editor/slider-ux.js +191 -0
  415. package/dist/web/static/js/editor/snap.js +107 -0
  416. package/dist/web/static/js/editor/state.js +257 -0
  417. package/dist/web/static/js/editor/stroke-pipeline.js +162 -0
  418. package/dist/web/static/js/editor/stroke-tool-sliders.js +64 -0
  419. package/dist/web/static/js/editor/tools/clone.js +81 -0
  420. package/dist/web/static/js/editor/tools/crop.js +137 -0
  421. package/dist/web/static/js/editor/tools/flood-fill.js +72 -0
  422. package/dist/web/static/js/editor/tools/lasso-mask.js +171 -0
  423. package/dist/web/static/js/editor/tools/lasso.js +65 -0
  424. package/dist/web/static/js/editor/tools/move.js +79 -0
  425. package/dist/web/static/js/editor/tools/stroke.js +123 -0
  426. package/dist/web/static/js/editor/tools/transform-drag.js +174 -0
  427. package/dist/web/static/js/editor/tools/transform-handles.js +271 -0
  428. package/dist/web/static/js/editor/tools/transform-session.js +381 -0
  429. package/dist/web/static/js/editor/tools/wand.js +46 -0
  430. package/dist/web/static/js/editor/wire-import.js +148 -0
  431. package/dist/web/static/js/editor/wire-inpaint-controls.js +164 -0
  432. package/dist/web/static/js/editor/wire-merge-buttons.js +103 -0
  433. package/dist/web/static/js/editor/wire-selection-controls.js +170 -0
  434. package/dist/web/static/js/editor/wire-topbar-menus.js +174 -0
  435. package/dist/web/static/js/editor/wire-topbar-overflow.js +49 -0
  436. package/dist/web/static/js/editor/wire-topbar.js +188 -0
  437. package/dist/web/static/js/emailInbox.js +1245 -0
  438. package/dist/web/static/js/emailLibrary/replyRecipients.js +27 -0
  439. package/dist/web/static/js/emailLibrary/signatureFold.js +339 -0
  440. package/dist/web/static/js/emailLibrary/state.js +34 -0
  441. package/dist/web/static/js/emailLibrary/utils.js +202 -0
  442. package/dist/web/static/js/emailLibrary.js +5217 -0
  443. package/dist/web/static/js/emojiPicker.js +311 -0
  444. package/dist/web/static/js/escMenuStack.js +102 -0
  445. package/dist/web/static/js/fileHandler.js +295 -0
  446. package/dist/web/static/js/gallery.js +2835 -0
  447. package/dist/web/static/js/galleryEditor.js +3798 -0
  448. package/dist/web/static/js/group.js +955 -0
  449. package/dist/web/static/js/init.js +405 -0
  450. package/dist/web/static/js/keyboard-shortcuts.js +292 -0
  451. package/dist/web/static/js/langIcons.js +187 -0
  452. package/dist/web/static/js/markdown/tableRow.js +19 -0
  453. package/dist/web/static/js/markdown.js +806 -0
  454. package/dist/web/static/js/memory.js +1418 -0
  455. package/dist/web/static/js/modalManager.js +1552 -0
  456. package/dist/web/static/js/modalSnap.js +782 -0
  457. package/dist/web/static/js/model/matchKey.js +19 -0
  458. package/dist/web/static/js/modelPicker.js +695 -0
  459. package/dist/web/static/js/modelSort.js +33 -0
  460. package/dist/web/static/js/models.js +642 -0
  461. package/dist/web/static/js/notes.js +5054 -0
  462. package/dist/web/static/js/package.json +1 -0
  463. package/dist/web/static/js/platform.js +47 -0
  464. package/dist/web/static/js/presets.js +1110 -0
  465. package/dist/web/static/js/providers.js +93 -0
  466. package/dist/web/static/js/rag.js +177 -0
  467. package/dist/web/static/js/research/jobs.js +335 -0
  468. package/dist/web/static/js/research/panel.js +1233 -0
  469. package/dist/web/static/js/researchSynapse.js +225 -0
  470. package/dist/web/static/js/search-chat.js +201 -0
  471. package/dist/web/static/js/search.js +52 -0
  472. package/dist/web/static/js/section-management.js +260 -0
  473. package/dist/web/static/js/sessions.js +3108 -0
  474. package/dist/web/static/js/settings.js +4991 -0
  475. package/dist/web/static/js/sidebar-layout.js +573 -0
  476. package/dist/web/static/js/signature.js +504 -0
  477. package/dist/web/static/js/skills.js +1908 -0
  478. package/dist/web/static/js/slashAutocomplete.js +267 -0
  479. package/dist/web/static/js/slashCommands.js +6099 -0
  480. package/dist/web/static/js/spinner.js +406 -0
  481. package/dist/web/static/js/storage.js +123 -0
  482. package/dist/web/static/js/tasks.js +2699 -0
  483. package/dist/web/static/js/theme.js +2086 -0
  484. package/dist/web/static/js/tileManager.js +386 -0
  485. package/dist/web/static/js/tourAutoplay.js +133 -0
  486. package/dist/web/static/js/tourHints.js +179 -0
  487. package/dist/web/static/js/tts-ai.js +521 -0
  488. package/dist/web/static/js/ui.js +1288 -0
  489. package/dist/web/static/js/util/ordinal.js +13 -0
  490. package/dist/web/static/js/voiceRecorder.js +283 -0
  491. package/dist/web/static/js/windowDrag.js +326 -0
  492. package/dist/web/static/js/windowResize.js +233 -0
  493. package/dist/web/static/landing.html +824 -0
  494. package/dist/web/static/lib/docx.umd.min.js +20116 -0
  495. package/dist/web/static/lib/highlight.min.js +1213 -0
  496. package/dist/web/static/lib/html2pdf.bundle.min.js +3 -0
  497. package/dist/web/static/lib/mammoth.browser.min.js +21 -0
  498. package/dist/web/static/lib/qrcode.min.js +8 -0
  499. package/dist/web/static/lib/xlsx.full.min.js +24 -0
  500. package/dist/web/static/login.html +582 -0
  501. package/dist/web/static/manifest.json +15 -0
  502. package/dist/web/static/style.css +35733 -0
  503. package/dist/web/static/sw.js +144 -0
  504. package/dist/workflows/engine.d.ts +162 -0
  505. package/dist/workflows/engine.d.ts.map +1 -0
  506. package/dist/workflows/engine.js +625 -0
  507. package/dist/workflows/engine.js.map +1 -0
  508. package/dist/workflows/index.d.ts +8 -0
  509. package/dist/workflows/index.d.ts.map +1 -0
  510. package/dist/workflows/index.js +14 -0
  511. package/dist/workflows/index.js.map +1 -0
  512. package/package.json +112 -0
@@ -0,0 +1,4546 @@
1
+ // static/js/chat.js
2
+
3
+ /**
4
+ * Main chat functionality - message handling and streaming
5
+ */
6
+ // ES6 module — IIFE removed
7
+
8
+ import Storage from './storage.js';
9
+ import uiModule from './ui.js';
10
+ import sessionModule from './sessions.js';
11
+ import chatRenderer from './chatRenderer.js';
12
+ import chatStream from './chatStream.js';
13
+ import { addAITTSButton } from './tts-ai.js';
14
+ import markdownModule from './markdown.js';
15
+ import spinnerModule from './spinner.js';
16
+ import presetsModule from './presets.js';
17
+ import fileHandlerModule from './fileHandler.js';
18
+ import searchModule from './search.js';
19
+ import documentModule from './document.js';
20
+ import * as emailInbox from './emailInbox.js';
21
+ import codeRunnerModule from './codeRunner.js';
22
+ import slashCommands, { initSlashCommands, isCommand, handleSlashCommand, handleSetupInput, handleSetupWizard, typewriterInto } from './slashCommands.js';
23
+ import createResearchSynapse from './researchSynapse.js';
24
+ const RESEARCH_TIMEOUT_MS = 360000;
25
+ const DEFAULT_TIMEOUT_MS = 120000;
26
+ const RESEARCH_SVG = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>';
27
+
28
+ let API_BASE = '';
29
+ let currentAbort = null;
30
+ let isStreaming = false;
31
+ // Continuous stall watchdog: while streaming, if the SSE stream produces
32
+ // NOTHING for STALL_THRESHOLD_MS (no deltas, no tool heartbeat — tools beat
33
+ // every 2s, so a full minute of silence means it's genuinely stuck or the
34
+ // model quietly stopped), surface a non-destructive "still working?" prompt
35
+ // instead of silently hanging. Replaces relying only on the tab-refocus
36
+ // recovery (which fired only on visibilitychange and silently reloaded).
37
+ let _stallWatchdog = null;
38
+ let _stallBannerShown = false;
39
+ const STALL_THRESHOLD_MS = 60000;
40
+ let _sendInFlight = false; // covers the window from click → streaming start
41
+ let _displayOverride = null; // Override visible user bubble text (hides injected prompts)
42
+ let _hideUserBubble = false; // Skip user bubble entirely (e.g. continue after stop)
43
+ let _pendingContinue = null; // Stores the stopped AI element to merge with new response
44
+ // ── Auto-recovery: when a turn's stream silently dies (connection drop) or
45
+ // goes quiet while the connection is alive, re-engage the model with a
46
+ // completion handshake instead of leaving it hung. Capped so it can't loop.
47
+ let _autoNudges = 0; // handshakes fired for the CURRENT user turn
48
+ let _autoContinuePending = false; // marks the next submit as an auto-continue (don't reset the counter)
49
+ const _AUTO_NUDGE_CAP = 3;
50
+
51
+ // shortModel and modelColor are now in chatRenderer.js
52
+ var _shortModel = chatRenderer.shortModel;
53
+ var _applyModelColor = chatRenderer.applyModelColor;
54
+ // Per-session research tracking (supports concurrent research across sessions)
55
+ const _researchingStreamIds = new Set();
56
+ let _researchTimerEl = null, _researchTimerInterval = null;
57
+ let _researchStartTime = 0, _researchAvgDuration = null;
58
+ let _researchSynapse = null;
59
+ function _clearResearchTimer() {
60
+ if (_researchTimerInterval) { clearInterval(_researchTimerInterval); _researchTimerInterval = null; }
61
+ if (_researchTimerEl) { _researchTimerEl.remove(); _researchTimerEl = null; }
62
+ if (_researchSynapse) {
63
+ // Mark complete first so the user briefly sees the "done" state,
64
+ // then tear it down on next tick.
65
+ try { _researchSynapse.complete(); } catch {}
66
+ const s = _researchSynapse;
67
+ _researchSynapse = null;
68
+ setTimeout(() => { try { s.destroy(); } catch {} }, 800);
69
+ }
70
+ _researchStartTime = 0;
71
+ _researchAvgDuration = null;
72
+ }
73
+
74
+ /** Append a "Generate Visual Report" button — delegates to chatRenderer. */
75
+ function _appendViewReportLink(msgEl, sessionId) {
76
+ const body = msgEl.querySelector('.body');
77
+ if (body) chatRenderer.appendReportButton(body, sessionId);
78
+ }
79
+ let currentAccumulated = ''; // Track accumulated text across function scope
80
+ let currentHolder = null; // Track current message holder
81
+ let currentSpinner = null; // Track current spinner for stop cleanup
82
+
83
+ // Background streaming support
84
+ const _backgroundStreams = new Map(); // sessionId -> { status, accumulated, sourcesHtml, abortCtrl, query, metrics }
85
+ let _streamSessionId = null; // Session ID for the currently active reader loop
86
+ let _lastReaderActivity = 0; // Timestamp of last reader.read() success — used to detect frozen streams
87
+ let _webLockRelease = null; // Function to release the Web Lock held during streaming
88
+
89
+ /** Check if an SSE reader is still actively connected for a session. */
90
+ function hasActiveStream(sessionId) {
91
+ return _streamSessionId === sessionId || _backgroundStreams.has(sessionId);
92
+ }
93
+
94
+ // Sources box builder and toggleSources are now in chatRenderer.js
95
+ var _buildSourcesBox = chatRenderer.buildSourcesBox;
96
+
97
+ // Browser notifications now in chatStream.js
98
+ var _notifyResearchComplete = chatStream.notifyResearchComplete;
99
+
100
+ // Model/image pricing, _buildImageBubble now in chatRenderer.js
101
+ var _buildImageBubble = chatRenderer.buildImageBubble;
102
+ var getModelCost = chatRenderer.getModelCost;
103
+ var getImageCost = chatRenderer.getImageCost;
104
+
105
+ // stripToolBlocks and roleTimestamp now in chatRenderer.js
106
+ var stripToolBlocks = chatRenderer.stripToolBlocks;
107
+
108
+ function _normalizeEndpointForCompare(url) {
109
+ if (!url) return '';
110
+ try {
111
+ const u = new URL(String(url), window.location.origin);
112
+ let path = u.pathname.replace(/\/+$/, '');
113
+ const suffixes = [
114
+ '/v1/chat/completions', '/chat/completions',
115
+ '/v1/completions', '/completions',
116
+ '/v1/messages', '/messages',
117
+ '/v1/models', '/models',
118
+ ];
119
+ for (const suffix of suffixes) {
120
+ if (path.toLowerCase().endsWith(suffix)) {
121
+ path = path.slice(0, -suffix.length).replace(/\/+$/, '');
122
+ break;
123
+ }
124
+ }
125
+ return (u.origin + path).toLowerCase();
126
+ } catch (_) {
127
+ return String(url).trim().replace(/\/+$/, '').toLowerCase();
128
+ }
129
+ }
130
+
131
+ async function _probeCurrentEndpointStatus(endpointUrl, signal) {
132
+ const target = _normalizeEndpointForCompare(endpointUrl);
133
+ if (!target) return null;
134
+ const modelsRes = await fetch(`${API_BASE}/api/models`, { credentials: 'same-origin', signal });
135
+ if (!modelsRes.ok) return null;
136
+ const modelsData = await modelsRes.json().catch(() => ({}));
137
+ const item = (modelsData.items || []).find(ep =>
138
+ _normalizeEndpointForCompare(ep.url || ep.endpoint_url || ep.base_url) === target
139
+ );
140
+ if (!item || !item.endpoint_id) return null;
141
+
142
+ const probesRes = await fetch(`${API_BASE}/api/model-endpoints/probe-local`, {
143
+ credentials: 'same-origin',
144
+ signal,
145
+ });
146
+ if (!probesRes.ok) return null;
147
+ const probes = await probesRes.json().catch(() => ({}));
148
+ return probes[item.endpoint_id] || null;
149
+ }
150
+
151
+ /**
152
+ * Initialize with dependencies
153
+ */
154
+ export function init(apiBase) {
155
+ API_BASE = apiBase;
156
+ initSlashCommands({ apiBase, isStreaming: () => isStreaming });
157
+ // Initialize email inbox
158
+ emailInbox.init(documentModule);
159
+ // Wire the slash-command autocomplete popup on the chat composer. The
160
+ // dispatcher already handles the typed command — this just surfaces the
161
+ // registry as a discoverable menu when the user starts a message with /.
162
+ import('./slashAutocomplete.js').then(mod => {
163
+ const ta = document.getElementById('message');
164
+ if (ta && mod.initSlashAutocomplete) mod.initSlashAutocomplete(ta);
165
+ }).catch(() => {});
166
+ }
167
+
168
+ // addMessage, createMsgFooter, displayMetrics, hideWelcomeScreen, showWelcomeScreen
169
+ // are now in chatRenderer.js — referenced via the public API delegation above.
170
+ var addMessage = chatRenderer.addMessage;
171
+ var createMsgFooter = chatRenderer.createMsgFooter;
172
+ var displayMetrics = chatRenderer.displayMetrics;
173
+ var hideWelcomeScreen = chatRenderer.hideWelcomeScreen;
174
+ var showWelcomeScreen = chatRenderer.showWelcomeScreen;
175
+
176
+ /**
177
+ * Update submit button state
178
+ */
179
+ function updateSubmitButton(state, submitBtn) {
180
+ if (!submitBtn) return;
181
+
182
+ if (state === 'streaming') {
183
+ // Clear any pending transitions from + → arrow swap
184
+ submitBtn.classList.remove('anim-spin', 'anim-spin-swap', 'anim-land', 'mic-mode', 'newchat-mode', 'newchat-expanded', 'recording');
185
+ // Ensure arrow icon is showing before launch
186
+ var icons = window._zeroBtnIcons;
187
+ if (icons) submitBtn.innerHTML = icons.send;
188
+ void submitBtn.offsetWidth;
189
+ // Arrow launches up, then stop icon lands in
190
+ submitBtn.classList.add('anim-launch');
191
+ const _stopSvg = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>';
192
+ // Wait for the launch keyframe to finish (0.3s) before swapping the
193
+ // arrow out for the stop icon — otherwise the swap happens mid-flight
194
+ // and the user sees nothing fly out.
195
+ setTimeout(() => {
196
+ submitBtn.innerHTML = _stopSvg;
197
+ submitBtn.classList.remove('anim-launch');
198
+ void submitBtn.offsetWidth;
199
+ submitBtn.classList.add('anim-land');
200
+ submitBtn.addEventListener('animationend', () => submitBtn.classList.remove('anim-land'), { once: true });
201
+ }, 300);
202
+ submitBtn.title = 'Stop generation';
203
+ submitBtn.dataset.mode = 'streaming';
204
+ submitBtn.dataset.phase = 'processing';
205
+ isStreaming = true;
206
+ _startStallWatchdog();
207
+ } else if (state === 'idle') {
208
+ submitBtn.dataset.mode = '';
209
+ delete submitBtn.dataset.phase;
210
+ submitBtn.classList.remove('recording');
211
+ isStreaming = false;
212
+ _stopStallWatchdog();
213
+ // Defer to global updater which handles mic/newchat/send modes
214
+ if (window._updateSendBtnIcon) {
215
+ setTimeout(window._updateSendBtnIcon, 50);
216
+ } else {
217
+ var icons = window._zeroBtnIcons;
218
+ submitBtn.innerHTML = icons ? icons.send : '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg>';
219
+ submitBtn.title = 'Send message';
220
+ submitBtn.classList.remove('mic-mode', 'newchat-mode');
221
+ }
222
+ }
223
+ }
224
+
225
+ // -----------------------------------------------------------------------
226
+ // Slash commands — now in slashCommands.js
227
+ // -----------------------------------------------------------------------
228
+
229
+ // API key pattern for the guard in handleChatSubmit
230
+ const API_KEY_RE = /^(sk-[a-zA-Z0-9_\-]{20,}|gsk_[a-zA-Z0-9]{20,}|AIza[a-zA-Z0-9_\-]{30,}|xai-[a-zA-Z0-9]{20,})$/;
231
+
232
+
233
+ /**
234
+ * Handle chat form submission
235
+ */
236
+ export async function handleChatSubmit(e) {
237
+ e.preventDefault();
238
+ // Cancel research clarification timeout if active
239
+ if (window._researchTimeoutTimer) {
240
+ clearTimeout(window._researchTimeoutTimer);
241
+ window._researchTimeoutTimer = null;
242
+ }
243
+ // Get current session
244
+ const sessionId = sessionModule.getCurrentSessionId();
245
+ const session = sessionModule.getSessions().find(s => s.id === sessionId);
246
+
247
+ const submitBtn = document.querySelector('.send-btn');
248
+
249
+ // If compare is active, stop all compare streams
250
+ if (window.compareModule && window.compareModule.isActive()) {
251
+ window.compareModule.handleCompareSubmit();
252
+ return;
253
+ }
254
+
255
+ // If currently streaming, stop it
256
+ if (isStreaming) {
257
+ // Cancel server-side research if in progress
258
+ const _cancelSid = sessionModule.getCurrentSessionId();
259
+ if (_cancelSid && _researchingStreamIds.has(_cancelSid)) {
260
+ fetch(`${API_BASE}/api/research/cancel/${_cancelSid}`, { method: 'POST' }).catch(e => console.warn('Research cancel failed:', e));
261
+ _researchingStreamIds.delete(_cancelSid);
262
+ _clearResearchTimer();
263
+ }
264
+ abortCurrentRequest(true); // explicit user Stop → also cancel the detached server run
265
+
266
+ // Clean up any running agent thread nodes (stop wave animation, remove "running" state)
267
+ document.querySelectorAll('.agent-thread-node.running').forEach(node => {
268
+ if (node._waveInterval) { clearInterval(node._waveInterval); node._waveInterval = null; }
269
+ if (node._elapsedTicker) { clearInterval(node._elapsedTicker); node._elapsedTicker = null; }
270
+ node.classList.remove('running');
271
+ const wave = node.querySelector('.agent-thread-wave');
272
+ if (wave) wave.textContent = '';
273
+ const icon = node.querySelector('.agent-thread-icon');
274
+ if (icon) icon.textContent = '\u25A0'; // stop square
275
+ const statusEl = node.querySelector('.agent-thread-status');
276
+ if (!statusEl) {
277
+ const header = node.querySelector('.agent-thread-header');
278
+ if (header) {
279
+ const s = document.createElement('span');
280
+ s.className = 'agent-thread-status';
281
+ s.textContent = 'stopped';
282
+ header.appendChild(s);
283
+ }
284
+ }
285
+ });
286
+ document.querySelectorAll('.agent-thread.streaming').forEach(t => t.classList.remove('streaming'));
287
+
288
+ // Clean up any thinking spinners
289
+ document.querySelectorAll('.agent-thinking-dots').forEach(el => {
290
+ if (el._spinner) el._spinner.destroy();
291
+ el.remove();
292
+ });
293
+ // No text accumulated — remove the empty holder with spinner
294
+ if (currentHolder && !currentAccumulated) {
295
+ if (currentSpinner) { currentSpinner.destroy(); currentSpinner = null; }
296
+ // Empty cancel — keep the assistant bubble around with a "Cancelled
297
+ // by user" indicator and persist a placeholder server-side so the
298
+ // turn survives a refresh instead of vanishing without a trace.
299
+ _renderCancelledBubble(currentHolder);
300
+ currentHolder = null;
301
+ updateSubmitButton('idle', submitBtn);
302
+ const messageInput = uiModule.el('message');
303
+ if (messageInput) messageInput.disabled = false;
304
+ currentAccumulated = '';
305
+ return;
306
+ }
307
+ // Render whatever was accumulated so far
308
+ if (currentHolder && currentAccumulated) {
309
+ // Store accumulated in a closure variable before it gets cleared
310
+ const stoppedContent = currentAccumulated;
311
+
312
+ // Store raw content in dataset for consistency with other messages
313
+ currentHolder.dataset.raw = stoppedContent;
314
+
315
+ currentHolder.querySelector('.body').innerHTML = markdownModule.processWithThinking(
316
+ markdownModule.squashOutsideCode(stoppedContent)
317
+ );
318
+
319
+ // Highlight code blocks
320
+ if (window.hljs) {
321
+ currentHolder.querySelectorAll('pre code').forEach((block) => {
322
+ window.hljs.highlightElement(block);
323
+ });
324
+ }
325
+
326
+ // Add the stopped indicator with continue button
327
+ const stoppedIndicator = document.createElement('div');
328
+ stoppedIndicator.className = 'stopped-indicator';
329
+ const stoppedLabel = document.createElement('span');
330
+ stoppedLabel.textContent = '[Message interrupted]';
331
+ stoppedIndicator.appendChild(stoppedLabel);
332
+ const continueBtn = document.createElement('button');
333
+ continueBtn.className = 'continue-btn';
334
+ continueBtn.title = 'Continue';
335
+ continueBtn.textContent = '\u25B8';
336
+ const _stoppedHolder = currentHolder; // capture before it gets cleared
337
+ continueBtn.addEventListener('click', () => {
338
+ stoppedIndicator.remove();
339
+ _hideUserBubble = true;
340
+ _pendingContinue = _stoppedHolder;
341
+ const cutoff = stoppedContent;
342
+ const msgInput = uiModule.el('message');
343
+ if (msgInput) {
344
+ msgInput.value = 'Your previous response was interrupted. It ended with:\n\n' + cutoff.slice(-500) + '\n\nDo NOT repeat what you already said. Continue exactly from where you were cut off.';
345
+ const sb = document.querySelector('.send-btn');
346
+ if (sb) sb.click();
347
+ }
348
+ });
349
+ stoppedIndicator.appendChild(continueBtn);
350
+ currentHolder.querySelector('.body').appendChild(stoppedIndicator);
351
+
352
+ // Tell server to mark this message as stopped
353
+ const _sid = sessionModule.getCurrentSessionId();
354
+ if (_sid) fetch(`${API_BASE}/api/session/${_sid}/mark-stopped`, { method: 'POST' }).catch(e => console.warn('mark-stopped failed:', e));
355
+
356
+ // Add footer with copy/regen if not already present
357
+ if (!currentHolder.querySelector('.msg-footer')) {
358
+ currentHolder.dataset.raw = stoppedContent;
359
+ currentHolder.appendChild(createMsgFooter(currentHolder));
360
+ }
361
+
362
+ uiModule.scrollHistory();
363
+ }
364
+
365
+ // Reset button state
366
+ updateSubmitButton('idle', submitBtn);
367
+
368
+ // Re-enable message input
369
+ const messageInput = uiModule.el('message');
370
+ if (messageInput) messageInput.disabled = false;
371
+
372
+ // Clear tracking variables
373
+ currentAccumulated = '';
374
+ currentHolder = null;
375
+
376
+ return;
377
+ }
378
+
379
+ // --- Send-path entry: block re-clicks between submit and stream start ---
380
+ if (_sendInFlight) return;
381
+ _sendInFlight = true;
382
+ // Instant visual feedback so the user sees their click was accepted
383
+ // even before the streaming button state kicks in below.
384
+ const _earlyMessageInput = uiModule.el('message');
385
+ if (_earlyMessageInput) _earlyMessageInput.disabled = true;
386
+ if (submitBtn) submitBtn.classList.add('send-pending');
387
+ const _releaseSendFlag = () => {
388
+ _sendInFlight = false;
389
+ if (_earlyMessageInput) _earlyMessageInput.disabled = false;
390
+ if (submitBtn) submitBtn.classList.remove('send-pending');
391
+ };
392
+
393
+ // --- Setup mode: intercept next message (but let slash commands through) ---
394
+ {
395
+ const el = uiModule.el;
396
+ const rawMsg = (el('message').value || '').trim();
397
+ const currentSetupMode = slashCommands.getSetupMode();
398
+ if (currentSetupMode && rawMsg && !isCommand(rawMsg)) {
399
+ const mode = currentSetupMode;
400
+ slashCommands.clearSetupMode(mode === 'endpoint-provider' || mode === 'endpoint-key-for-provider');
401
+ el('message').value = '';
402
+ if (window._syncModelPickerAutohide) window._syncModelPickerAutohide();
403
+ if (uiModule.autoResize) uiModule.autoResize(el('message'));
404
+ if (mode === true || mode === 'endpoint') {
405
+ handleSetupInput(rawMsg);
406
+ } else {
407
+ handleSetupWizard(mode, rawMsg);
408
+ }
409
+ _releaseSendFlag();
410
+ return;
411
+ }
412
+ if (currentSetupMode && rawMsg && isCommand(rawMsg)) {
413
+ slashCommands.clearSetupMode(); // Clear setup mode, fall through to slash handler
414
+ }
415
+ }
416
+
417
+ const el = uiModule.el;
418
+ const msg = el('message').value;
419
+ // Allow empty text when a regen carries over the original message's
420
+ // attachment ids — a photo-only message still has something to send.
421
+ if (!msg.trim() && !fileHandlerModule.getPendingCount() && !(_pendingRegenAttachments && _pendingRegenAttachments.length)) { _releaseSendFlag(); return; }
422
+
423
+ // --- Slash commands: execute directly without AI (no session needed) ---
424
+ if (isCommand(msg.trim())) {
425
+ const handled = await handleSlashCommand(msg.trim());
426
+ if (handled) {
427
+ el('message').value = '';
428
+ if (window._syncModelPickerAutohide) window._syncModelPickerAutohide();
429
+ if (uiModule.autoResize) uiModule.autoResize(el('message'));
430
+ _releaseSendFlag();
431
+ return;
432
+ }
433
+ }
434
+
435
+ // Materialize pending session (deferred from model click) on first message
436
+ if (sessionModule.hasPendingChat && sessionModule.hasPendingChat()) {
437
+ const ok = await sessionModule.materializePendingSession();
438
+ if (!ok || !sessionModule.getCurrentSessionId()) { _releaseSendFlag(); return; }
439
+ }
440
+
441
+ if (!sessionModule.getCurrentSessionId()) {
442
+ // Auto-create a session using default chat config. Always fetch fresh
443
+ // so that a recent Settings change takes effect without a page reload.
444
+ try {
445
+ let dc = null;
446
+ try {
447
+ const dcRes = await fetch('/api/default-chat');
448
+ dc = await dcRes.json();
449
+ if (dc && dc.endpoint_url && dc.model) {
450
+ try { window.__zeroDefaultChat = dc; } catch (_) {}
451
+ }
452
+ } catch (_) {
453
+ dc = (typeof window !== 'undefined' && window.__zeroDefaultChat) || null;
454
+ }
455
+ if (dc.endpoint_url && dc.model) {
456
+ await sessionModule.createDirectChat(dc.endpoint_url, dc.model, dc.endpoint_id);
457
+ const ok = await sessionModule.materializePendingSession();
458
+ if (!ok || !sessionModule.getCurrentSessionId()) { _releaseSendFlag(); return; }
459
+ } else {
460
+ el('message').value = '';
461
+ if (uiModule.autoResize) uiModule.autoResize(el('message'));
462
+ addMessage('assistant',
463
+ 'No chat session active. You can:\n\n' +
464
+ '- Open the model picker in the chat box and pick a model\n' +
465
+ '- Use the `+` button in the model picker to add a model endpoint\n' +
466
+ '- Use `/help` to see all available commands');
467
+ _releaseSendFlag();
468
+ return;
469
+ }
470
+ } catch (e) {
471
+ el('message').value = '';
472
+ if (uiModule.autoResize) uiModule.autoResize(el('message'));
473
+ addMessage('assistant',
474
+ 'No chat session active. You can:\n\n' +
475
+ '- Open the model picker in the chat box and pick a model\n' +
476
+ '- Use the `+` button in the model picker to add a model endpoint\n' +
477
+ '- Use `/help` to see all available commands');
478
+ _releaseSendFlag();
479
+ return;
480
+ }
481
+ }
482
+
483
+ // --- API key guard: warn if message looks like an API key ---
484
+ if (API_KEY_RE.test(msg.trim())) {
485
+ if (!await window.styledConfirm('This looks like an API key. Sending it to the AI could expose it.\n\nDid you mean to use /setup instead?', { confirmText: 'Send anyway', danger: true })) {
486
+ _releaseSendFlag();
487
+ return;
488
+ }
489
+ }
490
+
491
+
492
+ const messageInput = el('message');
493
+ const originalBtnText = submitBtn ? submitBtn.innerHTML : '';
494
+
495
+ // Re-enable the textarea now that we've handed off to the stream: the
496
+ // user wants to compose the next message while the AI is still talking.
497
+ // The `isStreaming` flag is the re-click guard for the send button.
498
+ if (messageInput) messageInput.disabled = false;
499
+ updateSubmitButton('streaming', submitBtn);
500
+ if (submitBtn) submitBtn.classList.remove('send-pending');
501
+ _sendInFlight = false;
502
+
503
+ // Capture session ID for background stream detection
504
+ const streamSessionId = sessionModule.getCurrentSessionId();
505
+ _streamSessionId = streamSessionId;
506
+ const streamQuery = msg;
507
+ _lastReaderActivity = Date.now();
508
+
509
+ // Acquire Web Lock to hint browser not to discard this tab while streaming
510
+ if (navigator.locks) {
511
+ navigator.locks.request('zero-stream-' + streamSessionId, { mode: 'exclusive', ifAvailable: true }, lock => {
512
+ if (!lock) return; // Another stream already holds a lock — fine
513
+ return new Promise(resolve => { _webLockRelease = resolve; });
514
+ }).catch(e => console.warn('web lock acquire failed:', e)); // Ignore lock errors — best-effort
515
+ }
516
+
517
+ // Declare accumulated outside try block so it's accessible in catch
518
+ let accumulated = '';
519
+ // Are we currently inside an unclosed <think> block? Toggled per think/answer
520
+ // cycle so a multi-round agent response (one reasoning phase PER round) wraps each
521
+ // round's reasoning in its own <think>…</think> instead of leaking rounds 2+ as text.
522
+ let _thinkOpen = false;
523
+ let holder = null;
524
+ let finalMeta = null;
525
+ let finalModelName = null;
526
+ let spinner = null;
527
+ let timedOut = false;
528
+ let processingProbeTimer = null;
529
+ let processingProbeAbort = null;
530
+ let _renderStream = () => {};
531
+ let _cancelThinkingTimer = () => {};
532
+ let _removeThinkingSpinner = () => {};
533
+ const clearProcessingProbe = () => {
534
+ if (processingProbeTimer) {
535
+ clearTimeout(processingProbeTimer);
536
+ processingProbeTimer = null;
537
+ }
538
+ if (processingProbeAbort) {
539
+ try { processingProbeAbort.abort(); } catch (_) {}
540
+ processingProbeAbort = null;
541
+ }
542
+ };
543
+
544
+ // Reset tracking variables at start
545
+ currentAccumulated = '';
546
+ currentHolder = null;
547
+
548
+ try {
549
+ // Re-enable auto-scroll when user sends a message
550
+ uiModule.setAutoScroll(true);
551
+ uiModule.scrollHistoryInstant();
552
+ // Clear completed dot now that user is interacting
553
+ if (sessionModule.clearStreamComplete) sessionModule.clearStreamComplete(sessionModule.getCurrentSessionId());
554
+
555
+ // Check for document selection context before consuming display override
556
+ const docSel = documentModule && documentModule.getSelectionContext();
557
+ if (docSel) {
558
+ const sels = Array.isArray(docSel) ? docSel : [docSel];
559
+ const lineRefs = sels.map(s =>
560
+ s.startLine === s.endLine ? `L${s.startLine}` : `L${s.startLine}-${s.endLine}`
561
+ );
562
+ _displayOverride = `[Doc edit: ${lineRefs.join(', ')}] ${msg}`;
563
+ }
564
+
565
+ const userDisplay = _displayOverride || msg;
566
+ _displayOverride = null;
567
+ const skipBubble = _hideUserBubble;
568
+ _hideUserBubble = false;
569
+ // Auto-recovery counter: carries across a turn's auto-continues, but resets
570
+ // when the user genuinely sends a new message (so each task gets a fresh cap).
571
+ // A real user turn (visible bubble) ALWAYS resets the budget — even if a
572
+ // prior auto-continue's deferred click never cleared the pending flag — so a
573
+ // stuck flag can't silently eat the next turn's recovery budget.
574
+ if (!skipBubble) { _autoNudges = 0; _autoContinuePending = false; }
575
+ else if (_autoContinuePending) { _autoContinuePending = false; }
576
+ const _pendingAttachInfo = fileHandlerModule.getPendingCount() ? fileHandlerModule.getPendingInfo() : null;
577
+ // Pre-read importable file contents before upload clears pending files
578
+ const IMPORTABLE_EXT = /\.(txt|py|js|ts|html|htm|css|md|json|csv|yml|yaml|sh|sql|rs|go|java|c|cpp|h|rb|php|xml|jsx|tsx|log|toml|ini|conf|env|vue|svelte|scss|sass|less)$/i;
579
+ const _importableFiles = [];
580
+ if (_pendingAttachInfo && documentModule) {
581
+ const rawFiles = fileHandlerModule.getPendingRaw ? fileHandlerModule.getPendingRaw() : [];
582
+ for (let i = 0; i < _pendingAttachInfo.length; i++) {
583
+ const att = _pendingAttachInfo[i];
584
+ if (IMPORTABLE_EXT.test(att.name) && rawFiles[i]) {
585
+ _importableFiles.push({ info: att, file: rawFiles[i] });
586
+ }
587
+ }
588
+ }
589
+ let _userMsgEl = null;
590
+ if (!skipBubble) {
591
+ _userMsgEl = addMessage('user', userDisplay, null, _pendingAttachInfo ? { attachments: _pendingAttachInfo } : null);
592
+ }
593
+ messageInput.value = '';
594
+ messageInput.style.height = '';
595
+ messageInput.dispatchEvent(new Event('input'));
596
+ // Mobile: dismiss the on-screen keyboard after sending. iOS in
597
+ // particular ignores a bare blur() in some cases (or some other
598
+ // listener refocuses straight after), so we temporarily mark the
599
+ // input readonly which forces the keyboard to retract, then blur,
600
+ // then drop the readonly attribute after the keyboard is gone so
601
+ // typing still works for the next message.
602
+ if (window.innerWidth <= 768) {
603
+ try {
604
+ messageInput.setAttribute('readonly', 'readonly');
605
+ messageInput.blur();
606
+ const _dropReadonly = () => { try { messageInput.removeAttribute('readonly'); } catch {} };
607
+ setTimeout(() => {
608
+ // If the blur stuck, the input is no longer the active element —
609
+ // safe to drop readonly now so the next message can be typed.
610
+ // If it did NOT stick (some mobile browsers keep the textarea
611
+ // focused after a programmatic blur), removing readonly here would
612
+ // re-summon the keyboard mid-stream — the "bounce up" that then
613
+ // lingers until the end-of-stream blur. In that case keep readonly
614
+ // on (keyboard stays down) and drop it the moment the user taps to
615
+ // type again, so typing still works without the bounce.
616
+ if (document.activeElement === messageInput) {
617
+ messageInput.addEventListener('pointerdown', _dropReadonly, { once: true });
618
+ messageInput.addEventListener('focus', _dropReadonly, { once: true });
619
+ } else {
620
+ _dropReadonly();
621
+ }
622
+ }, 120);
623
+ } catch {}
624
+ }
625
+
626
+ let ids = [];
627
+ try {
628
+ ids = await fileHandlerModule.uploadPending();
629
+ } catch(e) {
630
+ console.error('upload failed', e);
631
+ }
632
+
633
+ // Carry over the original message's file-ids on a regenerate so the new
634
+ // send still references the same photos / docs (and picks up the user's
635
+ // edited OCR text via the server-side .vision cache). Always CONSUME the
636
+ // slot — even when empty / errored — so the regen ids can't bleed into
637
+ // an unrelated next message if uploadPending() above had thrown.
638
+ if (_pendingRegenAttachments && _pendingRegenAttachments.length) {
639
+ ids = ids.concat(_pendingRegenAttachments);
640
+ }
641
+ _pendingRegenAttachments = null;
642
+
643
+ // The optimistic user bubble was rendered before the upload assigned ids,
644
+ // so image previews couldn't show (the renderer needs att.id). Now that
645
+ // the upload resolved, stamp the ids — plus width/height for images so
646
+ // the skeleton can size itself to the photo's aspect ratio — and
647
+ // re-render so the thumbnail appears live, no refresh needed.
648
+ if (_userMsgEl && _pendingAttachInfo && ids.length) {
649
+ const _meta = fileHandlerModule.getLastUploadedMeta?.() || [];
650
+ for (let i = 0; i < _pendingAttachInfo.length && i < ids.length; i++) {
651
+ _pendingAttachInfo[i].id = ids[i];
652
+ const _m = _meta[i];
653
+ if (_m) {
654
+ if (_m.width) _pendingAttachInfo[i].width = _m.width;
655
+ if (_m.height) _pendingAttachInfo[i].height = _m.height;
656
+ }
657
+ }
658
+ chatRenderer.updateMessageAttachments(_userMsgEl, _pendingAttachInfo);
659
+ }
660
+
661
+ // Offer to import text files to document library
662
+ if (_importableFiles.length > 0) {
663
+ const existing = document.getElementById('import-prompt-banner');
664
+ if (existing) existing.remove();
665
+ const banner = document.createElement('div');
666
+ banner.id = 'import-prompt-banner';
667
+ banner.className = 'import-prompt-banner';
668
+ const label = _importableFiles.length === 1
669
+ ? `Import "${_importableFiles[0].info.name}" to document library?`
670
+ : `Import ${_importableFiles.length} files to document library?`;
671
+ const textEl = document.createElement('span');
672
+ textEl.textContent = label;
673
+ banner.appendChild(textEl);
674
+ const importBtn = document.createElement('button');
675
+ importBtn.textContent = 'Import';
676
+ importBtn.addEventListener('click', async () => {
677
+ importBtn.disabled = true;
678
+ importBtn.textContent = 'Importing…';
679
+ const EXT_LANG = {'.py':'python','.js':'javascript','.ts':'typescript','.html':'html','.css':'css','.md':'markdown','.json':'json','.yml':'yaml','.yaml':'yaml','.sh':'bash','.sql':'sql','.rs':'rust','.go':'go','.java':'java','.c':'c','.cpp':'cpp','.rb':'ruby','.php':'php','.xml':'xml','.jsx':'javascript','.tsx':'typescript'};
680
+ let imported = 0;
681
+ for (const { info, file } of _importableFiles) {
682
+ try {
683
+ const content = await file.text();
684
+ const dotIdx = info.name.lastIndexOf('.');
685
+ const title = dotIdx > 0 ? info.name.slice(0, dotIdx) : info.name;
686
+ const ext = dotIdx >= 0 ? info.name.slice(dotIdx).toLowerCase() : '';
687
+ await fetch(`${API_BASE}/api/document`, {
688
+ method: 'POST',
689
+ headers: { 'Content-Type': 'application/json' },
690
+ body: JSON.stringify({ title, language: EXT_LANG[ext] || '', content }),
691
+ });
692
+ imported++;
693
+ } catch (e) { console.error('Import failed:', info.name, e); }
694
+ }
695
+ banner.textContent = `Imported ${imported} file${imported !== 1 ? 's' : ''}`;
696
+ setTimeout(() => banner.remove(), 2000);
697
+ });
698
+ banner.appendChild(importBtn);
699
+ const dismissBtn = document.createElement('button');
700
+ dismissBtn.textContent = '\u00d7';
701
+ dismissBtn.className = 'import-prompt-dismiss';
702
+ dismissBtn.addEventListener('click', () => banner.remove());
703
+ banner.appendChild(dismissBtn);
704
+ const chatBar = document.getElementById('chat-bar');
705
+ if (chatBar) chatBar.parentNode.insertBefore(banner, chatBar);
706
+ // Auto-dismiss after 15 seconds
707
+ setTimeout(() => { if (banner.parentNode) banner.remove(); }, 15000);
708
+ }
709
+
710
+ // Auto-save document editor content before sending so the AI sees latest text
711
+ if (documentModule && documentModule.isPanelOpen() && documentModule.getCurrentDocId()) {
712
+ try { await documentModule.saveDocument(); } catch(e) { console.warn('doc auto-save failed', e); }
713
+ }
714
+
715
+ // Inject document selection context if present
716
+ let finalMsg = msg;
717
+ if (docSel) {
718
+ const sels = Array.isArray(docSel) ? docSel : [docSel];
719
+ if (sels.length === 1) {
720
+ const s = sels[0];
721
+ const lineRef = s.startLine === s.endLine ? `line ${s.startLine}` : `lines ${s.startLine}-${s.endLine}`;
722
+ finalMsg = `In the document, edit this specific text (${lineRef}):\n\`\`\`\n${s.text}\n\`\`\`\n\nInstruction: ${msg}`;
723
+ } else {
724
+ const parts = sels.map((s, i) => {
725
+ const lineRef = s.startLine === s.endLine ? `line ${s.startLine}` : `lines ${s.startLine}-${s.endLine}`;
726
+ return `Selection ${i + 1} (${lineRef}):\n\`\`\`\n${s.text}\n\`\`\``;
727
+ });
728
+ finalMsg = `In the document, edit these specific sections:\n\n${parts.join('\n\n')}\n\nInstruction: ${msg}`;
729
+ }
730
+ }
731
+
732
+ // Apply inject prefix/suffix
733
+ const _inject = presetsModule.getInject ? presetsModule.getInject() : { prefix: '', suffix: '' };
734
+ let _finalMsgWithInject = finalMsg;
735
+ if (_inject.prefix) _finalMsgWithInject = _inject.prefix + ' ' + _finalMsgWithInject;
736
+ if (_inject.suffix) _finalMsgWithInject = _finalMsgWithInject + ' ' + _inject.suffix;
737
+
738
+ const fd = new FormData();
739
+ fd.append('message', _finalMsgWithInject);
740
+ fd.append('session', streamSessionId);
741
+ if (ids.length) fd.append('attachments', JSON.stringify(ids));
742
+ // Auto-save & send active doc ID so the backend sees latest content
743
+ if (documentModule && documentModule.isPanelOpen() && documentModule.getCurrentDocId()) {
744
+ try { await documentModule.saveDocument({ silent: true }); } catch (_e) { /* best-effort */ }
745
+ fd.append('active_doc_id', documentModule.getCurrentDocId());
746
+ }
747
+ // Web toggle: pre-search in Chat mode, tool permission in Agent mode
748
+ const toggleState = Storage.loadToggleState();
749
+ let isAgentMode = (toggleState.mode || 'chat') === 'agent';
750
+ // Auto-escalate to agent mode when a document is open — the user expects
751
+ // the AI to see the document and have tools to edit it
752
+ if (!isAgentMode && documentModule && documentModule.isPanelOpen() && documentModule.getCurrentDocId()) {
753
+ isAgentMode = true;
754
+ }
755
+ fd.append('mode', isAgentMode ? 'agent' : 'chat');
756
+ if (el('web-toggle').checked) {
757
+ if (isAgentMode) {
758
+ fd.append('allow_web_search', 'true');
759
+ } else {
760
+ fd.append('use_web', 'true');
761
+ }
762
+ }
763
+ if (el('research-toggle').checked) {
764
+ fd.append('use_research', 'true');
765
+ // Research always runs in chat mode — override agent if set
766
+ fd.set('mode', 'chat');
767
+ }
768
+ if (el('bash-toggle').checked) {
769
+ fd.append('allow_bash', 'true');
770
+ }
771
+ const ragChk = el('rag-toggle');
772
+ if (ragChk && !ragChk.checked) {
773
+ fd.append('use_rag', 'false');
774
+ }
775
+ const incognitoChk = el('incognito-toggle');
776
+ if (incognitoChk && incognitoChk.checked) {
777
+ fd.append('incognito', 'true');
778
+ }
779
+ if (presetsModule.getSelectedPreset()) {
780
+ fd.append('preset_id', presetsModule.getSelectedPreset());
781
+ }
782
+
783
+
784
+ const abortCtrl = new AbortController();
785
+ abortCtrl._reason = '';
786
+ currentAbort = abortCtrl;
787
+
788
+ const _tState = Storage.loadToggleState();
789
+ const _isAgent = (_tState.mode || 'chat') === 'agent';
790
+
791
+ // Timeout: 6 min for research and agent mode, 3 min otherwise
792
+ const timeoutMs = el('research-toggle').checked || _isAgent ? RESEARCH_TIMEOUT_MS : DEFAULT_TIMEOUT_MS;
793
+ const timeoutId = setTimeout(() => {
794
+ if (!abortCtrl.signal.aborted) {
795
+ timedOut = true;
796
+ abortCtrl._reason = 'timeout';
797
+ abortCtrl.abort();
798
+ }
799
+ }, timeoutMs);
800
+
801
+ const box = el('chat-history');
802
+ holder = document.createElement('div');
803
+ holder.className = 'msg msg-ai streaming';
804
+
805
+ // Track holder globally so stop button can access it
806
+ currentHolder = holder;
807
+ holder._researchQuery = msg; // Store query for notification text
808
+
809
+ const modelName = sessionModule.getCurrentModel() || null;
810
+
811
+ let loadingText = 'Initializing...';
812
+
813
+ if (el('web-toggle').checked && !_isAgent) {
814
+ const _searchLabel = searchModule ? searchModule.getProviderLabel() : 'web';
815
+ loadingText = `Searching via ${_searchLabel}...<br>
816
+ <span style="font-size: 0.9em; opacity: 0.8;">
817
+ Query: "${msg.substring(0, 50)}${msg.length > 50 ? '...' : ''}"<br>
818
+ Fetching top results...</span>`;
819
+ } else if (el('research-toggle').checked) {
820
+ loadingText = 'Deep research mode active...';
821
+ } else {
822
+ loadingText = 'Processing request...';
823
+ }
824
+
825
+ var roleLabel = _shortModel(modelName);
826
+ var _charNameInit = presetsModule.getCharacterName ? presetsModule.getCharacterName() : '';
827
+ if (_charNameInit) roleLabel = _charNameInit;
828
+ const roleTs = new Date().toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
829
+ holder.innerHTML = `<div class="role">${roleLabel} <span class="role-timestamp">${roleTs}</span></div><div class="body"></div>`;
830
+ _applyModelColor(holder.querySelector('.role'), modelName);
831
+ holder.style.position = 'relative';
832
+
833
+ // Create spinner
834
+ spinner = spinnerModule.create('Initializing', 'right', 'wave');
835
+ currentSpinner = spinner;
836
+ const bodyDiv = holder.querySelector('.body');
837
+ bodyDiv.appendChild(spinner.createElement());
838
+ spinner.start();
839
+
840
+ // Update spinner message based on mode
841
+ if (el('web-toggle').checked && !_isAgent) {
842
+ spinner.updateMessage('Searching web with ' + (searchModule ? searchModule.getProviderLabel() : 'SearXNG'));
843
+ setTimeout(() => spinner.updateMessage('Processing results'), 1500);
844
+ } else if (el('research-toggle').checked) {
845
+ spinner.updateMessage('Researching');
846
+ setTimeout(() => spinner.updateMessage('Analyzing sources'), 1500);
847
+ } else {
848
+ spinner.updateMessage('Processing request');
849
+ const endpointUrlForProbe = sessionModule.getCurrentEndpointUrl ? sessionModule.getCurrentEndpointUrl() : null;
850
+ if (endpointUrlForProbe && modelName) {
851
+ processingProbeTimer = setTimeout(async () => {
852
+ processingProbeTimer = null;
853
+ if (accumulated || !spinner || !spinner.element || (currentAbort && currentAbort.signal.aborted)) return;
854
+ processingProbeAbort = new AbortController();
855
+ try {
856
+ spinner.updateMessage('Checking model endpoint');
857
+ const status = await _probeCurrentEndpointStatus(endpointUrlForProbe, processingProbeAbort.signal);
858
+ if (accumulated || !spinner || !spinner.element || (currentAbort && currentAbort.signal.aborted)) return;
859
+ if (!status) {
860
+ spinner.updateMessage('Still waiting for model');
861
+ } else if (status.alive) {
862
+ const latency = status.latency_ms ? ` (${status.latency_ms}ms)` : '';
863
+ spinner.updateMessage(`Endpoint online${latency}; waiting for first token`);
864
+ } else {
865
+ // Probe confirms the endpoint isn't responding. Don't
866
+ // sit on a hung fetch — give the user 5s to read the
867
+ // status, then auto-abort with reason='offline' so the
868
+ // catch handler shows a clean "switch model" message
869
+ // instead of leaving the spinner spinning forever.
870
+ if (status.error) console.warn('Model endpoint probe failed:', status.error);
871
+ let _countdown = 5;
872
+ spinner.updateMessage(`Endpoint offline — cancelling in ${_countdown}s`);
873
+ const _tick = setInterval(() => {
874
+ _countdown--;
875
+ if (!spinner || !spinner.element || (currentAbort && currentAbort.signal.aborted) || accumulated) {
876
+ clearInterval(_tick);
877
+ return;
878
+ }
879
+ if (_countdown > 0) {
880
+ spinner.updateMessage(`Endpoint offline — cancelling in ${_countdown}s`);
881
+ } else {
882
+ clearInterval(_tick);
883
+ if (currentAbort && !currentAbort.signal.aborted) {
884
+ currentAbort._reason = 'offline';
885
+ currentAbort.abort();
886
+ }
887
+ }
888
+ }, 1000);
889
+ }
890
+ } catch (e) {
891
+ if (e && e.name !== 'AbortError' && spinner && spinner.element && !accumulated) {
892
+ spinner.updateMessage('Still waiting for model');
893
+ }
894
+ } finally {
895
+ processingProbeAbort = null;
896
+ }
897
+ }, 10000);
898
+ }
899
+ }
900
+
901
+ const researchBtn = el('research-toggle-btn');
902
+ if (el('research-toggle').checked && researchBtn) {
903
+ researchBtn.disabled = true;
904
+ researchBtn.classList.remove('active');
905
+ }
906
+ box.appendChild(holder);
907
+ uiModule.scrollHistory();
908
+
909
+ const enableResearchBtn = () => {
910
+ if (!researchBtn) return;
911
+ researchBtn.disabled = false;
912
+ researchBtn.classList.toggle('active', el('research-toggle').checked);
913
+ };
914
+
915
+ if (el('research-toggle').checked && researchBtn) {
916
+ researchBtn.style.display = 'none';
917
+ // Uncheck research toggle so follow-up messages don't trigger another research
918
+ el('research-toggle').checked = false;
919
+ }
920
+
921
+ // User's current UTC offset in minutes (east of UTC). Threaded into
922
+ // the agent so natural-language times like "today at 9pm" are
923
+ // interpreted in YOUR timezone, not the server's.
924
+ const _tzOffsetMin = -new Date().getTimezoneOffset();
925
+ const res = await fetch(`${API_BASE}/api/chat_stream`, {
926
+ method: 'POST',
927
+ body: fd,
928
+ headers: { 'X-Tz-Offset': String(_tzOffsetMin) },
929
+ signal: abortCtrl.signal
930
+ });
931
+
932
+ clearTimeout(timeoutId);
933
+
934
+ if (!res.ok) {
935
+ if (res.status === 404) {
936
+ // Session was deleted (e.g. by AI) — reload and go to welcome
937
+ holder.remove();
938
+ if (sessionModule) await sessionModule.loadSessions();
939
+ return;
940
+ }
941
+ let errText = `Error ${res.status}`;
942
+ try {
943
+ const errBody = await res.text();
944
+ // Parse nested JSON error if present
945
+ const m = errBody.match(/"message"\s*:\s*"([^"]+)"/);
946
+ if (m) errText = m[1].replace(/\\"/g, '"');
947
+ else if (errBody.length < 200) errText = errBody;
948
+ } catch {}
949
+ // Auto-switch to chat mode for tool-related errors
950
+ if (errText.includes('tool') || errText.includes('auto')) {
951
+ errText = 'This model doesn\'t support agent tools — switched to Chat mode. Try again.';
952
+ const _ab = document.getElementById('mode-agent-btn');
953
+ const _cb = document.getElementById('mode-chat-btn');
954
+ if (_ab && _cb) {
955
+ _ab.classList.remove('active');
956
+ _cb.classList.add('active');
957
+ const _toggle = _ab.closest('.mode-toggle');
958
+ if (_toggle) _toggle.classList.add('mode-chat');
959
+ }
960
+ if (typeof Storage !== 'undefined' && Storage.KEYS) {
961
+ const _st = Storage.getJSON(Storage.KEYS.TOGGLES, {});
962
+ _st.mode = 'chat';
963
+ Storage.setJSON(Storage.KEYS.TOGGLES, _st);
964
+ }
965
+ }
966
+ typewriterInto(holder.querySelector('.body'), errText);
967
+ enableResearchBtn();
968
+ return;
969
+ }
970
+
971
+ // Mark the chat log busy while streaming so screen readers wait for the
972
+ // settled response instead of announcing every token. Cleared in finally.
973
+ const _chatLog = document.getElementById('chat-history');
974
+ if (_chatLog) _chatLog.setAttribute('aria-busy', 'true');
975
+
976
+ const reader = res.body.getReader();
977
+ const decoder = new TextDecoder();
978
+ let buffer = '';
979
+ let metrics = null;
980
+ let isThinking = false;
981
+ let thinkingStartTime = null;
982
+ // Streaming TTS: synthesize sentence-by-sentence during streaming
983
+ const streamingTTS = !!(window.aiTTSManager && window.aiTTSManager.autoPlay && window.aiTTSManager.available);
984
+ if (streamingTTS) window.aiTTSManager.streamingStart();
985
+ // Multi-bubble agent tracking
986
+ let roundHolder = holder; // Current AI text bubble (changes per round)
987
+ let roundText = ''; // Text accumulated for current round
988
+ let currentToolBubble = null; // Current tool execution bubble
989
+ let roundFinalized = false; // Whether current round's text is finalized
990
+ let _sourcesHtml = ''; // Sources box HTML to prepend to body
991
+ let _sourcesExpanded = false; // Track if user expanded sources during stream
992
+ let _sourcesData = null; // Raw sources data for rebuilding
993
+ let _sourcesType = ''; // 'web' or 'research'
994
+ let _findingsData = null; // Raw findings data for collapsible box
995
+ // _keepResearchOn removed — clarification state now persisted server-side via DB mode
996
+ // Insert sources box as a stable DOM node that won't be replaced during streaming.
997
+ // Returns the content container to use for innerHTML updates.
998
+ function _ensureStreamLayout(body) {
999
+ if (!body) return body;
1000
+ // Sources are deferred to final render — don't insert during streaming
1001
+ // Ensure a stable content div exists for text content
1002
+ var contentDiv = body.querySelector('.stream-content');
1003
+ if (!contentDiv) {
1004
+ contentDiv = document.createElement('div');
1005
+ contentDiv.className = 'stream-content';
1006
+ body.appendChild(contentDiv);
1007
+ }
1008
+ return contentDiv;
1009
+ }
1010
+ const esc = uiModule.esc;
1011
+ // Remove thinking spinner helper
1012
+ _removeThinkingSpinner = () => {
1013
+ const el = document.querySelector('.agent-thinking-dots');
1014
+ if (el) {
1015
+ if (el._spinner) el._spinner.destroy();
1016
+ el.remove();
1017
+ }
1018
+ };
1019
+
1020
+ // Tool-aware thinking spinner
1021
+ let _lastToolName = '';
1022
+ const _searchIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" style="vertical-align:-2px;margin-right:4px"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>';
1023
+ const _toolLabels = {
1024
+ 'web_search': _searchIcon + 'Searching',
1025
+ 'bash': 'Running',
1026
+ 'python': 'Running',
1027
+ 'create_document': 'Writing',
1028
+ 'update_document': 'Writing',
1029
+ 'read_document': 'Reading',
1030
+ 'edit_file': 'Editing',
1031
+ 'read_file': 'Reading',
1032
+ 'write_file': 'Writing',
1033
+ 'list_files': 'Browsing',
1034
+ 'image_gen': 'Generating',
1035
+ 'generate_image': 'Generating',
1036
+ 'manage_memory': 'Remembering',
1037
+ 'save_memory': 'Remembering',
1038
+ 'search_memory': 'Recalling',
1039
+ 'manage_session': 'Organizing',
1040
+ 'deep_research': 'Researching',
1041
+ 'list_models': 'Browsing',
1042
+ 'ui_control': 'Adjusting',
1043
+ };
1044
+ function _thinkingLabel() {
1045
+ if (!_lastToolName) {
1046
+ return 'Thinking';
1047
+ }
1048
+ // Check exact match first, then prefix match
1049
+ const lower = _lastToolName.toLowerCase();
1050
+ if (_toolLabels[lower]) return _toolLabels[lower];
1051
+ for (const [key, label] of Object.entries(_toolLabels)) {
1052
+ if (lower.includes(key) || key.includes(lower)) return label;
1053
+ }
1054
+ return 'Thinking';
1055
+ }
1056
+
1057
+ function _showThinkingSpinner(label) {
1058
+ if (document.querySelector('.agent-thinking-dots')) return;
1059
+ const _thinkMsg = document.createElement('div');
1060
+ _thinkMsg.className = 'msg msg-ai agent-thinking-dots';
1061
+ const _thinkBody = document.createElement('div');
1062
+ _thinkBody.className = 'body';
1063
+ const _ts = spinnerModule.create(label || 'Thinking', 'right', 'wave');
1064
+ _thinkBody.appendChild(_ts.createElement());
1065
+ _ts.start(120);
1066
+ _thinkMsg._spinner = _ts;
1067
+ _thinkMsg.appendChild(_thinkBody);
1068
+ document.getElementById('chat-history').appendChild(_thinkMsg);
1069
+ uiModule.scrollHistory();
1070
+ }
1071
+
1072
+ // Auto-show thinking spinner after text stops streaming
1073
+ let _textPauseTimer = null;
1074
+ function _scheduleThinkingSpinner() {
1075
+ if (_textPauseTimer) clearTimeout(_textPauseTimer);
1076
+ _textPauseTimer = setTimeout(() => {
1077
+ if (!document.querySelector('.agent-thinking-dots') && isStreaming) {
1078
+ _showThinkingSpinner(_thinkingLabel());
1079
+ }
1080
+ }, 400);
1081
+ }
1082
+ _cancelThinkingTimer = () => {
1083
+ if (_textPauseTimer) { clearTimeout(_textPauseTimer); _textPauseTimer = null; }
1084
+ };
1085
+
1086
+ // Document streaming state (text-fence detection)
1087
+ let _docFenceOpened = false;
1088
+ let _docFenceContentStart = -1;
1089
+ let _liveThinkSection = null;
1090
+ let _liveThinkContent = null;
1091
+ let _liveThinkInner = null;
1092
+ let _liveThinkHeader = null;
1093
+ let _liveThinkSpinnerSlot = null;
1094
+ let _liveThinkTimerEl = null;
1095
+ let _liveThinkToggle = null;
1096
+ let _liveThinkDomId = null;
1097
+
1098
+ // Offscreen measurement div — reused across renders
1099
+ let _measureDiv = null;
1100
+
1101
+ function _replyAfterClosedThinking(text) {
1102
+ const closeRe = /<\/think(?:ing)?>/gi;
1103
+ let match = null;
1104
+ let last = null;
1105
+ while ((match = closeRe.exec(text || '')) !== null) last = match;
1106
+ if (!last) return '';
1107
+ return (text || '').slice(last.index + last[0].length).trimStart();
1108
+ }
1109
+
1110
+ // Direct render helper for streaming text
1111
+ _renderStream = () => {
1112
+ let dt = stripToolBlocks(roundText);
1113
+ const bodyEl = roundHolder.querySelector('.body');
1114
+ const contentEl = _ensureStreamLayout(bodyEl);
1115
+
1116
+ // If thinking was already collapsed in-place, only render the reply portion
1117
+ let liveReply = contentEl.querySelector('.live-reply-content');
1118
+ if (liveReply) {
1119
+ // Extract reply text — handle native <think> tags and non-tag patterns
1120
+ const closedThinkReply = _replyAfterClosedThinking(dt);
1121
+ const { thinkingBlocks, content: replyText } = closedThinkReply
1122
+ ? { thinkingBlocks: [''], content: closedThinkReply }
1123
+ : markdownModule.extractThinkingBlocks(dt);
1124
+ let replyTrimmed = '';
1125
+ if (thinkingBlocks.length) {
1126
+ replyTrimmed = (replyText || '').trim();
1127
+ } else {
1128
+ // Non-tag: check for garbled <think> (reasoning\n<think>reply)
1129
+ const _gm = dt.match(/^[\s\S]+?<think(?:ing)?>\s*([\s\S]*?)(?:<\/think(?:ing)?>)?\s*$/i);
1130
+ if (_gm && _gm[1].trim()) {
1131
+ replyTrimmed = _gm[1].trim();
1132
+ } else {
1133
+ // Pure non-tag: find reply boundary
1134
+ const _rPrefixes = markdownModule.startsWithReasoningPrefix;
1135
+ const _rpStarts = ['Hey', 'Hi ', 'Hi!', 'Hello', 'Sure', 'Yes', 'No ', 'No,', 'Yo', 'OK', 'Here', 'Absolutely', 'Of course', 'Great', 'Alright', 'Thanks', 'Welcome', 'Good ', "I'm happy", "I'd be"];
1136
+ const _rt = (replyText || '').trimStart();
1137
+ if (_rPrefixes(_rt)) {
1138
+ const _rLines = _rt.split('\n');
1139
+ for (let _ri = 1; _ri < _rLines.length; _ri++) {
1140
+ const _rl = _rLines[_ri].trim();
1141
+ if (!_rl) continue;
1142
+ if (_rpStarts.some(rp => _rl.startsWith(rp))) { replyTrimmed = _rLines.slice(_ri).join('\n'); break; }
1143
+ }
1144
+ if (!replyTrimmed) {
1145
+ for (const rp of _rpStarts) {
1146
+ const rx = new RegExp('[.!?]\\s*(' + rp.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')');
1147
+ const m = rx.exec(_rt);
1148
+ if (m && m.index > 20) { replyTrimmed = _rt.slice(m.index + 1).trim(); break; }
1149
+ }
1150
+ }
1151
+ }
1152
+ }
1153
+ }
1154
+ if (replyTrimmed) {
1155
+ const replyHtml = markdownModule.mdToHtml(markdownModule.squashOutsideCode(replyTrimmed));
1156
+ const prevLen = liveReply._prevTextLen || 0;
1157
+ liveReply.innerHTML = replyHtml;
1158
+ _fadeNewTokens(liveReply, prevLen);
1159
+ liveReply._prevTextLen = liveReply.textContent.length;
1160
+ if (window.hljs) liveReply.querySelectorAll('pre code').forEach((b) => window.hljs.highlightElement(b));
1161
+ }
1162
+ // Reply empty or not — preserve thinking bar, don't fall through to full re-render
1163
+ uiModule.scrollHistory();
1164
+ return;
1165
+ }
1166
+
1167
+ const prevLen = contentEl._prevTextLen || 0;
1168
+ // If thinking is still streaming (unclosed <think>), show indicator instead of raw text
1169
+ if (markdownModule.hasUnclosedThinkTag && markdownModule.hasUnclosedThinkTag(dt)) {
1170
+ const thinkStart = dt.search(/<think(?:ing)?>/i);
1171
+ const thinkContent = dt.substring(thinkStart).replace(/<think(?:ing)?>/i, '').trim();
1172
+ const lines = thinkContent.split('\n').length;
1173
+ // Don't show beforeThink text during streaming — it'll appear in the final render
1174
+ // This prevents the "split into two" duplication
1175
+ contentEl.innerHTML =
1176
+ '<div class="thinking-section"><div class="thinking-header"><div class="thinking-header-left">Thinking' +
1177
+ (lines > 1 ? ` (${lines} lines)` : '') + '</div></div></div>';
1178
+ contentEl._prevTextLen = 0;
1179
+ uiModule.scrollHistory();
1180
+ return;
1181
+ }
1182
+ const html = markdownModule.processWithThinking(markdownModule.squashOutsideCode(dt));
1183
+
1184
+ // Smooth expand only for regular chat text (not thinking/agent blocks)
1185
+ const _hasThinking = html.includes('thinking-section');
1186
+ const _isAgentRound = roundHolder !== holder;
1187
+ if (!_hasThinking && !_isAgentRound) {
1188
+ // Render into offscreen clone to measure new height before swapping
1189
+ if (!_measureDiv) {
1190
+ _measureDiv = document.createElement('div');
1191
+ _measureDiv.style.cssText = 'position:absolute;visibility:hidden;pointer-events:none;z-index:-1;';
1192
+ }
1193
+ _measureDiv.style.width = contentEl.offsetWidth + 'px';
1194
+ _measureDiv.className = contentEl.className;
1195
+ _measureDiv.innerHTML = html;
1196
+ contentEl.parentNode.appendChild(_measureDiv);
1197
+ const measuredH = _measureDiv.offsetHeight;
1198
+ _measureDiv.remove();
1199
+ const curMin = parseFloat(contentEl.style.minHeight) || 0;
1200
+ contentEl.style.minHeight = Math.max(curMin, measuredH) + 'px';
1201
+ } else {
1202
+ contentEl.style.minHeight = '';
1203
+ }
1204
+
1205
+ contentEl.innerHTML = html;
1206
+ _fadeNewTokens(contentEl, prevLen);
1207
+ contentEl._prevTextLen = contentEl.textContent.length;
1208
+ if (window.hljs) contentEl.querySelectorAll('pre code').forEach((b) => window.hljs.highlightElement(b));
1209
+ uiModule.scrollHistory();
1210
+ };
1211
+
1212
+ // Walk text nodes, skip past `prevLen` characters of old text,
1213
+ // wrap everything after that in <span class="token-new"> for fade-in
1214
+ function _fadeNewTokens(container, prevLen) {
1215
+ if (!prevLen) return; // First chunk — skip, whole msg already has entrance anim
1216
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
1217
+ let charCount = 0;
1218
+ const toWrap = [];
1219
+ while (walker.nextNode()) {
1220
+ const node = walker.currentNode;
1221
+ const len = node.textContent.length;
1222
+ if (charCount + len <= prevLen) { charCount += len; continue; }
1223
+ const splitAt = charCount < prevLen ? prevLen - charCount : 0;
1224
+ toWrap.push({ node, splitAt });
1225
+ charCount += len;
1226
+ }
1227
+ for (const { node, splitAt } of toWrap) {
1228
+ const parent = node.parentNode;
1229
+ if (!parent || parent.closest('pre, .think-content')) continue;
1230
+ const target = splitAt > 0 ? node.splitText(splitAt) : node;
1231
+ const span = document.createElement('span');
1232
+ span.className = 'token-new';
1233
+ parent.replaceChild(span, target);
1234
+ span.appendChild(target);
1235
+ }
1236
+ }
1237
+
1238
+ let _nextIsError = false;
1239
+ let _streamSawDone = false;
1240
+
1241
+ while (true) {
1242
+ const { done, value } = await reader.read();
1243
+ _lastReaderActivity = Date.now();
1244
+ if (done) break;
1245
+
1246
+ buffer += decoder.decode(value, { stream: true });
1247
+ const lines = buffer.split('\n');
1248
+ buffer = lines.pop() || '';
1249
+
1250
+ for (const line of lines) {
1251
+ // Log SSE event types (e.g. "event: error") for debugging
1252
+ if (line.startsWith('event: ')) {
1253
+ const evtType = line.slice(7).trim();
1254
+ if (evtType === 'error') _nextIsError = true;
1255
+ continue;
1256
+ }
1257
+ if (line.startsWith('data: ')) {
1258
+ const data = line.slice(6);
1259
+
1260
+ // (thinking spinner removal is handled in agent_step / tool_start / content handlers)
1261
+
1262
+ // Background detection: are we on a different session?
1263
+ const _isBg = (sessionModule.getCurrentSessionId() !== streamSessionId);
1264
+
1265
+ // On first transition to background, store state in map
1266
+ if (_isBg && !_backgroundStreams.has(streamSessionId)) {
1267
+ _backgroundStreams.set(streamSessionId, {
1268
+ status: 'running',
1269
+ accumulated: accumulated,
1270
+ sourcesHtml: _sourcesHtml,
1271
+ findingsData: null,
1272
+ abortCtrl: currentAbort,
1273
+ query: streamQuery,
1274
+ metrics: null,
1275
+ });
1276
+ if (sessionModule && sessionModule.markStreaming) {
1277
+ sessionModule.markStreaming(streamSessionId);
1278
+ }
1279
+ }
1280
+
1281
+ if (data === '[DONE]') {
1282
+ _streamSawDone = true;
1283
+ // Always update background map if entry exists (even if user switched back)
1284
+ var bgDone = _backgroundStreams.get(streamSessionId);
1285
+ if (bgDone) {
1286
+ bgDone.status = 'completed';
1287
+ bgDone.accumulated = accumulated;
1288
+ if (_isBg) {
1289
+ try {
1290
+ _notifyStreamComplete(streamSessionId, streamQuery);
1291
+ _insertStreamDoneToast(streamSessionId, streamQuery);
1292
+ } catch (toastErr) {
1293
+ console.warn('[bg-stream] Toast/notification error:', toastErr);
1294
+ }
1295
+ }
1296
+ // CRITICAL: always mark stream complete for the sidebar dot
1297
+ try {
1298
+ if (sessionModule && sessionModule.markStreamComplete) {
1299
+ sessionModule.markStreamComplete(streamSessionId);
1300
+ }
1301
+ } catch (dotErr) {
1302
+ console.warn('[bg-stream] markStreamComplete error:', dotErr);
1303
+ }
1304
+ // Don't do foreground final render — the checkBackgroundStream poll
1305
+ // will detect 'completed' and reload history cleanly
1306
+ break;
1307
+ }
1308
+ // Force-close thinking if still open (model never output boundary)
1309
+ if (isThinking) {
1310
+ isThinking = false;
1311
+ cancelAnimationFrame(_thinkTimerRAF);
1312
+ var _elapsedDone = thinkingStartTime ? ((Date.now() - thinkingStartTime) / 1000).toFixed(1) : null;
1313
+ if (_elapsedDone) {
1314
+ accumulated = accumulated.replace(/<think>/i, '<think time="' + _elapsedDone + '">');
1315
+ roundText = roundText.replace(/<think>/i, '<think time="' + _elapsedDone + '">');
1316
+ }
1317
+ if (_liveThinkHeader) _liveThinkHeader.textContent = 'View thinking process';
1318
+ if (_liveThinkSpinnerSlot) _liveThinkSpinnerSlot.remove();
1319
+ if (_liveThinkTimerEl && _elapsedDone) {
1320
+ _liveThinkTimerEl.textContent = _elapsedDone + 's';
1321
+ _liveThinkTimerEl.style.marginLeft = 'auto';
1322
+ _liveThinkTimerEl.style.marginRight = '5px';
1323
+ var _hdrDone = _liveThinkTimerEl.closest('.thinking-header');
1324
+ // Keep the chevron furthest right with the timer to its left
1325
+ // (match the live + final-render layout) — insert before the
1326
+ // toggle rather than appending (which would land after it).
1327
+ if (_hdrDone) {
1328
+ if (_liveThinkToggle && _liveThinkToggle.parentElement === _hdrDone)
1329
+ _hdrDone.insertBefore(_liveThinkTimerEl, _liveThinkToggle);
1330
+ else _hdrDone.appendChild(_liveThinkTimerEl);
1331
+ }
1332
+ }
1333
+ // Assign stable IDs
1334
+ var _thinkIdDone = 'think-' + Date.now();
1335
+ var _liveHdrDone = _liveThinkSection && _liveThinkSection.querySelector('.thinking-header');
1336
+ if (_liveHdrDone) _liveHdrDone.dataset.thinkingId = _thinkIdDone;
1337
+ if (_liveThinkContent) _liveThinkContent.id = _thinkIdDone;
1338
+ if (_liveThinkToggle) _liveThinkToggle.id = _thinkIdDone + '-toggle';
1339
+ // Create live-reply container so final render preserves thinking bar
1340
+ var _streamElDone = _liveThinkSection ? _liveThinkSection.parentElement : roundHolder.querySelector('.stream-content');
1341
+ if (!_streamElDone) _streamElDone = roundHolder.querySelector('.body');
1342
+ if (_streamElDone && !_streamElDone.querySelector('.live-reply-content')) {
1343
+ var _replyElDone = document.createElement('div');
1344
+ _replyElDone.className = 'live-reply-content';
1345
+ _streamElDone.appendChild(_replyElDone);
1346
+ }
1347
+ }
1348
+ // Normal foreground completion — metrics will be displayed in the final render block below
1349
+ break;
1350
+ }
1351
+ try {
1352
+ const json = JSON.parse(data);
1353
+ // Handle SSE error events (e.g. HTTP 404 from provider)
1354
+ if (_nextIsError || json.status >= 400) {
1355
+ _nextIsError = false;
1356
+ const errMsg = json.text || json.error?.message || `Error ${json.status || 'unknown'}`;
1357
+ console.error('Stream error:', errMsg);
1358
+ if (spinner && spinner.element) spinner.destroy();
1359
+ typewriterInto(roundHolder.querySelector('.body'), errMsg);
1360
+ break;
1361
+ }
1362
+ if (json.delta || json.type === 'tool_start' || json.type === 'agent_step' || json.type === 'doc_stream_delta') {
1363
+ clearProcessingProbe();
1364
+ }
1365
+ if (json.delta) {
1366
+ _cancelThinkingTimer();
1367
+ _removeThinkingSpinner();
1368
+ // Text arrived after tools — connect thread line to this bubble
1369
+ const _threadAbove = roundHolder?.previousElementSibling;
1370
+ if (_threadAbove && _threadAbove.classList.contains('agent-thread') && !_threadAbove.classList.contains('has-bottom')) {
1371
+ _threadAbove.classList.add('has-bottom');
1372
+ }
1373
+ // VLLM reasoning tokens: wrap in <think> tags for the thinking UI.
1374
+ // Stateful open/close (not a whole-message substring check) so each round
1375
+ // of a multi-round agent response gets its own <think>…</think> — otherwise
1376
+ // only round 1 is wrapped and rounds 2+ reasoning leaks into the answer.
1377
+ let _delta = json.delta;
1378
+ if (json.thinking) {
1379
+ if (!_thinkOpen) { _delta = '<think>' + _delta; _thinkOpen = true; }
1380
+ } else if (_thinkOpen) {
1381
+ _delta = '</think>' + _delta; _thinkOpen = false;
1382
+ }
1383
+ const wasEmpty = !accumulated;
1384
+ accumulated += _delta;
1385
+ roundText += _delta;
1386
+ currentAccumulated = accumulated; // Update global tracker
1387
+ // First token arrived — switch stop button from processing to streaming
1388
+ if (wasEmpty && submitBtn && !_isBg) {
1389
+ submitBtn.dataset.phase = 'receiving';
1390
+ }
1391
+
1392
+ // Update background map if running in background
1393
+ if (_isBg) {
1394
+ var bgEntry = _backgroundStreams.get(streamSessionId);
1395
+ if (bgEntry) bgEntry.accumulated = accumulated;
1396
+ continue; // Skip all DOM writes
1397
+ }
1398
+
1399
+ // --- Text-fence doc streaming (for models that don't use native tool calls) ---
1400
+ if (!_docFenceOpened && documentModule && roundText.includes('```create_document\n')) {
1401
+ const fenceIdx = roundText.indexOf('```create_document\n');
1402
+ const afterFence = roundText.slice(fenceIdx + '```create_document\n'.length);
1403
+ const fenceLines = afterFence.split('\n');
1404
+ if (fenceLines.length >= 1 && fenceLines[0].trim()) {
1405
+ _docFenceOpened = true;
1406
+ const title = fenceLines[0].trim();
1407
+ // Keep in sync with backend _KNOWN_LANGS in src/tool_implementations.py
1408
+ const knownLangs = ['python','py','javascript','js','typescript','ts','html','css','json','yaml','bash','sql','rust','go','java','c','cpp','markdown','text','plain','ruby','swift','kotlin','php','email','csv','xml','toml','ini'];
1409
+ const isLang = fenceLines.length >= 2 && knownLangs.includes(fenceLines[1].trim().toLowerCase());
1410
+ const lang = isLang ? fenceLines[1].trim() : '';
1411
+ _docFenceContentStart = fenceIdx + '```create_document\n'.length + title.length + 1 + (isLang ? fenceLines[1].length + 1 : 0);
1412
+ documentModule.streamDocOpen(title, lang);
1413
+ }
1414
+ }
1415
+ if (_docFenceOpened && _docFenceContentStart > 0 && documentModule) {
1416
+ let raw = roundText.slice(_docFenceContentStart);
1417
+ const closeIdx = raw.indexOf('\n```');
1418
+ if (closeIdx >= 0) raw = raw.slice(0, closeIdx);
1419
+ documentModule.streamDocDelta(raw);
1420
+ }
1421
+
1422
+ // Detect thinking-in-progress:
1423
+ // 1. Normal: <think>...no closing tag yet
1424
+ // 2. Malformed: <think></think>\n...text but no second </think> yet
1425
+ // 3. Qwen3.5: "Thinking Process:" without <think> tags
1426
+ let hasUnclosedThink = markdownModule.hasUnclosedThinkTag(roundText);
1427
+ // Detect non-tag thinking patterns: "Thinking:", "Thinking Process:", Gemma-style reasoning
1428
+ // These patterns don't use <think> tags, so we simulate unclosed thinking during streaming
1429
+ const _replyPrefixes = ['Hey', 'Hi ', 'Hi!', 'Hello', 'Sure', 'Yes', 'No ', 'No,', 'Yo', 'OK', 'Here', 'Absolutely', 'Of course', 'Great', 'Alright', 'Thanks', 'Welcome', 'Good ', "I'm happy", "I'd be"];
1430
+ if (!hasUnclosedThink && !roundText.includes('<think')) {
1431
+ const _trimmedRT = roundText.trimStart();
1432
+ const _isReasoning = markdownModule.startsWithReasoningPrefix(_trimmedRT);
1433
+ if (_isReasoning) {
1434
+ // Check if we can see a reply boundary yet (newline then reply pattern)
1435
+ const _lines = _trimmedRT.split('\n');
1436
+ let _replyFound = false;
1437
+ for (let li = 1; li < _lines.length; li++) {
1438
+ const _l = _lines[li].trim();
1439
+ if (!_l) continue;
1440
+ if (_replyPrefixes.some(rp => _l.startsWith(rp))) {
1441
+ _replyFound = true;
1442
+ break;
1443
+ }
1444
+ }
1445
+ if (!_replyFound) {
1446
+ // Also check within-line: "reasoning text.Reply text"
1447
+ const _inlineReply = _replyPrefixes.some(rp => {
1448
+ const rx = new RegExp('[.!?]\\s*' + rp.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
1449
+ const m = rx.exec(_trimmedRT);
1450
+ return m && m.index > 20;
1451
+ });
1452
+ if (!_inlineReply) hasUnclosedThink = true;
1453
+ }
1454
+ }
1455
+ }
1456
+ if (!hasUnclosedThink && /^<think(?:ing)?>\s*<\/think(?:ing)?>/i.test(roundText)) {
1457
+ // Empty <think></think> — the model likely put thinking outside the tags
1458
+ const afterEmpty = roundText.replace(/^<think(?:ing)?>\s*<\/think(?:ing)?>/i, '').trim();
1459
+ const closeTags = (afterEmpty.match(/<\/think(?:ing)?>/gi) || []).length;
1460
+ if (closeTags === 0 && afterEmpty.length > 0) {
1461
+ hasUnclosedThink = true; // still waiting for real closing tag
1462
+ }
1463
+ }
1464
+ // Detect false close: <think>short</think> where real thinking follows untagged
1465
+ // Only applies when there's a second </think> later (model leaked thinking outside tags)
1466
+ // Do NOT trigger if the text after </think> contains tool calls (that's real content)
1467
+ if (!hasUnclosedThink && isThinking) {
1468
+ const _thinkMatch = roundText.match(/<think(?:ing)?>([\s\S]*?)<\/think(?:ing)?>/i);
1469
+ const _thinkLen = _thinkMatch ? _thinkMatch[1].trim().length : 0;
1470
+ if (_thinkLen < 20) {
1471
+ const _afterClose = roundText.replace(/<think(?:ing)?>([\s\S]*?)<\/think(?:ing)?>/i, '').trim();
1472
+ // Only keep waiting if there's trailing text that looks like thinking (not tool calls)
1473
+ const _hasToolCall = /```(?:bash|python|web_search|read_file|write_file|create_document|edit_document|manage_|generate_image)/i.test(_afterClose);
1474
+ const _hasOrphanClose = /<\/think(?:ing)?>/i.test(_afterClose);
1475
+ if (!_hasToolCall && (_hasOrphanClose || (Date.now() - thinkingStartTime) < 500)) {
1476
+ hasUnclosedThink = true; // keep waiting for real </think>
1477
+ }
1478
+ }
1479
+ }
1480
+
1481
+ if (hasUnclosedThink && !isThinking) {
1482
+ isThinking = true;
1483
+ thinkingStartTime = Date.now();
1484
+ if (spinner && spinner.element) spinner.destroy();
1485
+
1486
+ // Create a live thinking box — starts expanded so content streams visibly
1487
+ var thinkBody = roundHolder.querySelector('.body');
1488
+ var thinkContent = _ensureStreamLayout(thinkBody);
1489
+ thinkContent.style.minHeight = '';
1490
+ _liveThinkDomId = 'live-think-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8);
1491
+ thinkContent.innerHTML = `
1492
+ <div class="thinking-section">
1493
+ <div class="thinking-header" data-thinking-id="${_liveThinkDomId}">
1494
+ <div class="thinking-header-left"><span class="live-think-header-text">Thinking\u2026</span></div>
1495
+ <span class="live-think-spinner-slot" style="flex-shrink:0;margin-left:auto;"></span>
1496
+ <span class="live-think-timer" style="font-size:11px;opacity:0.4;font-variant-numeric:tabular-nums;margin-left:6px;margin-right:5px;"></span>
1497
+ <span class="thinking-toggle live-think-toggle" id="${_liveThinkDomId}-toggle"></span>
1498
+ </div>
1499
+ <div class="thinking-content" id="${_liveThinkDomId}">
1500
+ <div class="thinking-content-inner live-think-inner"></div>
1501
+ </div>
1502
+ </div>`;
1503
+ _liveThinkSection = thinkContent.querySelector('.thinking-section');
1504
+ _liveThinkContent = thinkContent.querySelector('.thinking-content');
1505
+ _liveThinkInner = thinkContent.querySelector('.live-think-inner');
1506
+ _liveThinkHeader = thinkContent.querySelector('.live-think-header-text');
1507
+ _liveThinkSpinnerSlot = thinkContent.querySelector('.live-think-spinner-slot');
1508
+ _liveThinkTimerEl = thinkContent.querySelector('.live-think-timer');
1509
+ _liveThinkToggle = thinkContent.querySelector('.live-think-toggle');
1510
+ // Live timer
1511
+ var _thinkTimerStart = Date.now();
1512
+ var _thinkTimerRAF = 0;
1513
+ function _tickThinkTimer() {
1514
+ if (!_liveThinkTimerEl || !_liveThinkTimerEl.isConnected) return;
1515
+ var s = ((Date.now() - _thinkTimerStart) / 1000).toFixed(1);
1516
+ _liveThinkTimerEl.textContent = s + 's';
1517
+ _thinkTimerRAF = requestAnimationFrame(_tickThinkTimer);
1518
+ }
1519
+ _thinkTimerRAF = requestAnimationFrame(_tickThinkTimer);
1520
+ // Whirlpool spinner
1521
+ if (_liveThinkSpinnerSlot) {
1522
+ var _wp = spinnerModule.createWhirlpool(12);
1523
+ _wp.element.style.margin = '0';
1524
+ _wp.element.style.width = '12px';
1525
+ _wp.element.style.height = '12px';
1526
+ _wp.element.style.transform = 'translateY(-1px)'; // align the whirlpool with the header text
1527
+ _liveThinkSpinnerSlot.appendChild(_wp.element);
1528
+ }
1529
+ } else if (hasUnclosedThink && isThinking) {
1530
+ if (_liveThinkInner) {
1531
+ // Extract raw thinking text (strip all <think>/<thinking> open/close tags and prefixes)
1532
+ var thinkText = roundText.replace(/<\/?think(?:ing)?>/gi, '');
1533
+ thinkText = thinkText.replace(/^\s*Thinking(?:\s+Process)?:\s*/i, '');
1534
+ _liveThinkInner.innerHTML = markdownModule.mdToHtml(thinkText);
1535
+ // Keep thinking box scrolled to bottom
1536
+ var thinkBox = _liveThinkInner.closest('.thinking-content');
1537
+ if (thinkBox) thinkBox.scrollTop = thinkBox.scrollHeight;
1538
+ }
1539
+ uiModule.scrollHistory();
1540
+ continue;
1541
+ } else if (!hasUnclosedThink && isThinking) {
1542
+ isThinking = false;
1543
+ var _thinkTextLen = _liveThinkInner ? _liveThinkInner.textContent.trim().length : 0;
1544
+
1545
+ // If thinking was trivially short (< 20 chars), remove the section entirely
1546
+ // Models sometimes emit <think>The</think> or similar noise
1547
+ if (_thinkTextLen < 20 && _liveThinkSection) {
1548
+ _liveThinkSection.remove();
1549
+ _liveThinkSection = null;
1550
+ _liveThinkContent = null;
1551
+ _liveThinkInner = null;
1552
+ _liveThinkHeader = null;
1553
+ _liveThinkSpinnerSlot = null;
1554
+ _liveThinkTimerEl = null;
1555
+ _liveThinkToggle = null;
1556
+ _liveThinkDomId = null;
1557
+ // Fall through to normal streaming
1558
+ if (spinner && spinner.element) spinner.destroy();
1559
+ _renderStream();
1560
+ _scheduleThinkingSpinner();
1561
+ continue;
1562
+ }
1563
+
1564
+ // Thinking ended — smooth transition: update header, pause, then collapse
1565
+ // Stop live timer and spinner
1566
+ cancelAnimationFrame(_thinkTimerRAF);
1567
+ var elapsed = thinkingStartTime ? ((Date.now() - thinkingStartTime) / 1000).toFixed(1) : null;
1568
+ // Embed thinking time in the <think> tag for persistence on reload
1569
+ if (elapsed) {
1570
+ accumulated = accumulated.replace(/<think>/i, '<think time="' + elapsed + '">');
1571
+ roundText = roundText.replace(/<think>/i, '<think time="' + elapsed + '">');
1572
+ }
1573
+ if (_liveThinkHeader) _liveThinkHeader.textContent = 'View thinking process';
1574
+ if (_liveThinkSpinnerSlot) _liveThinkSpinnerSlot.remove();
1575
+ // Move timer to right side of header
1576
+ if (_liveThinkTimerEl && elapsed) {
1577
+ _liveThinkTimerEl.textContent = elapsed + 's';
1578
+ _liveThinkTimerEl.style.marginLeft = 'auto';
1579
+ _liveThinkTimerEl.style.marginRight = '5px';
1580
+ var _hdrRow = _liveThinkTimerEl.closest('.thinking-header');
1581
+ // Chevron furthest right, timer to its left — insert before
1582
+ // the toggle (appending would put the timer after it).
1583
+ if (_hdrRow) {
1584
+ if (_liveThinkToggle && _liveThinkToggle.parentElement === _hdrRow)
1585
+ _hdrRow.insertBefore(_liveThinkTimerEl, _liveThinkToggle);
1586
+ else _hdrRow.appendChild(_liveThinkTimerEl);
1587
+ }
1588
+ }
1589
+
1590
+ // Assign stable IDs (for click-toggle handler in markdown.js)
1591
+ var _thinkId = 'think-' + Date.now();
1592
+ var _liveHdr = _liveThinkSection && _liveThinkSection.querySelector('.thinking-header');
1593
+ if (_liveHdr) _liveHdr.dataset.thinkingId = _thinkId;
1594
+ if (_liveThinkContent) _liveThinkContent.id = _thinkId;
1595
+ if (_liveThinkToggle) _liveThinkToggle.id = _thinkId + '-toggle';
1596
+
1597
+ // Append a container for the reply text that follows thinking
1598
+ var _streamEl = _liveThinkSection ? _liveThinkSection.parentElement : roundHolder.querySelector('.stream-content');
1599
+ if (!_streamEl) _streamEl = roundHolder.querySelector('.body');
1600
+ if (_streamEl) {
1601
+ var _replyEl = document.createElement('div');
1602
+ _replyEl.className = 'live-reply-content';
1603
+ _streamEl.appendChild(_replyEl);
1604
+ }
1605
+
1606
+ // Render any reply text that arrived with the closing </think> token
1607
+ _renderStream();
1608
+ } else {
1609
+ // Normal streaming
1610
+ if (spinner && spinner.element) spinner.destroy();
1611
+ _renderStream();
1612
+ _scheduleThinkingSpinner();
1613
+ // Feed streaming TTS with accumulated text
1614
+ if (streamingTTS) window.aiTTSManager.streamingUpdate(roundText);
1615
+ }
1616
+ } else if (json.type === 'research_progress') {
1617
+ if (_isBg) continue; // Skip DOM updates in background
1618
+ _researchingStreamIds.add(streamSessionId);
1619
+ // Highlight research button while running
1620
+ var _rToggle = document.getElementById('research-toggle-btn');
1621
+ if (_rToggle) _rToggle.classList.add('research-running');
1622
+ // Request notification permission on first research event
1623
+ if ('Notification' in window && Notification.permission === 'default') {
1624
+ Notification.requestPermission();
1625
+ }
1626
+ // Mark session as researching in sidebar
1627
+ var _rSid = sessionModule && sessionModule.getCurrentSessionId();
1628
+ if (_rSid && sessionModule.markResearching) sessionModule.markResearching(_rSid);
1629
+ const rp = json.data;
1630
+ // Start research timer + synapse on first progress event
1631
+ if (!_researchTimerEl && spinner && spinner.element) {
1632
+ _researchStartTime = rp.started_at ? rp.started_at * 1000 : Date.now();
1633
+ _researchAvgDuration = rp.avg_duration || null;
1634
+ _researchTimerEl = document.createElement('div');
1635
+ _researchTimerEl.className = 'research-timer';
1636
+ // Styles in .research-timer CSS class
1637
+ spinner.element.parentNode.insertBefore(_researchTimerEl, spinner.element.nextSibling);
1638
+ _researchTimerInterval = setInterval(() => {
1639
+ if (!_researchTimerEl) return;
1640
+ var elapsed = Math.floor((Date.now() - _researchStartTime) / 1000);
1641
+ var mm = String(Math.floor(elapsed / 60)).padStart(2, '0');
1642
+ var ss = String(elapsed % 60).padStart(2, '0');
1643
+ var txt = mm + ':' + ss;
1644
+ if (_researchAvgDuration) {
1645
+ var avgM = String(Math.floor(_researchAvgDuration / 60)).padStart(2, '0');
1646
+ var avgS = String(Math.round(_researchAvgDuration % 60)).padStart(2, '0');
1647
+ txt += ' / avg ' + avgM + ':' + avgS;
1648
+ }
1649
+ _researchTimerEl.textContent = txt;
1650
+ }, 1000);
1651
+ // Synapse visualization — insert right above the timer so
1652
+ // it sits between the spinner message and the timer line.
1653
+ try {
1654
+ _researchSynapse = createResearchSynapse(spinner.element.parentNode, {
1655
+ query: holder._researchQuery || rp.query || '',
1656
+ startedAt: _researchStartTime,
1657
+ });
1658
+ // Move it to live between spinner and timer
1659
+ if (_researchSynapse.element && _researchTimerEl) {
1660
+ spinner.element.parentNode.insertBefore(_researchSynapse.element, _researchTimerEl);
1661
+ }
1662
+ } catch (e) { console.warn('synapse init failed', e); }
1663
+ }
1664
+ if (_researchSynapse) {
1665
+ _researchSynapse.setPhase(rp.phase, rp);
1666
+ if (typeof rp.round === 'number') _researchSynapse.setRound(rp.round);
1667
+ if (typeof rp.total_sources === 'number') _researchSynapse.setSourceCount(rp.total_sources);
1668
+ if (rp.phase === 'error') _researchSynapse.complete();
1669
+ }
1670
+ if (spinner && spinner.element) {
1671
+ if (rp.phase === 'probing') {
1672
+ spinner.updateMessage(`Verifying model: ${rp.model || '?'}`);
1673
+ } else if (rp.phase === 'planning') {
1674
+ spinner.updateMessage('Analyzing question & planning research strategy');
1675
+ } else if (rp.phase === 'searching') {
1676
+ const q = rp.queries ? `${rp.queries} queries` : '';
1677
+ const s = rp.total_sources ? ` · ${rp.total_sources} sources` : '';
1678
+ spinner.updateMessage(`Round ${rp.round || '?'}: Searching${q ? ' (' + q + ')' : ''}${s}`);
1679
+ } else if (rp.phase === 'reading') {
1680
+ spinner.updateMessage(rp.title ? `Reading: ${rp.title}` : `Round ${rp.round || '?'}: Reading ${rp.new_sources || ''} pages · ${rp.total_sources || 0} sources total`);
1681
+ } else if (rp.phase === 'analyzing') {
1682
+ spinner.updateMessage(`Round ${rp.round || '?'}: Analyzing ${rp.total_findings || 0} findings`);
1683
+ } else if (rp.phase === 'writing') {
1684
+ spinner.updateMessage(`Writing report · ${rp.total_sources || 0} sources`);
1685
+ } else if (rp.phase === 'error') {
1686
+ spinner.updateMessage(rp.message || 'Search error');
1687
+ }
1688
+ }
1689
+ } else if (json.type === 'research_sources') {
1690
+ if (_isBg) {
1691
+ // Store sources HTML in background map
1692
+ if (json.data && json.data.length > 0) {
1693
+ _sourcesHtml = _buildSourcesBox(json.data, 'research');
1694
+ var bgE = _backgroundStreams.get(streamSessionId);
1695
+ if (bgE) bgE.sourcesHtml = _sourcesHtml;
1696
+ }
1697
+ // Clear researching indicator for this background session
1698
+ if (sessionModule && sessionModule.clearResearching) sessionModule.clearResearching(streamSessionId);
1699
+ continue;
1700
+ }
1701
+ // Research done — clean up timer, show sources box, then spinner for LLM response
1702
+ _clearResearchTimer();
1703
+ holder._researchSources = json.data;
1704
+ var _rSid2 = sessionModule && sessionModule.getCurrentSessionId();
1705
+ if (_rSid2 && sessionModule.clearResearching) sessionModule.clearResearching(_rSid2);
1706
+ if (json.data && json.data.length > 0) {
1707
+ _sourcesData = json.data; _sourcesType = 'research';
1708
+ _sourcesHtml = _buildSourcesBox(json.data, 'research');
1709
+ }
1710
+ if (document.hidden) {
1711
+ _notifyResearchComplete(_rSid2 || '', holder._researchQuery || '');
1712
+ }
1713
+ } else if (json.type === 'research_findings') {
1714
+ if (_isBg) {
1715
+ var bgEf = _backgroundStreams.get(streamSessionId);
1716
+ if (bgEf) bgEf.findingsData = json.data;
1717
+ continue;
1718
+ }
1719
+ if (json.data && json.data.length > 0) {
1720
+ _findingsData = json.data;
1721
+ }
1722
+ } else if (json.type === 'research_done') {
1723
+ // Research complete — reload session to show the persisted report
1724
+ _clearResearchTimer();
1725
+ if (sessionModule && sessionModule.clearResearching) {
1726
+ sessionModule.clearResearching(streamSessionId);
1727
+ }
1728
+ _researchingStreamIds.delete(streamSessionId);
1729
+ // Small delay then reload session history which includes the full report
1730
+ setTimeout(async () => {
1731
+ // Don't yank the user back to this chat if they've navigated
1732
+ // away (e.g. started a new chat) while research finished —
1733
+ // just refresh the sidebar so the report shows when they return.
1734
+ if (sessionModule.getCurrentSessionId && sessionModule.getCurrentSessionId() === streamSessionId) {
1735
+ await sessionModule.selectSession(streamSessionId);
1736
+ } else {
1737
+ await sessionModule.loadSessions();
1738
+ }
1739
+ }, 500);
1740
+ continue;
1741
+ } else if (json.type === 'web_sources') {
1742
+ if (_isBg) {
1743
+ if (json.data && json.data.length > 0) {
1744
+ _sourcesHtml = _buildSourcesBox(json.data, 'web');
1745
+ var bgE2 = _backgroundStreams.get(streamSessionId);
1746
+ if (bgE2) bgE2.sourcesHtml = _sourcesHtml;
1747
+ }
1748
+ continue;
1749
+ }
1750
+ // Web search done — store sources for final render (don't render mid-stream)
1751
+ holder._webSources = json.data;
1752
+ if (json.data && json.data.length > 0) {
1753
+ _sourcesData = json.data; _sourcesType = 'web';
1754
+ _sourcesHtml = _buildSourcesBox(json.data, 'web');
1755
+ }
1756
+ } else if (json.type === 'model_fallback') {
1757
+ // Model went offline — switched to fallback
1758
+ var _fbData = json.data || {};
1759
+ uiModule.showToast(
1760
+ `Model ${_fbData.old_model || '?'} offline — switched to ${_fbData.new_model || '?'}`,
1761
+ 5000
1762
+ );
1763
+ // Update the model picker to reflect the new model
1764
+ if (sessionModule && sessionModule.updateModelPicker) {
1765
+ sessionModule.updateModelPicker();
1766
+ }
1767
+ continue;
1768
+ } else if (json.type === 'model_info') {
1769
+ // Update role label with model name as soon as we know it
1770
+ if (!_isBg && holder) {
1771
+ const roleEl = holder.querySelector('.role');
1772
+ if (roleEl) {
1773
+ const tsSpan = roleEl.querySelector('.role-timestamp');
1774
+ var _modelLabel = _shortModel(json.model);
1775
+ if (json.suffix) {
1776
+ _modelLabel += ' (' + json.suffix + ')';
1777
+ holder._roleSuffix = json.suffix;
1778
+ }
1779
+ // Prepend character name if sent by server or set locally
1780
+ var _charName = json.character_name || (presetsModule.getCharacterName ? presetsModule.getCharacterName() : '');
1781
+ if (_charName) {
1782
+ _modelLabel = _charName;
1783
+ holder._characterName = _charName;
1784
+ }
1785
+ roleEl.textContent = _modelLabel + ' ';
1786
+ _applyModelColor(roleEl, json.model);
1787
+ if (tsSpan) roleEl.appendChild(tsSpan);
1788
+ }
1789
+ }
1790
+ } else if (json.type === 'fallback') {
1791
+ // The selected model failed and another provider answered. Make
1792
+ // it visible so a misconfigured provider is never silently
1793
+ // masked under the selected model's name.
1794
+ if (!_isBg) {
1795
+ var _selM = _shortModel(json.selected_model || '');
1796
+ var _ansM = _shortModel(json.answered_by || '');
1797
+ uiModule.showToast('⚠ ' + _selM + ' failed — answered by ' + _ansM, 6000);
1798
+ if (holder) {
1799
+ var _rEl = holder.querySelector('.role');
1800
+ if (_rEl) {
1801
+ var _tsS = _rEl.querySelector('.role-timestamp');
1802
+ _rEl.textContent = _ansM + ' (fallback) ';
1803
+ _rEl.title = (json.selected_model || '') + ' failed' +
1804
+ (json.reason ? ': ' + json.reason : '') + ' — answered by ' + (json.answered_by || '');
1805
+ _applyModelColor(_rEl, json.answered_by);
1806
+ if (_tsS) _rEl.appendChild(_tsS);
1807
+ }
1808
+ }
1809
+ }
1810
+ } else if (json.type === 'attachments') {
1811
+ if (_isBg) continue;
1812
+ // Update user bubble — replace file chips with image previews
1813
+ const _ub = document.querySelector('#chat-history .msg-user:last-of-type');
1814
+ if (_ub) {
1815
+ const _aw = _ub.querySelector('.attach-cards');
1816
+ if (_aw) {
1817
+ for (const _att of json.data) {
1818
+ const _isImg = (_att.mime || '').startsWith('image/') || /\.(png|jpg|jpeg|gif|webp|svg|bmp)$/i.test(_att.name || '');
1819
+ if (_isImg && _att.id) {
1820
+ // Skip if we already have a preview for this file id —
1821
+ // on a regenerate the original user bubble keeps its
1822
+ // photo and the backend re-emits the attachment event
1823
+ // for the same id; without this guard we'd append a
1824
+ // duplicate (which visually pushes the real photo off).
1825
+ const _existingPreview = _aw.querySelector('[data-file-id="' + _att.id + '"]');
1826
+ if (_existingPreview) {
1827
+ if (_att.vision_model && !_existingPreview.querySelector('.attach-vision-model')) {
1828
+ const _vl = document.createElement('div');
1829
+ _vl.className = 'attach-vision-model';
1830
+ _vl.textContent = 'Vision: ' + String(_att.vision_model).split('/').pop();
1831
+ const _name = _existingPreview.querySelector('.attach-image-name');
1832
+ if (_name) _existingPreview.insertBefore(_vl, _name);
1833
+ else _existingPreview.appendChild(_vl);
1834
+ }
1835
+ continue;
1836
+ }
1837
+ const _card = _aw.querySelector('.attach-card[data-name="' + (_att.name || '').replace(/"/g, '\\"') + '"]');
1838
+ const _iw = document.createElement('div');
1839
+ _iw.className = 'attach-image-preview';
1840
+ _iw.dataset.fileId = _att.id;
1841
+ _iw.style.cursor = 'pointer';
1842
+ _iw.onclick = () => window.open(API_BASE + '/api/upload/' + _att.id, '_blank');
1843
+ const _im = document.createElement('img');
1844
+ _im.src = API_BASE + '/api/upload/' + _att.id;
1845
+ _im.alt = _att.name || 'Image';
1846
+ _im.style.cssText = 'max-width:300px;max-height:200px;border-radius:6px;display:block;';
1847
+ _iw.appendChild(_im);
1848
+ if (_att.vision_model) {
1849
+ const _vl = document.createElement('div');
1850
+ _vl.className = 'attach-vision-model';
1851
+ _vl.textContent = 'Vision: ' + String(_att.vision_model).split('/').pop();
1852
+ _iw.appendChild(_vl);
1853
+ }
1854
+ if (_att.name) {
1855
+ const _nm = document.createElement('div');
1856
+ _nm.className = 'attach-image-name';
1857
+ _nm.textContent = _att.name;
1858
+ _iw.appendChild(_nm);
1859
+ }
1860
+ if (_card) _card.replaceWith(_iw); else _aw.appendChild(_iw);
1861
+ } else {
1862
+ const _card = _aw.querySelector('.attach-card[data-name="' + (_att.name || '').replace(/"/g, '\\"') + '"]');
1863
+ if (_card && _att.id) {
1864
+ _card.dataset.fileId = _att.id;
1865
+ _card.style.cursor = 'pointer';
1866
+ _card.onclick = () => window.open(API_BASE + '/api/upload/' + _att.id, '_blank');
1867
+ }
1868
+ }
1869
+ }
1870
+ }
1871
+ // Caption / OCR text is no longer rendered as an inline
1872
+ // collapsible on the user bubble — the user can view/edit
1873
+ // it via the "Caption" button on the photo thumbnail.
1874
+ }
1875
+ } else if (json.type === 'rag_sources') {
1876
+ if (_isBg) continue;
1877
+ holder._ragSources = json.data;
1878
+ } else if (json.type === 'memories_used') {
1879
+ if (_isBg) continue;
1880
+ holder._memoriesUsed = json.data;
1881
+ } else if (json.type === 'compacted') {
1882
+ if (!_isBg) {
1883
+ uiModule.showToast('Context compacted — older messages summarized');
1884
+ }
1885
+ } else if (json.type === 'metrics') {
1886
+ metrics = json.data;
1887
+ if (_isBg) {
1888
+ var bgM = _backgroundStreams.get(streamSessionId);
1889
+ if (bgM) bgM.metrics = json.data;
1890
+ continue;
1891
+ }
1892
+
1893
+ } else if (json.type === 'message_saved') {
1894
+ // Wire the persisted DB id onto the just-streamed bubble so it
1895
+ // can be edited/deleted immediately, without reloading the chat.
1896
+ if (_isBg) continue;
1897
+ if (currentHolder && json.id) currentHolder.dataset.dbId = json.id;
1898
+
1899
+ } else if (json.type === 'tool_start') {
1900
+ if (_isBg) continue;
1901
+ _cancelThinkingTimer();
1902
+ _removeThinkingSpinner();
1903
+ // Force-close thinking if still open — tools are real content, not thinking
1904
+ if (isThinking) {
1905
+ isThinking = false;
1906
+ cancelAnimationFrame(_thinkTimerRAF);
1907
+ var _elapsed2 = thinkingStartTime ? ((Date.now() - thinkingStartTime) / 1000).toFixed(1) : null;
1908
+ if (_liveThinkHeader) _liveThinkHeader.textContent = 'View thinking process';
1909
+ if (_liveThinkTimerEl) _liveThinkTimerEl.textContent = _elapsed2 ? _elapsed2 + 's' : '';
1910
+ if (_liveThinkSpinnerSlot) _liveThinkSpinnerSlot.remove();
1911
+ // Assign stable IDs
1912
+ var _thinkId2 = 'think-' + Date.now();
1913
+ var _liveHdr2 = _liveThinkSection && _liveThinkSection.querySelector('.thinking-header');
1914
+ if (_liveHdr2) _liveHdr2.dataset.thinkingId = _thinkId2;
1915
+ if (_liveThinkContent) _liveThinkContent.id = _thinkId2;
1916
+ if (_liveThinkToggle) _liveThinkToggle.id = _thinkId2 + '-toggle';
1917
+ }
1918
+ _renderStream();
1919
+ // --- Finalize current text bubble (only once per round) ---
1920
+ if (!roundFinalized) {
1921
+ roundFinalized = true;
1922
+ if (spinner && spinner.element) spinner.destroy();
1923
+ const dt = stripToolBlocks(roundText);
1924
+ if (dt.trim()) {
1925
+ var _body3 = roundHolder.querySelector('.body');
1926
+ var _contentEl3 = _ensureStreamLayout(_body3);
1927
+ _contentEl3.style.minHeight = ''; // clear streaming inflate
1928
+ _contentEl3.innerHTML = markdownModule.processWithThinking(markdownModule.squashOutsideCode(dt));
1929
+ if (window.hljs) roundHolder.querySelectorAll('pre code').forEach((b) => window.hljs.highlightElement(b));
1930
+ } else {
1931
+ roundHolder.style.display = 'none';
1932
+ }
1933
+ }
1934
+
1935
+ // Track tool name for contextual spinner labels
1936
+ _lastToolName = json.tool || '';
1937
+
1938
+ // --- Thread timeline: group tools in a thread container ---
1939
+ const cmd = json.command || '';
1940
+ const chatBox = document.getElementById('chat-history');
1941
+ // Find existing thread to append to — check last few children
1942
+ // (agent_step may insert an empty msg-ai between tool rounds)
1943
+ let threadWrap = null;
1944
+ for (let ci = chatBox.children.length - 1; ci >= Math.max(0, chatBox.children.length - 5); ci--) {
1945
+ const child = chatBox.children[ci];
1946
+ if (child.classList.contains('agent-thread')) {
1947
+ threadWrap = child;
1948
+ break;
1949
+ }
1950
+ // Skip hidden (empty) bubbles and thinking spinners
1951
+ if (child.style.display === 'none' || child.classList.contains('agent-thinking-dots')) continue;
1952
+ // Stop if we hit a visible message bubble (has real content between tools)
1953
+ if (child.classList.contains('msg')) break;
1954
+ }
1955
+ if (threadWrap) {
1956
+ // Continuing an existing thread — remove has-bottom (agent_step may have set it
1957
+ // expecting text, but we got more tools instead)
1958
+ threadWrap.classList.remove('has-bottom');
1959
+ } else {
1960
+ threadWrap = document.createElement('div');
1961
+ threadWrap.className = 'agent-thread';
1962
+ // Extend line up to connect to chat bubble above (if there is one)
1963
+ const _prevSib = chatBox.lastElementChild;
1964
+ const _hasBubbleAbove = _prevSib && (_prevSib.classList.contains('msg') && _prevSib.style.display !== 'none');
1965
+ const _hasThreadAbove = _prevSib && _prevSib.classList.contains('agent-thread');
1966
+ if (_hasBubbleAbove || _hasThreadAbove || (roundText.trim() && roundHolder && roundHolder.style.display !== 'none')) {
1967
+ threadWrap.classList.add('has-top');
1968
+ }
1969
+ chatBox.appendChild(threadWrap);
1970
+ }
1971
+ threadWrap.classList.add('streaming');
1972
+ const toolLabel = _toolLabels[json.tool.toLowerCase()] || json.tool;
1973
+ const node = document.createElement('div')
1974
+ node.className = 'agent-thread-node running';
1975
+ const cmdHtml = cmd ? `<pre class="agent-thread-cmd">${esc(cmd)}</pre>` : '';
1976
+ node.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">\u25B6</span><span class="agent-thread-tool">${toolLabel}</span><span class="agent-thread-wave">▁▂▃</span></div><div class="agent-thread-content">${cmdHtml}</div>`;
1977
+ // Expand/collapse via delegated click handler (init at module bottom).
1978
+ threadWrap.appendChild(node);
1979
+ currentToolBubble = node;
1980
+ // Animate the wave
1981
+ const waveEl = node.querySelector('.agent-thread-wave');
1982
+ if (waveEl) {
1983
+ const waveFrames = ['▁▂▃', '▂▃▄', '▃▄▅', '▄▅▆', '▅▆▇', '▆▅▄', '▅▄▃', '▄▃▂'];
1984
+ let waveIdx = 0;
1985
+ node._waveInterval = setInterval(() => {
1986
+ waveIdx = (waveIdx + 1) % waveFrames.length;
1987
+ waveEl.textContent = waveFrames[waveIdx];
1988
+ }, 100);
1989
+ }
1990
+ // Smooth per-second "cooking" timer — ticks every second (not
1991
+ // just on the 2s backend heartbeat) so a long-running tool
1992
+ // always shows visible motion and never reads as frozen.
1993
+ node._startTime = Date.now();
1994
+ node._elapsedTicker = setInterval(() => {
1995
+ const hdr2 = node.querySelector('.agent-thread-header');
1996
+ if (!hdr2) return;
1997
+ let el2 = hdr2.querySelector('.agent-thread-elapsed');
1998
+ if (!el2) {
1999
+ el2 = document.createElement('span');
2000
+ el2.className = 'agent-thread-elapsed';
2001
+ // Sits on the LEFT, right after the icon.
2002
+ const icon = hdr2.querySelector('.agent-thread-icon');
2003
+ if (icon && icon.nextSibling) hdr2.insertBefore(el2, icon.nextSibling);
2004
+ else hdr2.appendChild(el2);
2005
+ }
2006
+ const s = (Date.now() - node._startTime) / 1000;
2007
+ // Hundredths so it visibly counts sub-second (1.00, 1.05, …).
2008
+ el2.textContent = s < 60 ? `${s.toFixed(2)}s` : `${Math.floor(s / 60)}m ${(s % 60).toFixed(2).padStart(5, '0')}s`;
2009
+ }, 50);
2010
+ uiModule.scrollHistory();
2011
+
2012
+ } else if (json.type === 'tool_progress') {
2013
+ // Long-running subprocess (bash, python) is still in
2014
+ // flight — refresh the running tool card with the
2015
+ // elapsed-time + tail of its stdout/stderr so the
2016
+ // user doesn't stare at a blind "Running…" spinner.
2017
+ if (_isBg) continue;
2018
+ if (!currentToolBubble) continue;
2019
+ // The per-second ticker (started in tool_start) owns the
2020
+ // elapsed display; here we just surface the live output tail.
2021
+ const tailStr = (json.tail || '').trim();
2022
+ if (tailStr) {
2023
+ let tailEl = currentToolBubble.querySelector('.agent-thread-tail');
2024
+ if (!tailEl) {
2025
+ tailEl = document.createElement('pre');
2026
+ tailEl.className = 'agent-thread-tail';
2027
+ tailEl.style.cssText = 'margin:4px 0 0;padding:6px 8px;font-size:11px;background:rgba(0,0,0,0.18);border-radius:4px;max-height:140px;overflow:auto;white-space:pre-wrap;opacity:0.85;';
2028
+ const content = currentToolBubble.querySelector('.agent-thread-content');
2029
+ if (content) content.appendChild(tailEl);
2030
+ }
2031
+ tailEl.textContent = tailStr;
2032
+ tailEl.scrollTop = tailEl.scrollHeight;
2033
+ }
2034
+ uiModule.scrollHistory();
2035
+
2036
+ } else if (json.type === 'tool_output') {
2037
+ if (_isBg) continue;
2038
+ // --- Update the current thread node ---
2039
+ if (currentToolBubble) {
2040
+ // Stop wave animation + the per-second cooking ticker
2041
+ if (currentToolBubble._waveInterval) {
2042
+ clearInterval(currentToolBubble._waveInterval);
2043
+ currentToolBubble._waveInterval = null;
2044
+ }
2045
+ if (currentToolBubble._elapsedTicker) {
2046
+ clearInterval(currentToolBubble._elapsedTicker);
2047
+ currentToolBubble._elapsedTicker = null;
2048
+ }
2049
+ const ok = (json.exit_code === 0 || json.exit_code == null);
2050
+ const cmd = json.command || '';
2051
+ let outHtml = '';
2052
+ if (json.output && json.output.trim()) {
2053
+ outHtml = `<details class="agent-tool-output"><summary>Output</summary><pre>${esc(json.output)}</pre></details>`;
2054
+ }
2055
+ const cmdHtml2 = cmd ? `<pre class="agent-thread-cmd">${esc(cmd)}</pre>` : '';
2056
+ // Preserve the user's .open choice across the innerHTML
2057
+ // rewrite \u2014 otherwise expanding a running tool collapses
2058
+ // it as soon as the result lands, forcing the user to
2059
+ // click again. Click handling is delegated (see init at
2060
+ // bottom of file) so no per-node listener needed.
2061
+ const _wasOpen = currentToolBubble.classList.contains('open');
2062
+ currentToolBubble.className = 'agent-thread-node' + (ok ? '' : ' error') + (_wasOpen ? ' open' : '');
2063
+ currentToolBubble.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">${ok ? '\u2713' : '\u2717'}</span><span class="agent-thread-tool">${esc(json.tool)}</span><span class="agent-thread-status">${ok ? 'done' : 'failed'}</span><span class="agent-thread-chevron">\u25B6</span></div><div class="agent-thread-content">${cmdHtml2}${outHtml}</div>`;
2064
+ // Reset so thinking spinner between tools says "Thinking" not the old tool's label
2065
+ _lastToolName = '';
2066
+ uiModule.scrollHistory();
2067
+ }
2068
+ // --- Render generated images inline ---
2069
+ if (json.image_url) {
2070
+ const chatBox = document.getElementById('chat-history');
2071
+ chatBox.appendChild(_buildImageBubble(json.image_url, json.image_prompt, json.image_model, json.image_size, json.image_quality, json.image_id));
2072
+ uiModule.scrollHistory();
2073
+ // Notify gallery to refresh if open
2074
+ window.dispatchEvent(new CustomEvent('gallery-refresh'));
2075
+ }
2076
+ // --- Render browser screenshots in tool output ---
2077
+ if (json.screenshot && currentToolBubble) {
2078
+ const contentEl = currentToolBubble.querySelector('.agent-thread-content');
2079
+ if (contentEl) {
2080
+ const details = document.createElement('details');
2081
+ details.className = 'agent-tool-output';
2082
+ details.innerHTML = `<summary>Screenshot</summary><img src="${json.screenshot}" style="max-width:100%;border-radius:6px;margin-top:6px;border:1px solid var(--border)" />`;
2083
+ contentEl.appendChild(details);
2084
+ }
2085
+ }
2086
+ // --- Reload sessions after manage_session tool (delete, rename, etc.) ---
2087
+ // Debounce so bulk deletes don't fire loadSessions per call
2088
+ if (json.tool === 'manage_session' && sessionModule) {
2089
+ if (window._manageSessionTimer) clearTimeout(window._manageSessionTimer);
2090
+ window._manageSessionTimer = setTimeout(() => sessionModule.loadSessions(), 1000);
2091
+ }
2092
+ // --- Live-refresh the calendar after manage_calendar (add/edit/delete) ---
2093
+ // so a new event shows without the user hard-refreshing. Debounced
2094
+ // so a batch of event creates only triggers one refetch.
2095
+ if (json.tool === 'manage_calendar') {
2096
+ if (window._manageCalTimer) clearTimeout(window._manageCalTimer);
2097
+ window._manageCalTimer = setTimeout(
2098
+ () => window.dispatchEvent(new CustomEvent('calendar-refresh')), 600);
2099
+ }
2100
+ // --- Live-refresh Memories after manage_memory changes ---
2101
+ if (json.tool === 'manage_memory') {
2102
+ if (window._manageMemoryTimer) clearTimeout(window._manageMemoryTimer);
2103
+ window._manageMemoryTimer = setTimeout(
2104
+ () => window.dispatchEvent(new CustomEvent('memory-refresh')), 600);
2105
+ }
2106
+ // --- Apply UI control actions embedded in tool_output ---
2107
+ if (json.ui_event) {
2108
+ chatStream.handleUIControl(json);
2109
+ }
2110
+
2111
+ // Schedule a thinking spinner between tool rounds (short delay so
2112
+ // agent_step in the same SSE chunk can cancel it before it shows)
2113
+ _scheduleThinkingSpinner();
2114
+ uiModule.scrollHistory();
2115
+
2116
+ } else if (json.type === 'doc_stream_open') {
2117
+ if (_isBg) {
2118
+ // Store for replay when user returns to this session
2119
+ var bgDocOpen = _backgroundStreams.get(streamSessionId);
2120
+ if (bgDocOpen) {
2121
+ bgDocOpen._docTitle = json.title || '';
2122
+ bgDocOpen._docLang = json.language || '';
2123
+ bgDocOpen._docContent = '';
2124
+ }
2125
+ continue;
2126
+ }
2127
+ if (documentModule) {
2128
+ documentModule.streamDocOpen(json.title || '', json.language || '');
2129
+ }
2130
+
2131
+ } else if (json.type === 'doc_stream_delta') {
2132
+ if (_isBg) {
2133
+ var bgDocDelta = _backgroundStreams.get(streamSessionId);
2134
+ if (bgDocDelta) bgDocDelta._docContent = json.content || '';
2135
+ continue;
2136
+ }
2137
+ if (documentModule) {
2138
+ documentModule.streamDocDelta(json.content || '');
2139
+ }
2140
+
2141
+ } else if (json.type === 'doc_update') {
2142
+ // doc_update means the server already saved the doc to DB.
2143
+ if (_isBg) continue;
2144
+ if (documentModule) {
2145
+ documentModule.handleDocUpdate(json);
2146
+ }
2147
+
2148
+ } else if (json.type === 'doc_suggestions') {
2149
+ if (_isBg) continue;
2150
+ if (documentModule && documentModule.handleDocSuggestions) {
2151
+ documentModule.handleDocSuggestions(json);
2152
+ }
2153
+
2154
+ } else if (json.type === 'ui_control') {
2155
+ if (_isBg) continue;
2156
+ chatStream.handleUIControl(json.data || {});
2157
+
2158
+ } else if (json.type === 'agent_step') {
2159
+ if (_isBg) continue;
2160
+ _cancelThinkingTimer();
2161
+ _removeThinkingSpinner();
2162
+ _renderStream();
2163
+ // Mark thread as connected to bubble below
2164
+ const _activeThread = document.querySelector('.agent-thread.streaming');
2165
+ if (_activeThread) {
2166
+ _activeThread.classList.add('has-bottom');
2167
+ }
2168
+ // --- New round: create fresh AI bubble with spinner ---
2169
+ currentToolBubble = null;
2170
+ roundFinalized = false;
2171
+ isThinking = false;
2172
+ _docFenceOpened = false;
2173
+ _docFenceContentStart = -1;
2174
+ const box = document.getElementById('chat-history');
2175
+ const newWrap = document.createElement('div');
2176
+ newWrap.className = 'msg msg-ai msg-continuation streaming';
2177
+ // Add model name label
2178
+ const newRole = document.createElement('div');
2179
+ newRole.className = 'role';
2180
+ const metaS = sessionModule.getSessions().find(s => s.id === streamSessionId);
2181
+ newRole.textContent = _shortModel(metaS?.model) || '';
2182
+ _applyModelColor(newRole, metaS?.model);
2183
+ newWrap.appendChild(newRole);
2184
+ const newBody = document.createElement('div');
2185
+ newBody.className = 'body';
2186
+ newWrap.appendChild(newBody);
2187
+ box.appendChild(newWrap);
2188
+ roundHolder = newWrap;
2189
+ roundText = '';
2190
+ // Destroy any previous spinner before creating new one
2191
+ if (spinner && spinner.element) spinner.destroy();
2192
+ // Show spinner while waiting for text (skip for research — has its own progress)
2193
+ if (!_researchingStreamIds.has(streamSessionId)) {
2194
+ spinner = spinnerModule.create('Generating response', 'right', 'wave');
2195
+ newBody.appendChild(spinner.createElement());
2196
+ spinner.start();
2197
+ }
2198
+ if (streamingTTS) window.aiTTSManager._streamSentencesSent = 0;
2199
+ uiModule.scrollHistory();
2200
+ } else if (json.type === 'budget_exceeded') {
2201
+ if (_isBg) continue;
2202
+ _cancelThinkingTimer();
2203
+ _removeThinkingSpinner();
2204
+ const budgetDiv = document.createElement('div');
2205
+ budgetDiv.style.cssText = 'font-size:11px;opacity:0.6;font-style:italic;padding:4px 8px;margin:4px 0;';
2206
+ budgetDiv.textContent = `Tool budget reached (${json.used}/${json.limit} calls). Agent stopped.`;
2207
+ const chatBox = document.getElementById('chat-history');
2208
+ chatBox.appendChild(budgetDiv);
2209
+
2210
+ } else if (json.type === 'teacher_takeover') {
2211
+ if (_isBg) continue;
2212
+ _cancelThinkingTimer();
2213
+ _removeThinkingSpinner();
2214
+ // Finalize any in-flight bubble so the takeover banner
2215
+ // separates student attempt from teacher attempt.
2216
+ if (spinner && spinner.element) { try { spinner.destroy(); } catch(_){} spinner = null; }
2217
+ const chatBox = document.getElementById('chat-history');
2218
+ const banner = document.createElement('div');
2219
+ banner.className = 'teacher-takeover-banner';
2220
+ banner.style.cssText = 'margin:10px 0;padding:8px 12px;border-left:3px solid #c08a3e;background:rgba(192,138,62,0.08);font-size:12px;color:var(--fg);border-radius:4px;';
2221
+ const teacherName = json.teacher_model || 'teacher';
2222
+ const why = json.student_failure ? ` &mdash; <span style="opacity:0.7">${esc(json.student_failure)}</span>` : '';
2223
+ banner.innerHTML = `<strong>Teacher takeover:</strong> escalating to <code>${esc(teacherName)}</code>${why}`;
2224
+ chatBox.appendChild(banner);
2225
+ // Reset round bubble state so the teacher's first text starts a new bubble
2226
+ roundHolder = null;
2227
+ roundText = '';
2228
+ roundFinalized = false;
2229
+ currentToolBubble = null;
2230
+ uiModule.scrollHistory();
2231
+
2232
+ } else if (json.type === 'skill_saved') {
2233
+ if (_isBg) continue;
2234
+ const chatBox = document.getElementById('chat-history');
2235
+ const note = document.createElement('div');
2236
+ note.className = 'skill-saved-note';
2237
+ note.style.cssText = 'margin:6px 0;padding:6px 10px;border-left:3px solid #4a8a4a;background:rgba(74,138,74,0.07);font-size:12px;color:var(--fg);border-radius:4px;';
2238
+ note.innerHTML = `<strong>Skill learned:</strong> <code>${esc(json.name || '')}</code>${json.category ? ` <span style="opacity:0.6">[${esc(json.category)}]</span>` : ''}`;
2239
+ chatBox.appendChild(note);
2240
+ uiModule.scrollHistory();
2241
+
2242
+ } else if (json.type === 'escalation_failed' || json.type === 'skill_save_failed') {
2243
+ if (_isBg) continue;
2244
+ const chatBox = document.getElementById('chat-history');
2245
+ const note = document.createElement('div');
2246
+ note.className = 'escalation-failed-note';
2247
+ note.style.cssText = 'margin:6px 0;padding:6px 10px;border-left:3px solid #8a4a4a;background:rgba(138,74,74,0.07);font-size:12px;color:var(--fg);border-radius:4px;';
2248
+ const label = json.type === 'escalation_failed' ? 'Teacher could not solve it' : 'Skill not saved';
2249
+ note.innerHTML = `<strong>${label}:</strong> <span style="opacity:0.75">${esc(json.reason || '')}</span>`;
2250
+ chatBox.appendChild(note);
2251
+ uiModule.scrollHistory();
2252
+
2253
+ } else if (json.error) {
2254
+ // --- Backend error (timeout, connection issue, etc.) ---
2255
+ console.error('Stream error from backend:', json.error);
2256
+ if (_isBg) continue;
2257
+ if (spinner && spinner.element) spinner.destroy();
2258
+ const errDiv = document.createElement('div');
2259
+ errDiv.style.cssText = 'color: var(--color-error); font-style: italic; padding: 4px 0;';
2260
+ errDiv.textContent = `[Error: ${json.error}]`;
2261
+ roundHolder.querySelector('.body').appendChild(errDiv);
2262
+ uiModule.scrollHistory();
2263
+ }
2264
+ } catch (e) {
2265
+ console.error('Error parsing SSE data:', e);
2266
+ }
2267
+ }
2268
+ }
2269
+ }
2270
+
2271
+ if (!_streamSawDone) {
2272
+ throw new Error('Stream closed before completion');
2273
+ }
2274
+
2275
+ _renderStream();
2276
+ _cancelThinkingTimer();
2277
+ _removeThinkingSpinner();
2278
+ // Stop any thread pulse animations
2279
+ document.querySelectorAll('.agent-thread.streaming').forEach(t => t.classList.remove('streaming'));
2280
+ // --- Final render (skip if stream was ever backgrounded or currently in background) ---
2281
+ // Remove streaming class from all round bubbles
2282
+ holder.classList.remove('streaming');
2283
+ if (roundHolder && roundHolder !== holder) roundHolder.classList.remove('streaming');
2284
+
2285
+ const _isBgFinal = (sessionModule.getCurrentSessionId() !== streamSessionId) || _backgroundStreams.has(streamSessionId);
2286
+ if (!_isBgFinal) {
2287
+ finalMeta = sessionModule.getSessions().find(s => s.id === sessionModule.getCurrentSessionId());
2288
+ finalModelName = _shortModel(metrics?.model || finalMeta?.model);
2289
+ // Preserve suffix (e.g. "Research") if set by model_info event
2290
+ if (holder._roleSuffix) finalModelName += ' (' + holder._roleSuffix + ')';
2291
+ // Prepend character name if set
2292
+ var _charNameFinal = presetsModule.getCharacterName ? presetsModule.getCharacterName() : '';
2293
+ if (_charNameFinal) finalModelName = _charNameFinal;
2294
+ const roleEl = holder.querySelector('.role');
2295
+ if (roleEl) {
2296
+ const tsSpan = roleEl.querySelector('.role-timestamp');
2297
+ roleEl.textContent = finalModelName + ' ';
2298
+ _applyModelColor(roleEl, metrics?.model || finalMeta?.model);
2299
+ if (tsSpan) roleEl.appendChild(tsSpan);
2300
+ }
2301
+ holder.dataset.raw = accumulated;
2302
+
2303
+ // Anti-stall: a turn that ran tools but ended with essentially no
2304
+ // final prose usually means the model stopped mid-task (the case
2305
+ // where you had to type "did you finish?"). Offer a one-click
2306
+ // Continue that resumes exactly where it left off — reuses the same
2307
+ // resume mechanism as the user-stop "[Message interrupted]" button.
2308
+ try {
2309
+ const _usedTools = holder.querySelector('.agent-thread-node');
2310
+ const _proseLen = (accumulated || '').replace(/<[^>]*>/g, '').trim().length;
2311
+ if (_usedTools && _proseLen < 24 && !holder.querySelector('.agent-continue-btn')) {
2312
+ const _stall = document.createElement('div');
2313
+ _stall.className = 'stopped-indicator';
2314
+ const _lbl = document.createElement('span');
2315
+ _lbl.style.cssText = 'font-style:italic;opacity:0.7;';
2316
+ _lbl.textContent = 'Paused mid-task';
2317
+ _stall.appendChild(_lbl);
2318
+ const _cont = document.createElement('button');
2319
+ _cont.className = 'continue-btn agent-continue-btn';
2320
+ _cont.title = 'Continue — pick up where it left off';
2321
+ _cont.textContent = '▸';
2322
+ _cont.addEventListener('click', () => {
2323
+ _stall.remove();
2324
+ const mi = uiModule.el('message');
2325
+ if (mi) {
2326
+ mi.value = 'Continue — you stopped before finishing. Pick up exactly where you left off and complete the task.';
2327
+ const sb = document.querySelector('.send-btn');
2328
+ if (sb) sb.click();
2329
+ }
2330
+ });
2331
+ _stall.appendChild(_cont);
2332
+ (holder.querySelector('.body') || holder).appendChild(_stall);
2333
+ }
2334
+ } catch (_) {}
2335
+
2336
+ // Clear streaming minHeight lock
2337
+ const _streamContent = roundHolder.querySelector('.stream-content');
2338
+ if (_streamContent) _streamContent.style.minHeight = '';
2339
+
2340
+ // Finalize the last round's bubble — flatten stream-content wrapper for clean DOM
2341
+ const finalDisplay = stripToolBlocks(roundText);
2342
+ if (finalDisplay.trim()) {
2343
+ var _body4 = roundHolder.querySelector('.body');
2344
+ // Preserve sources expanded state before final render
2345
+ var _wasExpanded = _sourcesExpanded || !!(_body4 && _body4.querySelector('.sources-content.expanded'));
2346
+
2347
+ // If thinking was collapsed in-place during streaming, preserve it
2348
+ var _liveReplyEl = _body4 && _body4.querySelector('.live-reply-content');
2349
+ var _extracted = _liveReplyEl ? markdownModule.extractThinkingBlocks(finalDisplay) : null;
2350
+ var _finalReply = '';
2351
+ if (_liveReplyEl) {
2352
+ // Try standard extraction first (for native <think> tags)
2353
+ if (_extracted?.thinkingBlocks?.length) {
2354
+ _finalReply = (_extracted.content || '').trim();
2355
+ } else {
2356
+ // Non-tag thinking: extract reply from raw text
2357
+ // Handle garbled <think> tag: "Thinking: reasoning\n<think>reply"
2358
+ const _garbledMatch = finalDisplay.match(/^[\s\S]+?<think(?:ing)?>\s*([\s\S]*?)(?:<\/think(?:ing)?>)?\s*$/i);
2359
+ if (_garbledMatch && _garbledMatch[1].trim()) {
2360
+ _finalReply = _garbledMatch[1].trim();
2361
+ } else {
2362
+ // Pure non-tag: find reply boundary by prefix patterns
2363
+ const _rs2 = ['Hey', 'Hi ', 'Hi!', 'Hello', 'Sure', 'Yes', 'No ', 'No,', 'Yo', 'OK', 'Here', 'Absolutely', 'Of course', 'Great', 'Alright', 'Thanks', 'Welcome', 'Good ', "I'm happy", "I'd be"];
2364
+ const _fr = (finalDisplay || '').trimStart();
2365
+ if (markdownModule.startsWithReasoningPrefix(_fr)) {
2366
+ const _fLines = _fr.split('\n');
2367
+ for (let _fi = 1; _fi < _fLines.length; _fi++) {
2368
+ const _fl = _fLines[_fi].trim();
2369
+ if (!_fl) continue;
2370
+ if (_rs2.some(rp => _fl.startsWith(rp))) { _finalReply = _fLines.slice(_fi).join('\n'); break; }
2371
+ }
2372
+ // Within-line check
2373
+ if (!_finalReply) {
2374
+ for (const rp of _rs2) {
2375
+ const rx = new RegExp('[.!?]\\s*(' + rp.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')');
2376
+ const m = rx.exec(_fr);
2377
+ if (m && m.index > 20) { _finalReply = _fr.slice(m.index + 1).trim(); break; }
2378
+ }
2379
+ }
2380
+ }
2381
+ }
2382
+ }
2383
+ }
2384
+ if (_liveReplyEl && _finalReply) {
2385
+ // Render reply into the live-reply container (thinking bar already showing)
2386
+ var _replyHtml = markdownModule.mdToHtml(markdownModule.squashOutsideCode(_finalReply));
2387
+ _liveReplyEl.innerHTML = _replyHtml;
2388
+ _liveReplyEl.classList.remove('live-reply-content');
2389
+ if (_sourcesData) {
2390
+ var _srcEl = document.createElement('div');
2391
+ _srcEl.innerHTML = _buildSourcesBox(_sourcesData, _sourcesType, _wasExpanded);
2392
+ _body4.insertBefore(_srcEl.firstChild || _srcEl, _body4.firstChild);
2393
+ }
2394
+ if (_findingsData) _body4.insertAdjacentHTML('beforeend', chatRenderer.buildFindingsBox(_findingsData));
2395
+ } else {
2396
+ // Full re-render (reply empty or no live-reply container)
2397
+ _body4.innerHTML = (_sourcesData ? _buildSourcesBox(_sourcesData, _sourcesType, _wasExpanded) : '')
2398
+ + markdownModule.processWithThinking(markdownModule.squashOutsideCode(finalDisplay))
2399
+ + (_findingsData ? chatRenderer.buildFindingsBox(_findingsData) : '');
2400
+ }
2401
+ } else if (_sourcesHtml) {
2402
+ var _body4b = roundHolder.querySelector('.body');
2403
+ var _wasExpanded2 = _sourcesExpanded || !!(_body4b && _body4b.querySelector('.sources-content.expanded'));
2404
+ _body4b.innerHTML = _sourcesData ? _buildSourcesBox(_sourcesData, _sourcesType, _wasExpanded2) : _sourcesHtml;
2405
+ } else if (roundHolder !== holder) {
2406
+ // Check if there's thinking content worth showing
2407
+ const _thinkMatch = roundText.match(/<think(?:ing)?>([\s\S]*?)<\/think(?:ing)?>/i);
2408
+ if (_thinkMatch && _thinkMatch[1].trim()) {
2409
+ // Show thinking in a collapsed section even if no visible reply text
2410
+ const _body4c = roundHolder.querySelector('.body');
2411
+ if (_body4c) _body4c.innerHTML = markdownModule.processWithThinking(roundText);
2412
+ } else {
2413
+ roundHolder.style.display = 'none';
2414
+ // Thread above expected a bubble below — remove has-bottom since bubble is hidden
2415
+ const _lastThread = roundHolder.previousElementSibling;
2416
+ if (_lastThread && _lastThread.classList.contains('agent-thread')) {
2417
+ _lastThread.classList.remove('has-bottom');
2418
+ }
2419
+ }
2420
+ }
2421
+
2422
+
2423
+ if (window.hljs) {
2424
+ roundHolder.querySelectorAll('pre code').forEach((block) => {
2425
+ window.hljs.highlightElement(block);
2426
+ });
2427
+ }
2428
+ if (markdownModule.renderMermaid) markdownModule.renderMermaid(roundHolder);
2429
+
2430
+ uiModule.scrollHistory();
2431
+ // Render RAG sources if present
2432
+ if (holder._ragSources && holder._ragSources.length) {
2433
+ const details = document.createElement('details');
2434
+ details.className = 'rag-sources';
2435
+ const summary = document.createElement('summary');
2436
+ summary.textContent = `Sources (${holder._ragSources.length} documents)`;
2437
+ details.appendChild(summary);
2438
+ holder._ragSources.forEach(src => {
2439
+ const item = document.createElement('div');
2440
+ item.className = 'rag-source-item';
2441
+ const _esc = uiModule.esc;
2442
+ item.innerHTML = `<strong>${_esc(src.filename)}</strong> <span class="rag-similarity">${(src.similarity * 100).toFixed(1)}%</span><div class="rag-snippet">${_esc(src.snippet)}</div>`;
2443
+ details.appendChild(item);
2444
+ });
2445
+ holder.querySelector('.body').appendChild(details);
2446
+ }
2447
+
2448
+ // Hide first bubble if it has no visible text content (e.g. agent went straight to tools)
2449
+ if (holder !== roundHolder && holder.style.display !== 'none') {
2450
+ const _hBody = holder.querySelector('.body');
2451
+ const _hText = _hBody ? _hBody.textContent.trim() : '';
2452
+ if (!_hText) holder.style.display = 'none';
2453
+ }
2454
+
2455
+ // Attach footer to the last visible bubble (roundHolder for multi-round agent, holder for single)
2456
+ const footerTarget = (roundHolder && roundHolder !== holder && roundHolder.style.display !== 'none') ? roundHolder : holder;
2457
+ footerTarget.appendChild(createMsgFooter(footerTarget));
2458
+ // Add "View Report" link for completed research
2459
+ if (_researchingStreamIds.has(streamSessionId)) {
2460
+ _appendViewReportLink(footerTarget, streamSessionId);
2461
+ }
2462
+ // Also store raw on the footer target so copy/TTS work
2463
+ if (footerTarget !== holder) footerTarget.dataset.raw = accumulated;
2464
+ if (addAITTSButton && accumulated && window.aiTTSManager?._provider !== 'disabled' && window.aiTTSManager?.available) {
2465
+ addAITTSButton(footerTarget, accumulated);
2466
+ }
2467
+ // TTS auto-play: streaming mode flushes remaining text, non-streaming enqueues full message
2468
+ if (accumulated && window.aiTTSManager && window.aiTTSManager.autoPlay) {
2469
+ const ttsBtn = holder.querySelector('.ai-tts-button');
2470
+ if (ttsBtn) {
2471
+ var ICON_PLAY_TTS = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="6 3 20 12 6 21 6 3"/></svg>';
2472
+ var ICON_STOP_TTS = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none"><rect x="5" y="5" width="14" height="14" rx="2"/></svg>';
2473
+ const resetFn = () => {
2474
+ ttsBtn.innerHTML = ICON_PLAY_TTS;
2475
+ ttsBtn.classList.remove('playing', 'loading');
2476
+ ttsBtn.style.color = '#6b7280';
2477
+ ttsBtn.title = 'Read aloud';
2478
+ };
2479
+ if (streamingTTS) {
2480
+ // Flush remaining partial sentence and attach the real button
2481
+ window.aiTTSManager.streamingEnd(accumulated);
2482
+ window.aiTTSManager.streamingAttachButton(ttsBtn, resetFn);
2483
+ // If still playing sentences from the stream, show stop icon
2484
+ if (window.aiTTSManager.isPlaying || window.aiTTSManager._processing) {
2485
+ ttsBtn.innerHTML = ICON_STOP_TTS;
2486
+ ttsBtn.classList.add('playing');
2487
+ ttsBtn.style.color = '#ccc';
2488
+ ttsBtn.title = 'Stop';
2489
+ }
2490
+ } else {
2491
+ // Non-streaming fallback (autoPlay toggled mid-stream, etc.)
2492
+ window.aiTTSManager.enqueue(accumulated, ttsBtn, resetFn);
2493
+ }
2494
+ }
2495
+ }
2496
+ if (metrics) {
2497
+ displayMetrics(footerTarget, metrics);
2498
+ }
2499
+ // Attach variant navigation if this was a regeneration
2500
+ _attachVariantNav(footerTarget);
2501
+
2502
+ // Merge with previous stopped message if this was a continue
2503
+ if (_pendingContinue) {
2504
+ const prevEl = _pendingContinue;
2505
+ _pendingContinue = null;
2506
+ const prevBody = prevEl.querySelector('.body');
2507
+ const newBody = footerTarget.querySelector('.body');
2508
+ if (prevBody && newBody && prevEl.parentNode) {
2509
+ // Merge: combine raw text with *(continued)* marker
2510
+ const oldRaw = prevEl.dataset.raw || '';
2511
+ const newRaw = footerTarget.dataset.raw || '';
2512
+ const mergedRaw = oldRaw + '\n\n*(continued)*\n\n' + newRaw;
2513
+ prevEl.dataset.raw = mergedRaw;
2514
+ // Re-render merged content
2515
+ prevBody.innerHTML = markdownModule.processWithThinking(
2516
+ markdownModule.squashOutsideCode(mergedRaw)
2517
+ );
2518
+ // Remove the new bubble and re-add footer to the merged one
2519
+ footerTarget.remove();
2520
+ const oldFooter = prevEl.querySelector('.msg-footer');
2521
+ if (oldFooter) oldFooter.remove();
2522
+ prevEl.appendChild(createMsgFooter(prevEl));
2523
+ if (window.hljs) {
2524
+ prevEl.querySelectorAll('pre code').forEach(block => window.hljs.highlightElement(block));
2525
+ }
2526
+
2527
+ // Persist merge to server
2528
+ const sid = sessionModule.getCurrentSessionId();
2529
+ if (sid) {
2530
+ fetch(`${API_BASE}/api/session/${sid}/merge-last-assistant`, {
2531
+ method: 'POST',
2532
+ headers: { 'Content-Type': 'application/json' },
2533
+ body: JSON.stringify({ separator: '\n\n*(continued)*\n\n' })
2534
+ }).catch(e => console.warn('merge-last-assistant failed:', e));
2535
+ }
2536
+ }
2537
+ }
2538
+ } // end if (!_isBgFinal)
2539
+
2540
+ } catch (err) {
2541
+ _renderStream();
2542
+ // Clean up any active spinner (e.g. "Generating response" during tool calls)
2543
+ if (spinner && spinner.element) spinner.destroy();
2544
+ _cancelThinkingTimer();
2545
+ _removeThinkingSpinner();
2546
+ document.querySelectorAll('.agent-thread.streaming').forEach(t => t.classList.remove('streaming'));
2547
+ // Check if this stream was running in background
2548
+ const _isBgCatch = (sessionModule.getCurrentSessionId() !== streamSessionId) || _backgroundStreams.has(streamSessionId);
2549
+
2550
+ if (_isBgCatch) {
2551
+ // Error happened while backgrounded — update map, don't touch DOM
2552
+ console.error('Background stream error:', err);
2553
+ var bgErr = _backgroundStreams.get(streamSessionId);
2554
+ if (bgErr && bgErr.status === 'completed') {
2555
+ // [DONE] was already processed — this error is benign (e.g. reader.read() after close)
2556
+ // Don't override the completed status; just ensure the completed dot stays
2557
+ if (sessionModule && sessionModule.clearStreaming) {
2558
+ sessionModule.clearStreaming(streamSessionId);
2559
+ }
2560
+ } else if (bgErr) {
2561
+ bgErr.status = 'error';
2562
+ if (sessionModule && sessionModule.clearStreaming) {
2563
+ sessionModule.clearStreaming(streamSessionId);
2564
+ }
2565
+ }
2566
+ } else {
2567
+ // Stop streaming TTS on any error/abort
2568
+ if (streamingTTS && window.aiTTSManager) window.aiTTSManager.stop();
2569
+
2570
+ if (currentAbort && currentAbort.signal.aborted) {
2571
+ const abortReason = currentAbort._reason || '';
2572
+ // Timeout-triggered aborts should remain visible instead of disappearing.
2573
+ if (timedOut || abortReason === 'timeout') {
2574
+ const timeoutMsg = _isAgent
2575
+ ? 'Agent response timed out. Try again, switch to a faster model, or reduce tool usage.'
2576
+ : 'Response timed out. Try again.';
2577
+
2578
+ if (holder && !accumulated) {
2579
+ holder.querySelector('.body').innerHTML =
2580
+ `<div style="color: var(--color-error); font-style: italic; padding: 4px 0;">[${timeoutMsg}]</div>`;
2581
+ } else if (holder && accumulated) {
2582
+ const timeoutNote = document.createElement('div');
2583
+ timeoutNote.className = 'stopped-indicator';
2584
+ timeoutNote.innerHTML =
2585
+ `<span style="color: var(--color-error);">[${timeoutMsg}]</span>`;
2586
+ holder.querySelector('.body').appendChild(timeoutNote);
2587
+ }
2588
+ currentAbort = null;
2589
+ return;
2590
+ }
2591
+
2592
+ if (abortReason === 'offline') {
2593
+ const offlineMsg = 'Endpoint offline — switch model or try again.';
2594
+ if (holder && !accumulated) {
2595
+ holder.querySelector('.body').innerHTML =
2596
+ `<div style="color: var(--color-error); font-style: italic; padding: 4px 0;">[${offlineMsg}]</div>`;
2597
+ } else if (holder && accumulated) {
2598
+ const offlineNote = document.createElement('div');
2599
+ offlineNote.className = 'stopped-indicator';
2600
+ offlineNote.innerHTML =
2601
+ `<span style="color: var(--color-error);">[${offlineMsg}]</span>`;
2602
+ holder.querySelector('.body').appendChild(offlineNote);
2603
+ }
2604
+ currentAbort = null;
2605
+ return;
2606
+ }
2607
+
2608
+ if (abortReason === 'recovery') {
2609
+ const recoveryMsg = 'Streaming was interrupted after the tab went inactive. Partial output was preserved.';
2610
+ if (holder && !accumulated) {
2611
+ holder.querySelector('.body').innerHTML =
2612
+ `<div style="color: var(--color-error); font-style: italic; padding: 4px 0;">[${recoveryMsg}]</div>`;
2613
+ } else if (holder && accumulated) {
2614
+ const recoveryNote = document.createElement('div');
2615
+ recoveryNote.className = 'stopped-indicator';
2616
+ recoveryNote.innerHTML =
2617
+ `<span style="color: var(--color-error);">[${recoveryMsg}]</span>`;
2618
+ holder.querySelector('.body').appendChild(recoveryNote);
2619
+ }
2620
+ currentAbort = null;
2621
+ return;
2622
+ }
2623
+
2624
+ // User-initiated stop (or browser navigation abort).
2625
+ // Stopped before any text arrived — keep the bubble as a
2626
+ // "Cancelled by user" record (so it survives a refresh).
2627
+ if (holder && !accumulated) {
2628
+ _renderCancelledBubble(holder);
2629
+ }
2630
+
2631
+ // But just in case the stop button didn't render it, render it here
2632
+ if (holder && accumulated && !currentHolder) {
2633
+ holder.dataset.raw = accumulated;
2634
+ holder.querySelector('.body').innerHTML = markdownModule.processWithThinking(
2635
+ markdownModule.squashOutsideCode(accumulated)
2636
+ );
2637
+
2638
+ if (window.hljs) {
2639
+ holder.querySelectorAll('pre code').forEach((block) => {
2640
+ window.hljs.highlightElement(block);
2641
+ });
2642
+ }
2643
+
2644
+ const stoppedIndicator = document.createElement('div');
2645
+ stoppedIndicator.className = 'stopped-indicator';
2646
+ const stoppedLabel = document.createElement('span');
2647
+ stoppedLabel.textContent = '[Message interrupted]';
2648
+ stoppedIndicator.appendChild(stoppedLabel);
2649
+ const continueBtn = document.createElement('button');
2650
+ continueBtn.className = 'continue-btn';
2651
+ continueBtn.title = 'Continue';
2652
+ continueBtn.textContent = '\u25B8';
2653
+ continueBtn.addEventListener('click', () => {
2654
+ stoppedIndicator.remove();
2655
+ _hideUserBubble = true;
2656
+ _pendingContinue = holder;
2657
+ const cutoff = accumulated;
2658
+ const msgInput = uiModule.el('message');
2659
+ if (msgInput) {
2660
+ msgInput.value = 'Your previous response was interrupted. It ended with:\n\n' + cutoff.slice(-500) + '\n\nDo NOT repeat what you already said. Continue exactly from where you were cut off.';
2661
+ const sb = document.querySelector('.send-btn');
2662
+ if (sb) sb.click();
2663
+ }
2664
+ });
2665
+ stoppedIndicator.appendChild(continueBtn);
2666
+ holder.querySelector('.body').appendChild(stoppedIndicator);
2667
+
2668
+ // Tell server to mark this message as stopped
2669
+ const _sid2 = sessionModule.getCurrentSessionId();
2670
+ if (_sid2) fetch(`${API_BASE}/api/session/${_sid2}/mark-stopped`, { method: 'POST' }).catch(e => console.warn('mark-stopped failed:', e));
2671
+
2672
+ if (!holder.querySelector('.msg-footer')) {
2673
+ holder.appendChild(createMsgFooter(holder));
2674
+ }
2675
+
2676
+ uiModule.scrollHistory();
2677
+ }
2678
+
2679
+ // Now clear the abort controller
2680
+ currentAbort = null;
2681
+ } else {
2682
+ console.error(err);
2683
+ // Stream died with a tool node still spinning. Its per-node tickers
2684
+ // (_elapsedTicker 50ms / _waveInterval 100ms) are normally cleared in
2685
+ // `tool_output`, which will never arrive now — without this sweep they
2686
+ // fire forever on the orphaned node (and auto-recover compounds it per
2687
+ // nudge). Safe here: auto-recover's new send is deferred 200ms, so no
2688
+ // fresh running nodes exist yet.
2689
+ document.querySelectorAll('.agent-thread-node.running').forEach(node => {
2690
+ if (node._waveInterval) { clearInterval(node._waveInterval); node._waveInterval = null; }
2691
+ if (node._elapsedTicker) { clearInterval(node._elapsedTicker); node._elapsedTicker = null; }
2692
+ node.classList.remove('running');
2693
+ });
2694
+ // Stream died unexpectedly — the "silently died" case. Re-engage the
2695
+ // model immediately (no wait) with a completion handshake, up to the
2696
+ // cap. Only auto-recover from connection-class failures; deterministic
2697
+ // errors (unsupported tools, 4xx/5xx, parse failures) surface right away
2698
+ // instead of burning the nudge budget on a guaranteed-to-fail retry.
2699
+ if (!(_isRecoverableStreamErr(err) && _tryAutoRecover(holder, accumulated, streamSessionId))) {
2700
+ const errorHolder = document.querySelector('.msg-ai:last-of-type .body');
2701
+ if (errorHolder) {
2702
+ let errMsg = `Error: ${err.message}`;
2703
+ // Add hint for tool-call errors
2704
+ if (err.message && (err.message.includes('tool') || err.message.includes('auto'))) {
2705
+ errMsg += '\n\nThis model may not support tools — try switching to Chat mode.';
2706
+ }
2707
+ typewriterInto(errorHolder, errMsg);
2708
+ }
2709
+ }
2710
+ }
2711
+ }
2712
+ } finally {
2713
+ clearProcessingProbe();
2714
+ // Streaming done — let screen readers announce the settled response.
2715
+ const _chatLogDone = document.getElementById('chat-history');
2716
+ if (_chatLogDone) _chatLogDone.setAttribute('aria-busy', 'false');
2717
+ // Always clean up research tracking regardless of background state
2718
+ _researchingStreamIds.delete(streamSessionId);
2719
+ if (_researchingStreamIds.size === 0) {
2720
+ var _rToggleCleanup = document.getElementById('research-toggle-btn');
2721
+ if (_rToggleCleanup) _rToggleCleanup.classList.remove('research-running');
2722
+ }
2723
+
2724
+ // Only reset UI state if still on the stream's session and was never backgrounded
2725
+ const _isBgFinally = (sessionModule.getCurrentSessionId() !== streamSessionId) || _backgroundStreams.has(streamSessionId);
2726
+
2727
+ if (!_isBgFinally) {
2728
+ // Reset button to idle state
2729
+ updateSubmitButton('idle', submitBtn);
2730
+
2731
+ // Re-enable message input; on mobile blur to dismiss keyboard
2732
+ if (messageInput) {
2733
+ messageInput.disabled = false;
2734
+ if (window.innerWidth <= 768) {
2735
+ messageInput.blur();
2736
+ } else {
2737
+ messageInput.focus();
2738
+ }
2739
+ }
2740
+
2741
+ // Clear tracking variables
2742
+ currentAccumulated = '';
2743
+ currentHolder = null;
2744
+ currentSpinner = null;
2745
+ _researchingStreamIds.delete(streamSessionId);
2746
+ // Clear research-running highlight if no more active research
2747
+ if (_researchingStreamIds.size === 0) {
2748
+ var _rToggle2 = document.getElementById('research-toggle-btn');
2749
+ if (_rToggle2) _rToggle2.classList.remove('research-running');
2750
+ }
2751
+ _clearResearchTimer();
2752
+
2753
+ // Re-enable research button and auto-untoggle after use
2754
+ // (skip if clarification round — keep toggle on for follow-up)
2755
+ const _el = uiModule.el;
2756
+ const _researchBtn = _el('research-toggle-btn');
2757
+ const _researchToggle = _el('research-toggle');
2758
+ if (_researchToggle && _researchToggle.checked) {
2759
+ _researchToggle.checked = false;
2760
+ Storage.setToggle('research', false);
2761
+ }
2762
+ if (_researchBtn) {
2763
+ _researchBtn.disabled = false;
2764
+ _researchBtn.classList.remove('active');
2765
+ _researchBtn.style.display = 'none';
2766
+ }
2767
+ // Also sync overflow and tool sidebar buttons
2768
+ const _overflowRes = _el('overflow-research-btn');
2769
+ if (_overflowRes) _overflowRes.classList.remove('active');
2770
+ const _toolRes = _el('tool-research-btn');
2771
+ if (_toolRes) _toolRes.classList.remove('active');
2772
+
2773
+ }
2774
+
2775
+ // Research clarification timeout — if user doesn't reply within 5 min, show timeout
2776
+ if (holder && holder._roleSuffix === 'Research' && !_researchingStreamIds.has(streamSessionId)) {
2777
+ var _timeoutSessionId = streamSessionId;
2778
+ var _timeoutTimer = setTimeout(async function() {
2779
+ // Check if research_pending is still active (user hasn't replied)
2780
+ try {
2781
+ var _box = document.getElementById('chat-history');
2782
+ if (_box && sessionModule.getCurrentSessionId() === _timeoutSessionId) {
2783
+ var _timeoutMsg = document.createElement('div');
2784
+ _timeoutMsg.className = 'msg msg-ai';
2785
+ _timeoutMsg.innerHTML = '<div class="role">Zero</div><div class="body" style="opacity:0.6;font-style:italic;">Research clarification timed out. Toggle research again to start over.</div>';
2786
+ _box.appendChild(_timeoutMsg);
2787
+ uiModule.scrollHistory();
2788
+ }
2789
+ } catch(_te) {}
2790
+ }, 5 * 60 * 1000);
2791
+ // Cancel timeout if user sends a message
2792
+ var _origSubmit = window._researchTimeoutTimer;
2793
+ if (_origSubmit) clearTimeout(_origSubmit);
2794
+ window._researchTimeoutTimer = _timeoutTimer;
2795
+ }
2796
+
2797
+ // Release Web Lock
2798
+ if (_webLockRelease) {
2799
+ _webLockRelease();
2800
+ _webLockRelease = null;
2801
+ }
2802
+
2803
+ // Refresh session list after a delay (picks up auto-generated names)
2804
+ setTimeout(() => {
2805
+ if (sessionModule && sessionModule.loadSessions) {
2806
+ sessionModule.loadSessions();
2807
+ }
2808
+ }, 3000);
2809
+ }
2810
+ }
2811
+
2812
+ /**
2813
+ * Abort current chat request
2814
+ */
2815
+ // stopServer=true ONLY for an explicit user Stop. The run is now DETACHED
2816
+ // (survives tab close / navigation), so the generic abort used by cleanup
2817
+ // paths (session switch, delete, reader teardown on tab close) must NOT stop
2818
+ // the server run — otherwise closing the tab would kill the background task,
2819
+ // defeating the whole point. Only the Stop button cancels the server run.
2820
+ export function abortCurrentRequest(stopServer = false) {
2821
+ if (currentAbort) {
2822
+ currentAbort.abort();
2823
+ // Don't set to null here - let catch block handle it
2824
+ }
2825
+ if (stopServer) {
2826
+ try {
2827
+ const _sid = _streamSessionId
2828
+ || (window.sessionModule && window.sessionModule.getCurrentSessionId && window.sessionModule.getCurrentSessionId());
2829
+ if (_sid) {
2830
+ fetch(`/api/chat/stop/${encodeURIComponent(_sid)}`, { method: 'POST', credentials: 'same-origin' }).catch(() => {});
2831
+ }
2832
+ } catch (_) {}
2833
+ }
2834
+ }
2835
+
2836
+ // ── Stall watchdog ──────────────────────────────────────────────
2837
+ // Auto-recover a turn whose stream died (connection drop) or went silent:
2838
+ // preserve the partial, then re-submit a completion handshake by reusing the
2839
+ // existing continue/resume path. Returns false at the cap so the caller can
2840
+ // surface the failure instead of nudging forever.
2841
+ // Only auto-recover from connection-class failures (the genuine "silently
2842
+ // died" case). Deterministic errors — unsupported tools, HTTP 4xx/5xx, JSON
2843
+ // parse failures — will fail identically on retry, so surfacing them
2844
+ // immediately is both more honest and avoids wasting the nudge budget.
2845
+ function _isRecoverableStreamErr(err) {
2846
+ if (!err) return false;
2847
+ if (err.name === 'TypeError') return true; // fetch/reader network failure
2848
+ const m = (err.message || '').toLowerCase();
2849
+ if (/\btool\b|unsupported|json|parse|\b4\d\d\b|\b5\d\d\b/.test(m)) return false;
2850
+ return /network|fetch|connection|reset|closed|aborted|stream|tim(?:e|ed)\s?out|econn|eof/.test(m);
2851
+ }
2852
+
2853
+ function _tryAutoRecover(holder, accumulated, sessionId) {
2854
+ if (_autoNudges >= _AUTO_NUDGE_CAP) return false;
2855
+ _autoNudges++;
2856
+ if (holder && accumulated) {
2857
+ holder.dataset.raw = accumulated;
2858
+ try {
2859
+ holder.querySelector('.body').innerHTML =
2860
+ markdownModule.processWithThinking(markdownModule.squashOutsideCode(accumulated));
2861
+ } catch (_) {}
2862
+ }
2863
+ _pendingContinue = holder || null; // merge the continuation into the same bubble
2864
+ _hideUserBubble = true; // no user bubble for the handshake
2865
+ _autoContinuePending = true; // don't reset the counter on this submit
2866
+ const _abandon = () => { // clear the pending flags so they can't
2867
+ _pendingContinue = null; // leak into whatever chat is now open
2868
+ _hideUserBubble = false;
2869
+ _autoContinuePending = false;
2870
+ };
2871
+ // Defer so the stream's finally resets state first — otherwise the send
2872
+ // button is still in "stop" mode and clicking it would toggle, not send.
2873
+ setTimeout(() => {
2874
+ // The stream that died may not be the chat the user is now looking at —
2875
+ // never inject the recovery handshake into the wrong conversation.
2876
+ if (sessionId && sessionModule.getCurrentSessionId() !== sessionId) { _abandon(); return; }
2877
+ const msgInput = uiModule.el('message');
2878
+ const sb = document.querySelector('.send-btn');
2879
+ if (!msgInput || !sb) { _abandon(); return; }
2880
+ const tail = (accumulated || '').slice(-400);
2881
+ msgInput.value = tail
2882
+ ? `The stream dropped before you finished. It ended with:\n\n${tail}\n\nIf the task is fully complete, reply with just: DONE. Otherwise continue exactly where you left off and finish it — do not repeat what you already wrote.`
2883
+ : `The stream dropped before you produced anything. If the task is already done, reply with just: DONE. Otherwise complete it now.`;
2884
+ sb.click();
2885
+ }, 200);
2886
+ return true;
2887
+ }
2888
+
2889
+ function _removeStallBanner() {
2890
+ const b = document.getElementById('stall-banner');
2891
+ if (b) b.remove();
2892
+ _stallBannerShown = false;
2893
+ }
2894
+ function _showStallBanner(secs) {
2895
+ if (document.getElementById('stall-banner')) return;
2896
+ _stallBannerShown = true;
2897
+ const box = document.getElementById('chat-history');
2898
+ if (!box) return;
2899
+ const bar = document.createElement('div');
2900
+ bar.id = 'stall-banner';
2901
+ bar.className = 'stall-banner';
2902
+ const mins = Math.floor(secs / 60);
2903
+ const label = mins >= 1 ? `${mins}m` : `${secs}s`;
2904
+ bar.innerHTML = `<span class="stall-banner-txt">Quiet for ${label} — still working?</span>`;
2905
+ const cont = document.createElement('button');
2906
+ cont.className = 'stall-banner-btn';
2907
+ cont.textContent = 'Nudge it';
2908
+ cont.title = 'Stop the stalled stream and ask it to continue';
2909
+ cont.addEventListener('click', () => {
2910
+ _removeStallBanner();
2911
+ const mi = uiModule.el('message');
2912
+ if (mi) {
2913
+ mi.value = 'Are you still working? If you stopped, continue exactly where you left off and finish the task.';
2914
+ const sb = document.querySelector('.send-btn');
2915
+ if (sb) sb.click();
2916
+ }
2917
+ });
2918
+ const stop = document.createElement('button');
2919
+ stop.className = 'stall-banner-btn stall-banner-stop';
2920
+ stop.textContent = 'Stop';
2921
+ stop.addEventListener('click', () => { _removeStallBanner(); abortCurrentRequest(true); });
2922
+ bar.appendChild(cont);
2923
+ bar.appendChild(stop);
2924
+ box.appendChild(bar);
2925
+ if (uiModule.scrollHistory) uiModule.scrollHistory();
2926
+ }
2927
+ function _startStallWatchdog() {
2928
+ // Disabled: the server-side stall detector / auto-continue (agent
2929
+ // loop-breaker) handles quiet/stalled streams now, so the manual
2930
+ // "Quiet for Nm — still working?" banner is redundant (and annoying).
2931
+ if (_stallWatchdog) { clearInterval(_stallWatchdog); _stallWatchdog = null; }
2932
+ _removeStallBanner();
2933
+ }
2934
+ function _stopStallWatchdog() {
2935
+ if (_stallWatchdog) { clearInterval(_stallWatchdog); _stallWatchdog = null; }
2936
+ _removeStallBanner();
2937
+ }
2938
+
2939
+ /** Show a "Cancelled by user" record in `holder` and persist an empty
2940
+ * assistant placeholder server-side so the turn survives a refresh.
2941
+ * Called from both abort paths when no tokens had streamed yet. */
2942
+ function _renderCancelledBubble(holder) {
2943
+ if (!holder) return;
2944
+ holder.dataset.raw = '';
2945
+ const body = holder.querySelector('.body');
2946
+ if (body) {
2947
+ body.innerHTML = '';
2948
+ const indicator = document.createElement('div');
2949
+ indicator.className = 'stopped-indicator';
2950
+ const label = document.createElement('span');
2951
+ label.style.fontStyle = 'italic';
2952
+ label.style.opacity = '0.7';
2953
+ label.textContent = '[Cancelled by user]';
2954
+ indicator.appendChild(label);
2955
+ body.appendChild(indicator);
2956
+ }
2957
+ if (typeof createMsgFooter === 'function' && !holder.querySelector('.msg-footer')) {
2958
+ holder.appendChild(createMsgFooter(holder));
2959
+ }
2960
+ // Persist as an assistant message with stopped+cancelled metadata so the
2961
+ // chat-history loader renders the same indicator after a refresh.
2962
+ // Include the model name so the bubble header still shows which model
2963
+ // was running when the user hit Stop.
2964
+ const sid = sessionModule.getCurrentSessionId();
2965
+ if (sid) {
2966
+ let modelName = '';
2967
+ try { modelName = sessionModule.getCurrentModel?.() || ''; } catch {}
2968
+ // Fallback: pull from the holder's existing meta (the streaming
2969
+ // placeholder usually has the model set in the header already).
2970
+ if (!modelName) {
2971
+ modelName = holder.dataset.model
2972
+ || holder.querySelector('.msg-header .msg-model')?.textContent
2973
+ || '';
2974
+ }
2975
+ fetch(`${API_BASE}/api/session/${sid}/inject_messages`, {
2976
+ method: 'POST', credentials: 'same-origin',
2977
+ headers: { 'Content-Type': 'application/json' },
2978
+ body: JSON.stringify({
2979
+ messages: [{
2980
+ role: 'assistant',
2981
+ content: '',
2982
+ metadata: { stopped: true, cancelled: true, model: modelName },
2983
+ }],
2984
+ }),
2985
+ }).catch(() => {});
2986
+ }
2987
+ }
2988
+
2989
+ /**
2990
+ * Detach current stream to run in background instead of aborting.
2991
+ * Called when user switches sessions mid-stream.
2992
+ */
2993
+ export function detachCurrentStream(sessionId) {
2994
+ if (!isStreaming || !currentAbort) {
2995
+ // Not streaming — fall through to abort
2996
+ abortCurrentRequest();
2997
+ return;
2998
+ }
2999
+ // Store background stream state
3000
+ _backgroundStreams.set(sessionId, {
3001
+ status: 'running',
3002
+ accumulated: currentAccumulated,
3003
+ sourcesHtml: '',
3004
+ findingsData: null,
3005
+ abortCtrl: currentAbort,
3006
+ query: currentHolder ? (currentHolder._researchQuery || '') : '',
3007
+ metrics: null,
3008
+ });
3009
+ // Mark session with pulsing dot in sidebar
3010
+ if (sessionModule && sessionModule.markStreaming) {
3011
+ sessionModule.markStreaming(sessionId);
3012
+ }
3013
+ // Clear local state WITHOUT aborting the fetch
3014
+ currentAbort = null;
3015
+ isStreaming = false;
3016
+ currentHolder = null;
3017
+ currentAccumulated = '';
3018
+ // Reset submit button so the new chat is ready to send
3019
+ const submitBtn = document.querySelector('.send-btn');
3020
+ if (submitBtn) updateSubmitButton('idle', submitBtn);
3021
+ }
3022
+
3023
+ // _notifyStreamComplete and _insertStreamDoneToast now in chatStream.js
3024
+ var _notifyStreamComplete = chatStream.notifyStreamComplete;
3025
+ var _insertStreamDoneToast = chatStream.insertStreamDoneToast;
3026
+
3027
+ /**
3028
+ * Check for background streams when switching to a session.
3029
+ * Called after history loads on session switch.
3030
+ */
3031
+ export function checkBackgroundStream(sessionId) {
3032
+ if (!sessionId || !_backgroundStreams.has(sessionId)) return;
3033
+ var entry = _backgroundStreams.get(sessionId);
3034
+
3035
+ if (entry.status === 'completed') {
3036
+ // Response is already saved to DB and will appear in history — just clean up
3037
+ _backgroundStreams.delete(sessionId);
3038
+ return;
3039
+ }
3040
+
3041
+ if (entry.status === 'error') {
3042
+ _backgroundStreams.delete(sessionId);
3043
+ var box = document.getElementById('chat-history');
3044
+ if (box) {
3045
+ var errHolder = document.createElement('div');
3046
+ errHolder.className = 'msg msg-ai';
3047
+ errHolder.innerHTML = '<div class="body"><i style="color: var(--color-error);">[Background stream encountered an error]</i></div>';
3048
+ box.appendChild(errHolder);
3049
+ }
3050
+ return;
3051
+ }
3052
+
3053
+ if (entry.status === 'running') {
3054
+ // Stream is still active — show a clean spinner, poll until done,
3055
+ // then reload history to show the final saved response.
3056
+ var box = document.getElementById('chat-history');
3057
+ if (!box) return;
3058
+
3059
+ // Replay any doc content that was streamed in the background
3060
+ if (entry._docTitle != null && documentModule) {
3061
+ documentModule.streamDocOpen(entry._docTitle, entry._docLang || '');
3062
+ if (entry._docContent) {
3063
+ documentModule.streamDocDelta(entry._docContent);
3064
+ }
3065
+ }
3066
+
3067
+ var holder = document.createElement('div');
3068
+ holder.className = 'msg msg-ai';
3069
+ var meta = sessionModule.getSessions().find(function(s) { return s.id === sessionId; });
3070
+ var roleLabel = _shortModel(meta && meta.model);
3071
+ var roleTs = new Date().toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
3072
+ holder.innerHTML = '<div class="role">' + roleLabel + ' <span class="role-timestamp">' + roleTs + '</span></div><div class="body"></div>';
3073
+ _applyModelColor(holder.querySelector('.role'), meta && meta.model);
3074
+
3075
+ var bodyDiv = holder.querySelector('.body');
3076
+ var spinner = spinnerModule.create('Response streaming in background', 'right');
3077
+ bodyDiv.appendChild(spinner.createElement());
3078
+ spinner.start();
3079
+
3080
+ box.appendChild(holder);
3081
+ uiModule.scrollHistory();
3082
+
3083
+ // Poll map until stream finishes, then reload history
3084
+ var pollId = setInterval(function() {
3085
+ if (sessionModule.getCurrentSessionId() !== sessionId) {
3086
+ clearInterval(pollId);
3087
+ spinner.destroy();
3088
+ if (holder.parentNode) holder.remove();
3089
+ return;
3090
+ }
3091
+ // Update doc content while polling
3092
+ var curPoll = _backgroundStreams.get(sessionId);
3093
+ if (curPoll && curPoll._docContent && documentModule) {
3094
+ documentModule.streamDocDelta(curPoll._docContent);
3095
+ }
3096
+ if (!curPoll || curPoll.status !== 'running') {
3097
+ clearInterval(pollId);
3098
+ spinner.destroy();
3099
+ if (holder.parentNode) holder.remove(); // Remove entire holder, not just spinner
3100
+ _backgroundStreams.delete(sessionId);
3101
+ // Reload session to show the completed response — but only if the user
3102
+ // is still on it; don't yank them back from a new chat they opened.
3103
+ if (sessionModule.getCurrentSessionId && sessionModule.getCurrentSessionId() === sessionId) {
3104
+ sessionModule.selectSession(sessionId);
3105
+ } else {
3106
+ sessionModule.loadSessions();
3107
+ }
3108
+ }
3109
+ }, 500);
3110
+ }
3111
+ }
3112
+
3113
+ // Tag short single-line code blocks with .pre-compact so the CSS can
3114
+ // render the Run/Edit/Copy buttons as a slim row that doesn't make a
3115
+ // 1-line bash block taller than its own contents.
3116
+ function _markCompactPre(pre) {
3117
+ const code = pre.querySelector('code');
3118
+ if (!code) return;
3119
+ const txt = code.textContent || '';
3120
+ // Count visible lines — ignore trailing newline (common with fenced
3121
+ // blocks) and treat any empty extra line as not a real second line.
3122
+ const lines = txt.replace(/\n+$/, '').split('\n');
3123
+ const compact = lines.length <= 1 && txt.length < 200;
3124
+ pre.classList.toggle('pre-compact', compact);
3125
+ }
3126
+ function _scanCompactPres(root) {
3127
+ if (!root || !root.querySelectorAll) return;
3128
+ root.querySelectorAll('pre').forEach(_markCompactPre);
3129
+ }
3130
+ // Global observer so any <pre> added anywhere in the app (chat stream,
3131
+ // chat re-renders, document library chat previews, slash commands,
3132
+ // research previews, etc.) gets tagged without each call site needing
3133
+ // to remember.
3134
+ (function _initCompactPreObserver() {
3135
+ if (window._cmpPreObserverWired) return;
3136
+ window._cmpPreObserverWired = true;
3137
+ _scanCompactPres(document.body);
3138
+ const obs = new MutationObserver((muts) => {
3139
+ for (const m of muts) {
3140
+ for (const n of m.addedNodes) {
3141
+ if (n.nodeType !== 1) continue;
3142
+ if (n.tagName === 'PRE') _markCompactPre(n);
3143
+ if (n.querySelectorAll) _scanCompactPres(n);
3144
+ }
3145
+ }
3146
+ });
3147
+ obs.observe(document.body, { childList: true, subtree: true });
3148
+ })();
3149
+
3150
+ /**
3151
+ * Initialize event listeners
3152
+ */
3153
+ export function initListeners() {
3154
+ // Global event delegation for copy-code buttons
3155
+ document.addEventListener('click', (e) => {
3156
+ const btn = e.target.closest('.copy-code');
3157
+ if (!btn) return;
3158
+ e.stopPropagation();
3159
+ const code = btn.getAttribute('data-code');
3160
+ if (code && uiModule) {
3161
+ uiModule.copyToClipboard(code);
3162
+ // Visual feedback: swap the icon to a checkmark (regular size)
3163
+ // and add .copied which the CSS uses to flash green + pulse.
3164
+ // For slim/.pre-compact buttons the label text comes from a
3165
+ // CSS ::before — swap it via data-state so we don't break the
3166
+ // text-button layout.
3167
+ const origHTML = btn.innerHTML;
3168
+ const isCompact = !!btn.closest('pre.pre-compact');
3169
+ if (!isCompact) {
3170
+ btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
3171
+ }
3172
+ btn.classList.add('copied');
3173
+ btn.dataset.state = 'copied';
3174
+ setTimeout(() => {
3175
+ if (!isCompact) btn.innerHTML = origHTML;
3176
+ btn.classList.remove('copied');
3177
+ delete btn.dataset.state;
3178
+ }, 1500);
3179
+ }
3180
+ });
3181
+
3182
+ // Run code button delegation
3183
+ document.addEventListener('click', (e) => {
3184
+ const btn = e.target.closest('.run-code');
3185
+ if (!btn) return;
3186
+ e.stopPropagation();
3187
+ if (codeRunnerModule) codeRunnerModule.run(btn);
3188
+ });
3189
+
3190
+ // Edit code button delegation — toggle contentEditable on the code element
3191
+ document.addEventListener('click', (e) => {
3192
+ const btn = e.target.closest('.edit-code');
3193
+ if (!btn) return;
3194
+ e.stopPropagation();
3195
+ const pre = btn.closest('pre');
3196
+ if (!pre) return;
3197
+ const codeEl = pre.querySelector('code');
3198
+ if (!codeEl) return;
3199
+ const isEditing = codeEl.contentEditable !== 'false' && codeEl.contentEditable !== 'inherit';
3200
+ if (isEditing) {
3201
+ // Save: exit edit mode, update data-code on copy/run buttons
3202
+ codeEl.contentEditable = 'false';
3203
+ codeEl.classList.remove('editing');
3204
+ pre.classList.remove('editing');
3205
+ const newCode = codeEl.textContent;
3206
+ const copyBtn = pre.querySelector('.copy-code');
3207
+ if (copyBtn) copyBtn.setAttribute('data-code', newCode);
3208
+ const runBtn = pre.querySelector('.run-code');
3209
+ if (runBtn) runBtn.setAttribute('data-code', newCode);
3210
+ // Swap icon back to pencil
3211
+ btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>';
3212
+ btn.title = 'Edit';
3213
+ btn.classList.remove('active');
3214
+ } else {
3215
+ // Enter edit mode. Firefox (especially on mobile) historically lacks
3216
+ // contentEditable="plaintext-only" — setting it there leaves the block
3217
+ // non-editable, so the tap "just gets a checkmark" with no way to type.
3218
+ // Fall back to "true" when plaintext-only didn't take.
3219
+ try { codeEl.contentEditable = 'plaintext-only'; } catch (_) { /* unsupported value */ }
3220
+ if (codeEl.contentEditable !== 'plaintext-only') codeEl.contentEditable = 'true';
3221
+ codeEl.classList.add('editing');
3222
+ pre.classList.add('editing');
3223
+ // preventScroll keeps the page from jumping to the codeblock when
3224
+ // focusing the editable on mobile — the browser would otherwise
3225
+ // scroll it into view above the keyboard, which reads as "auto-
3226
+ // scroll triggered by clicking Edit".
3227
+ try { codeEl.focus({ preventScroll: true }); } catch (_) { codeEl.focus(); }
3228
+ // Swap icon to checkmark
3229
+ btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
3230
+ btn.title = 'Done editing';
3231
+ btn.classList.add('active');
3232
+ }
3233
+ });
3234
+
3235
+ // Tapping a code block body (not its buttons) toggles the overlay
3236
+ // copy/edit/run buttons, which otherwise cover the text on mobile.
3237
+ document.addEventListener('click', (e) => {
3238
+ if (e.target.closest('.copy-code, .edit-code, .run-code')) return;
3239
+ const pre = e.target.closest('pre');
3240
+ if (!pre || !pre.querySelector('.copy-code')) return;
3241
+ // Don't hide while editing — the buttons (incl. the Done checkmark) matter.
3242
+ if (pre.classList.contains('editing')) return;
3243
+ pre.classList.toggle('buttons-hidden');
3244
+ });
3245
+
3246
+ // Position copy/run buttons top or bottom based on viewport position
3247
+ // — DESKTOP ONLY. On mobile this was constantly retriggering on tap
3248
+ // (synthetic mouseenter) and made the buttons jump, so the user's
3249
+ // finger landed on the moved target. Keep them pinned at the top on
3250
+ // touch — no auto-repositioning.
3251
+ document.addEventListener('mouseenter', (e) => {
3252
+ if (window.matchMedia('(max-width: 768px)').matches) return;
3253
+ const pre = e.target.closest ? e.target.closest('pre') : null;
3254
+ if (!pre || pre.dataset.btnPosComputed) return;
3255
+ const rect = pre.getBoundingClientRect();
3256
+ const threshold = window.innerHeight * 0.35;
3257
+ const isBottom = rect.top < threshold;
3258
+ const copyBtn = pre.querySelector('.copy-code');
3259
+ if (copyBtn) copyBtn.classList.toggle('bottom', isBottom);
3260
+ const editBtn = pre.querySelector('.edit-code');
3261
+ if (editBtn) editBtn.classList.toggle('bottom', isBottom);
3262
+ const runBtn = pre.querySelector('.run-code');
3263
+ if (runBtn) runBtn.classList.toggle('bottom', isBottom);
3264
+ pre.dataset.btnPosComputed = '1';
3265
+ }, true);
3266
+
3267
+ // Tab suspension recovery: when user tabs back in, check if stream froze
3268
+ document.addEventListener('visibilitychange', () => {
3269
+ if (document.visibilityState !== 'visible') return;
3270
+ if (!isStreaming) return;
3271
+
3272
+ // Stream claims to be running — check if reader is actually alive
3273
+ const staleSince = Date.now() - _lastReaderActivity;
3274
+ if (staleSince < 20000) return; // Active recently, probably fine
3275
+
3276
+ // Reader hasn't produced data in 5+ seconds after tab resume.
3277
+ // Give it a short grace period then recover.
3278
+ console.warn('[tab-recovery] Stream appears frozen (no activity for ' + Math.round(staleSince/1000) + 's). Recovering...');
3279
+
3280
+ setTimeout(() => {
3281
+ // Re-check — maybe the reader woke up during the grace period
3282
+ if (!isStreaming) return;
3283
+ const stillStale = Date.now() - _lastReaderActivity;
3284
+ if (stillStale < 5000) return; // Came back to life
3285
+
3286
+ console.warn('[tab-recovery] Stream confirmed dead. Aborting and reloading session.');
3287
+
3288
+ // Abort the frozen stream, but preserve the visible bubble.
3289
+ if (currentAbort) {
3290
+ currentAbort._reason = 'recovery';
3291
+ currentAbort.abort();
3292
+ }
3293
+ isStreaming = false;
3294
+
3295
+ // Release Web Lock
3296
+ if (_webLockRelease) {
3297
+ _webLockRelease();
3298
+ _webLockRelease = null;
3299
+ }
3300
+
3301
+ // Reset UI state
3302
+ var _submitBtn = document.getElementById('submit');
3303
+ updateSubmitButton('idle', _submitBtn);
3304
+ var _msgInput = document.getElementById('message');
3305
+ if (_msgInput) _msgInput.disabled = false;
3306
+ }, 2000); // 2 second grace period
3307
+ });
3308
+
3309
+ // On mobile, fade out welcome text when keyboard opens to prevent overlap
3310
+ if (window.innerWidth <= 768) {
3311
+ const msgInput = document.getElementById('message');
3312
+ if (msgInput) {
3313
+ msgInput.addEventListener('focus', () => {
3314
+ const ws = document.getElementById('welcome-screen');
3315
+ if (ws && !ws.classList.contains('hidden')) {
3316
+ ws.classList.add('kb-hidden');
3317
+ }
3318
+ });
3319
+ msgInput.addEventListener('blur', () => {
3320
+ const ws = document.getElementById('welcome-screen');
3321
+ if (ws && !ws.classList.contains('hidden')) {
3322
+ // Delay re-show so tapping within chatbox doesn't flash
3323
+ setTimeout(() => {
3324
+ if (document.activeElement !== msgInput) {
3325
+ ws.classList.remove('kb-hidden');
3326
+ }
3327
+ }, 200);
3328
+ }
3329
+ });
3330
+ }
3331
+ // Smooth viewport resize when keyboard opens/closes
3332
+ if (window.visualViewport) {
3333
+ window.visualViewport.addEventListener('resize', () => {
3334
+ document.documentElement.style.setProperty('--vh', window.visualViewport.height + 'px');
3335
+ });
3336
+ document.documentElement.style.setProperty('--vh', window.visualViewport.height + 'px');
3337
+ }
3338
+ }
3339
+
3340
+ // If the browser discarded and restored this tab, reload the current session
3341
+ // so the user sees the server-saved partial response instead of a blank page
3342
+ if (document.wasDiscarded) {
3343
+ console.warn('[tab-recovery] Tab was discarded by browser — reloading session');
3344
+ setTimeout(() => {
3345
+ var _sid = sessionModule && sessionModule.getCurrentSessionId();
3346
+ if (_sid) sessionModule.selectSession(_sid);
3347
+ }, 500);
3348
+ }
3349
+ }
3350
+
3351
+ /**
3352
+ * Regenerate response: truncate history to the user message before this AI message,
3353
+ * then re-submit that user message.
3354
+ */
3355
+ /**
3356
+ * Edit a user message: show an input, truncate to before it, resubmit the edited text.
3357
+ */
3358
+ export async function editUserMessage(userMsgElement) {
3359
+ const box = document.getElementById('chat-history');
3360
+ const allMsgs = Array.from(box.querySelectorAll('.msg'));
3361
+ const msgIndex = allMsgs.indexOf(userMsgElement);
3362
+ if (msgIndex < 0) return;
3363
+
3364
+ const bodyEl = userMsgElement.querySelector('.body');
3365
+ const currentText = bodyEl ? bodyEl.textContent.trim().replace(/\s*\[\d+ attachment\(s\)\]$/, '') : '';
3366
+
3367
+ // Replace body with an editable textarea
3368
+ const editor = document.createElement('textarea');
3369
+ editor.className = 'edit-textarea';
3370
+ editor.value = currentText;
3371
+ editor.rows = Math.max(2, currentText.split('\n').length);
3372
+
3373
+ const btnRow = document.createElement('div');
3374
+ btnRow.style.cssText = 'display:flex; gap:6px; margin-top:4px;';
3375
+
3376
+ const saveBtn = document.createElement('button');
3377
+ saveBtn.className = 'edit-save-btn';
3378
+ saveBtn.textContent = 'Send';
3379
+ const cancelBtn = document.createElement('button');
3380
+ cancelBtn.className = 'edit-cancel-btn';
3381
+ cancelBtn.textContent = 'Cancel';
3382
+ btnRow.appendChild(saveBtn);
3383
+ btnRow.appendChild(cancelBtn);
3384
+
3385
+ const originalHTML = bodyEl.innerHTML;
3386
+ bodyEl.innerHTML = '';
3387
+ bodyEl.appendChild(editor);
3388
+ bodyEl.appendChild(btnRow);
3389
+ editor.focus();
3390
+
3391
+ cancelBtn.addEventListener('click', (e) => {
3392
+ e.stopPropagation();
3393
+ bodyEl.innerHTML = originalHTML;
3394
+ });
3395
+
3396
+ saveBtn.addEventListener('click', async (e) => {
3397
+ e.stopPropagation();
3398
+ const newText = editor.value.trim();
3399
+ if (!newText) return;
3400
+
3401
+ const sessionId = sessionModule.getCurrentSessionId();
3402
+ if (!sessionId) return;
3403
+
3404
+ const keepCount = msgIndex;
3405
+ try {
3406
+ await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, {
3407
+ method: 'POST',
3408
+ headers: { 'Content-Type': 'application/json' },
3409
+ body: JSON.stringify({ keep_count: keepCount })
3410
+ });
3411
+
3412
+ // Remove DOM elements from msgIndex onward
3413
+ for (let i = allMsgs.length - 1; i >= msgIndex; i--) {
3414
+ allMsgs[i].remove();
3415
+ }
3416
+
3417
+ // Submit the edited text
3418
+ const messageInput = uiModule.el('message');
3419
+ messageInput.value = newText;
3420
+ const submitBtn = document.querySelector('.send-btn');
3421
+ if (submitBtn) submitBtn.click();
3422
+ } catch (err) {
3423
+ console.error('Edit failed:', err);
3424
+ if (uiModule) uiModule.showError('Edit failed: ' + err.message);
3425
+ bodyEl.innerHTML = originalHTML;
3426
+ }
3427
+ });
3428
+
3429
+ // Also submit on Enter (without shift)
3430
+ editor.addEventListener('keydown', (e) => {
3431
+ if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
3432
+ e.preventDefault();
3433
+ saveBtn.click();
3434
+ }
3435
+ });
3436
+ }
3437
+
3438
+ /**
3439
+ * Resend a user message — truncates history to that point and resubmits.
3440
+ */
3441
+ export async function resendUserMessage(userMsgElement) {
3442
+ const box = document.getElementById('chat-history');
3443
+ const allMsgs = Array.from(box.querySelectorAll('.msg'));
3444
+ const msgIndex = allMsgs.indexOf(userMsgElement);
3445
+ if (msgIndex < 0) return;
3446
+
3447
+ // Prefer dataset.raw (stripped original user text) over .body.textContent
3448
+ // — the latter slurps the rendered "View image description" collapsible
3449
+ // content too, which would then be sent back as the user's question and
3450
+ // the AI would reply to that gibberish instead of the actual prompt.
3451
+ const bodyEl = userMsgElement.querySelector('.body');
3452
+ let text = (userMsgElement.dataset.raw || (bodyEl ? bodyEl.textContent : '') || '').trim();
3453
+ text = text.replace(/\s*\[\d+ attachment\(s\)\]$/, '');
3454
+
3455
+ // Collect file_ids attached to this user message so the resend re-carries
3456
+ // the photos / docs (and the chat handler picks up the user-edited OCR
3457
+ // text cached server-side under those file ids).
3458
+ const _attachEls = userMsgElement.querySelectorAll('[data-file-id]');
3459
+ let _ids = Array.from(_attachEls).map(el => el.dataset.fileId).filter(Boolean);
3460
+ if (!_ids.length) {
3461
+ const _imgs = userMsgElement.querySelectorAll('.attach-image-preview img, .attach-card img');
3462
+ for (const _im of _imgs) {
3463
+ const _m = (_im.getAttribute('src') || '').match(/\/api\/upload\/([A-Za-z0-9_\-]+)/);
3464
+ if (_m && _m[1] && !_ids.includes(_m[1])) _ids.push(_m[1]);
3465
+ }
3466
+ }
3467
+
3468
+ // Rescue: legacy bubbles may have stored the filename as the message
3469
+ // content (artifact of earlier broken resends). Don't re-send that as
3470
+ // the user prompt if we still have the file attached. Loosen the regex
3471
+ // to cover real-world camera/screenshot names with spaces, parens,
3472
+ // multi-dots: "Screen Shot 2026-05-28 at 4.05.32 PM.png", "IMG (1).JPG".
3473
+ if (text && _ids.length && /^[^\n\r]{1,200}\.(png|jpe?g|gif|webp|svg|bmp|heic|heif)$/i.test(text)) {
3474
+ text = '';
3475
+ }
3476
+ // Empty text + no attachments → tell the user instead of silently bailing.
3477
+ // The common case is a regen during a pre-upload race where the bubble
3478
+ // never had an `[data-file-id]` to scrape.
3479
+ if (!text && !_ids.length) {
3480
+ if (uiModule?.showError) uiModule.showError('Nothing to resend — message has no text and no attachments yet (try again after the upload finishes).');
3481
+ return;
3482
+ }
3483
+
3484
+ const sessionId = sessionModule.getCurrentSessionId();
3485
+ if (!sessionId) return;
3486
+
3487
+ // Truncate backend to keep everything before this user message
3488
+ const keepCount = msgIndex;
3489
+ try {
3490
+ await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, {
3491
+ method: 'POST',
3492
+ headers: { 'Content-Type': 'application/json' },
3493
+ body: JSON.stringify({ keep_count: keepCount })
3494
+ });
3495
+
3496
+ // Drop the AI replies after the user message but KEEP the user bubble
3497
+ // itself (so its photo stays visible). Then suppress the new user
3498
+ // bubble that send would otherwise add — same pattern as regenerate.
3499
+ let sibling = userMsgElement.nextSibling;
3500
+ while (sibling) {
3501
+ const next = sibling.nextSibling;
3502
+ sibling.remove();
3503
+ sibling = next;
3504
+ }
3505
+ _hideUserBubble = true;
3506
+ _pendingRegenAttachments = _ids;
3507
+
3508
+ // Resubmit
3509
+ const messageInput = uiModule.el('message');
3510
+ messageInput.value = text;
3511
+ const submitBtn = document.querySelector('.send-btn');
3512
+ if (submitBtn) submitBtn.click();
3513
+ } catch (err) {
3514
+ console.error('Resend failed:', err);
3515
+ if (uiModule) uiModule.showError('Resend failed: ' + err.message);
3516
+ }
3517
+ }
3518
+
3519
+ export async function regenerateFrom(aiMsgElement) {
3520
+ const box = document.getElementById('chat-history');
3521
+ const allMsgs = Array.from(box.querySelectorAll('.msg'));
3522
+ const aiIndex = allMsgs.indexOf(aiMsgElement);
3523
+ if (aiIndex < 0) return;
3524
+
3525
+ // Find the preceding user message
3526
+ let userIndex = -1;
3527
+ let userText = '';
3528
+ let userMsgEl = null;
3529
+ for (let i = aiIndex - 1; i >= 0; i--) {
3530
+ if (allMsgs[i].classList.contains('msg-user')) {
3531
+ userIndex = i;
3532
+ userMsgEl = allMsgs[i];
3533
+ // Prefer dataset.raw (set by addMessage with the stripped, original
3534
+ // user text) over the rendered body's textContent — the latter
3535
+ // pulls in the "View image description" collapsible content too,
3536
+ // duplicating the OCR text on regen.
3537
+ const bodyEl = userMsgEl.querySelector('.body');
3538
+ userText = (userMsgEl.dataset.raw || (bodyEl ? bodyEl.textContent : '') || '').trim();
3539
+ userText = userText.replace(/\s*\[\d+ attachment\(s\)\]$/, '');
3540
+ break;
3541
+ }
3542
+ }
3543
+
3544
+ if (userIndex < 0) {
3545
+ if (uiModule) uiModule.showError('Could not find the user message to regenerate');
3546
+ return;
3547
+ }
3548
+
3549
+ // Collect any file_ids attached to the original user message so the
3550
+ // regenerated send re-uses them. Without this the AI is regenerated on
3551
+ // text alone — photos (and the user-edited OCR text cached server-side
3552
+ // under that file_id) would be silently dropped.
3553
+ const _attachEls = userMsgEl ? userMsgEl.querySelectorAll('[data-file-id]') : [];
3554
+ let _regenIds = Array.from(_attachEls).map(el => el.dataset.fileId).filter(Boolean);
3555
+ // Fallback for bubbles rendered before the data-file-id stamp landed:
3556
+ // sniff the file id straight out of any `.attach-image-preview img`
3557
+ // src URLs (matches /api/upload/<id>). Otherwise an older bubble would
3558
+ // regen with zero attachments and the photo would be lost from the
3559
+ // resulting message even though the file still exists on disk.
3560
+ if (!_regenIds.length && userMsgEl) {
3561
+ const _imgs = userMsgEl.querySelectorAll('.attach-image-preview img, .attach-card img');
3562
+ for (const _im of _imgs) {
3563
+ const _m = (_im.getAttribute('src') || '').match(/\/api\/upload\/([A-Za-z0-9_\-]+)/);
3564
+ if (_m && _m[1] && !_regenIds.includes(_m[1])) _regenIds.push(_m[1]);
3565
+ }
3566
+ }
3567
+ _pendingRegenAttachments = _regenIds;
3568
+
3569
+ // Rescue: earlier-version regens (before the dataset.raw fix) stored the
3570
+ // photo's filename as the user-message content. On a follow-up regen,
3571
+ // that filename would be sent back as the literal user prompt, so the
3572
+ // AI thinks the question is "blue_night_preview.jpg" and replies "that's
3573
+ // an image file". If userText is just a bare image filename and we have
3574
+ // attachments, drop it so the OCR text (or the image bytes for vision
3575
+ // models) is what the model actually sees.
3576
+ if (userText && _pendingRegenAttachments.length &&
3577
+ /^[^\n\r]{1,200}\.(png|jpe?g|gif|webp|svg|bmp|heic|heif)$/i.test(userText.trim())) {
3578
+ userText = '';
3579
+ }
3580
+
3581
+ // A photo-only message has empty user text — regen must still proceed,
3582
+ // because the attachments themselves are the message. Bail only if there
3583
+ // is no text AND no attachments to send.
3584
+ if (!userText && !_pendingRegenAttachments.length) {
3585
+ if (uiModule) uiModule.showError('Nothing to regenerate — the user message has no text and no attachments');
3586
+ return;
3587
+ }
3588
+
3589
+ const sessionId = sessionModule.getCurrentSessionId();
3590
+ if (!sessionId) return;
3591
+
3592
+ // Save current response as a variant
3593
+ const oldRaw = aiMsgElement.dataset.raw || aiMsgElement.querySelector('.body')?.textContent || '';
3594
+ const oldHtml = aiMsgElement.querySelector('.body')?.innerHTML || '';
3595
+ let variants = [];
3596
+ try { variants = JSON.parse(aiMsgElement.dataset.variants || '[]'); } catch(_) {}
3597
+ if (variants.length === 0) {
3598
+ // First regen — save the original as variant 0
3599
+ variants.push({ raw: oldRaw, html: oldHtml, label: 'original' });
3600
+ }
3601
+
3602
+ const keepCount = userIndex;
3603
+
3604
+ try {
3605
+ await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, {
3606
+ method: 'POST',
3607
+ headers: { 'Content-Type': 'application/json' },
3608
+ body: JSON.stringify({ keep_count: keepCount })
3609
+ });
3610
+
3611
+ for (let i = allMsgs.length - 1; i > aiIndex; i--) {
3612
+ allMsgs[i].remove();
3613
+ }
3614
+
3615
+ // Remove the AI message from DOM — it will be replaced by the new streaming response
3616
+ // But first, stash the variants data so we can transfer it to the new element
3617
+ _pendingVariants = variants;
3618
+ _pendingVariantLabel = 'regen';
3619
+ aiMsgElement.remove();
3620
+
3621
+ _hideUserBubble = true;
3622
+ const messageInput = uiModule.el('message');
3623
+ messageInput.value = userText;
3624
+ const submitBtn = document.querySelector('.send-btn');
3625
+ if (submitBtn) submitBtn.click();
3626
+
3627
+ } catch (err) {
3628
+ console.error('Regenerate failed:', err);
3629
+ if (uiModule) uiModule.showError('Regenerate failed: ' + err.message);
3630
+ }
3631
+ }
3632
+
3633
+ // Pending variants from a regeneration — transferred to new streaming element
3634
+ let _pendingVariants = null;
3635
+ let _pendingVariantLabel = null;
3636
+ // File-ids carried over from the original user message during a regen, so
3637
+ // photos / OCR overrides survive into the new send. Consumed once.
3638
+ let _pendingRegenAttachments = null;
3639
+
3640
+ /**
3641
+ * Called after streaming completes to attach variant navigation if this was a regen.
3642
+ */
3643
+ function _attachVariantNav(msgElement) {
3644
+ if (!_pendingVariants) return;
3645
+ const variants = _pendingVariants;
3646
+ _pendingVariants = null;
3647
+
3648
+ // Add the new response as the latest variant
3649
+ const newRaw = msgElement.dataset.raw || msgElement.querySelector('.body')?.textContent || '';
3650
+ const newHtml = msgElement.querySelector('.body')?.innerHTML || '';
3651
+ const varLabel = _pendingVariantLabel || 'regen';
3652
+ _pendingVariantLabel = null;
3653
+ variants.push({ raw: newRaw, html: newHtml, label: varLabel });
3654
+
3655
+ msgElement.dataset.variants = JSON.stringify(variants);
3656
+ msgElement.dataset.variantIndex = String(variants.length - 1);
3657
+
3658
+ _renderVariantNav(msgElement, variants, variants.length - 1);
3659
+
3660
+ // Persist variants to server
3661
+ const sid = sessionModule.getCurrentSessionId();
3662
+ if (sid) {
3663
+ fetch(`${API_BASE}/api/session/${sid}/update-last-meta`, {
3664
+ method: 'POST',
3665
+ headers: { 'Content-Type': 'application/json' },
3666
+ body: JSON.stringify({ metadata: { variants: variants, variantIndex: variants.length - 1 } })
3667
+ }).catch(e => console.warn('update-last-meta (variants) failed:', e));
3668
+ }
3669
+ }
3670
+
3671
+ const _VARIANT_ICONS = { regen: '\u21BB', shorter: '\u2702', simpler: '?', original: '\u25CB' };
3672
+ function _variantTagText(label) {
3673
+ return _VARIANT_ICONS[label] || _VARIANT_ICONS['original'];
3674
+ }
3675
+
3676
+ function _renderVariantNav(msgElement, variants, currentIdx) {
3677
+ // Remove existing nav if any
3678
+ const old = msgElement.querySelector('.variant-nav');
3679
+ if (old) old.remove();
3680
+
3681
+ if (variants.length < 2) return;
3682
+
3683
+ const nav = document.createElement('span');
3684
+ nav.className = 'variant-nav';
3685
+ nav.addEventListener('click', (e) => e.stopPropagation());
3686
+
3687
+ // Label showing what this variant is
3688
+ // Divider
3689
+ const divider = document.createElement('span');
3690
+ divider.className = 'variant-divider';
3691
+ divider.textContent = '|';
3692
+ nav.appendChild(divider);
3693
+
3694
+ // Label
3695
+ const curVariant = variants[currentIdx];
3696
+ const tagLabel = document.createElement('span');
3697
+ tagLabel.className = 'variant-tag' + (curVariant?.label === 'shorter' ? ' variant-tag-scissors' : '');
3698
+ tagLabel.textContent = _variantTagText(curVariant?.label);
3699
+ nav.appendChild(tagLabel);
3700
+
3701
+ // < button
3702
+ const prevBtn = document.createElement('button');
3703
+ prevBtn.className = 'variant-btn';
3704
+ prevBtn.textContent = '<';
3705
+ prevBtn.disabled = currentIdx === 0;
3706
+ prevBtn.addEventListener('click', (e) => { e.stopPropagation(); _switchVariant(msgElement, variants, currentIdx - 1); });
3707
+ nav.appendChild(prevBtn);
3708
+
3709
+ // Clickable number for current index (click left number = go left, right = go right)
3710
+ const numLeft = document.createElement('button');
3711
+ numLeft.className = 'variant-num';
3712
+ numLeft.textContent = String(currentIdx + 1);
3713
+ numLeft.disabled = currentIdx === 0;
3714
+ numLeft.addEventListener('click', (e) => { e.stopPropagation(); _switchVariant(msgElement, variants, currentIdx - 1); });
3715
+ nav.appendChild(numLeft);
3716
+
3717
+ const slash = document.createElement('span');
3718
+ slash.className = 'variant-slash';
3719
+ slash.textContent = '/';
3720
+ nav.appendChild(slash);
3721
+
3722
+ const numRight = document.createElement('button');
3723
+ numRight.className = 'variant-num';
3724
+ numRight.textContent = String(variants.length);
3725
+ numRight.disabled = currentIdx === variants.length - 1;
3726
+ numRight.addEventListener('click', (e) => { e.stopPropagation(); _switchVariant(msgElement, variants, currentIdx + 1); });
3727
+ nav.appendChild(numRight);
3728
+
3729
+ // > button
3730
+ const nextBtn = document.createElement('button');
3731
+ nextBtn.className = 'variant-btn';
3732
+ nextBtn.textContent = '>';
3733
+ nextBtn.disabled = currentIdx === variants.length - 1;
3734
+ nextBtn.addEventListener('click', (e) => { e.stopPropagation(); _switchVariant(msgElement, variants, currentIdx + 1); });
3735
+ nav.appendChild(nextBtn);
3736
+
3737
+ // Insert into the .role header
3738
+ const roleEl = msgElement.querySelector('.role');
3739
+ if (roleEl) {
3740
+ roleEl.appendChild(nav);
3741
+ } else {
3742
+ msgElement.appendChild(nav);
3743
+ }
3744
+ }
3745
+
3746
+ function _switchVariant(msgElement, variants, newIdx) {
3747
+ if (newIdx < 0 || newIdx >= variants.length) return;
3748
+ const v = variants[newIdx];
3749
+ const body = msgElement.querySelector('.body');
3750
+ if (body) body.innerHTML = v.html;
3751
+ msgElement.dataset.raw = v.raw;
3752
+ msgElement.dataset.variantIndex = String(newIdx);
3753
+ if (window.hljs) {
3754
+ msgElement.querySelectorAll('pre code').forEach(block => window.hljs.highlightElement(block));
3755
+ }
3756
+ _renderVariantNav(msgElement, variants, newIdx);
3757
+
3758
+ // Persist selected variant to server
3759
+ const sid = sessionModule.getCurrentSessionId();
3760
+ if (sid) {
3761
+ fetch(`${API_BASE}/api/session/${sid}/update-last-meta`, {
3762
+ method: 'POST',
3763
+ headers: { 'Content-Type': 'application/json' },
3764
+ body: JSON.stringify({ metadata: { variantIndex: newIdx } })
3765
+ }).catch(e => console.warn('update-last-meta (variantIndex) failed:', e));
3766
+ }
3767
+ }
3768
+
3769
+ export async function forkFrom(aiMsgElement) {
3770
+ const box = document.getElementById('chat-history');
3771
+ const allMsgs = Array.from(box.querySelectorAll('.msg'));
3772
+ const aiIndex = allMsgs.indexOf(aiMsgElement);
3773
+ if (aiIndex < 0) return;
3774
+
3775
+ const sessionId = sessionModule.getCurrentSessionId();
3776
+ if (!sessionId) return;
3777
+
3778
+ const keepCount = aiIndex + 1;
3779
+
3780
+ try {
3781
+ const res = await fetch(`${API_BASE}/api/session/${sessionId}/fork`, {
3782
+ method: 'POST',
3783
+ headers: { 'Content-Type': 'application/json' },
3784
+ body: JSON.stringify({ keep_count: keepCount }),
3785
+ });
3786
+ if (!res.ok) throw new Error(await res.text());
3787
+ const data = await res.json();
3788
+
3789
+ await sessionModule.loadSessions();
3790
+ await sessionModule.selectSession(data.id);
3791
+ if (uiModule) uiModule.showToast(`Forked → ${data.name}`);
3792
+ } catch (err) {
3793
+ console.error('Fork failed:', err);
3794
+ if (uiModule) uiModule.showError('Fork failed: ' + err.message);
3795
+ }
3796
+ }
3797
+
3798
+ /**
3799
+ * Check for pending/completed research after page refresh or session switch.
3800
+ * If research is still running, show a spinner and poll until done.
3801
+ * If research is done, fetch result and render it.
3802
+ */
3803
+ export async function checkPendingResearch(sessionId) {
3804
+ if (!sessionId) return;
3805
+ try {
3806
+ const res = await fetch(`${API_BASE}/api/research/status/${sessionId}`);
3807
+ if (!res.ok) return; // 404 = no research for this session
3808
+ const data = await res.json();
3809
+
3810
+ if (data.status === 'done') {
3811
+ // Fetch and render the completed result
3812
+ _notifyResearchComplete(sessionId, data.query || '');
3813
+ if (sessionModule && sessionModule.clearResearching) sessionModule.clearResearching(sessionId);
3814
+ const resultRes = await fetch(`${API_BASE}/api/research/result/${sessionId}`, { method: 'POST' });
3815
+ if (resultRes.ok) {
3816
+ const resultData = await resultRes.json();
3817
+ if (resultData.result) {
3818
+ // Skip if history already has a research message for this session
3819
+ if (document.querySelector(`#chat-history .msg-ai[data-research-session="${sessionId}"]`)) return;
3820
+
3821
+ var srcBox = '';
3822
+ if (resultData.sources && resultData.sources.length > 0) {
3823
+ srcBox = _buildSourcesBox(resultData.sources, 'research');
3824
+ }
3825
+ var findingsBox = chatRenderer.buildFindingsBox(resultData.raw_findings);
3826
+ var cleanResult = resultData.result;
3827
+ // Build DOM directly to avoid double-processing through addMessage
3828
+ chatRenderer.hideWelcomeScreen();
3829
+ var _box = document.getElementById('chat-history');
3830
+ if (_box) {
3831
+ var _wrap = document.createElement('div');
3832
+ _wrap.className = 'msg msg-ai';
3833
+ _wrap.dataset.researchSession = sessionId;
3834
+ var _role = document.createElement('div');
3835
+ _role.className = 'role';
3836
+ var _meta = sessionModule.getSessions().find(function(s) { return s.id === sessionId; });
3837
+ _role.textContent = _shortModel(_meta?.model);
3838
+ _applyModelColor(_role, _meta?.model);
3839
+ _role.appendChild(chatRenderer.roleTimestamp());
3840
+ var _body = document.createElement('div');
3841
+ _body.className = 'body';
3842
+ _body.innerHTML = srcBox + markdownModule.processWithThinking(
3843
+ markdownModule.squashOutsideCode(cleanResult)
3844
+ ) + findingsBox;
3845
+ _wrap.dataset.raw = cleanResult;
3846
+ _wrap.appendChild(_role);
3847
+ _wrap.appendChild(_body);
3848
+ _wrap.appendChild(chatRenderer.createMsgFooter(_wrap));
3849
+ _appendViewReportLink(_wrap, sessionId);
3850
+ _box.appendChild(_wrap);
3851
+ if (window.hljs) _wrap.querySelectorAll('pre code').forEach(function(b) { window.hljs.highlightElement(b); });
3852
+ uiModule.scrollHistory();
3853
+ }
3854
+ }
3855
+ }
3856
+ return;
3857
+ }
3858
+
3859
+ if (data.status !== 'running') return;
3860
+
3861
+ // Don't show reconnect UI if we've already switched away
3862
+ if (sessionModule.getCurrentSessionId() !== sessionId) return;
3863
+
3864
+ // Research is still running — show reconnect UI with spinner
3865
+ const box = document.getElementById('chat-history');
3866
+ if (!box) return;
3867
+
3868
+ const holder = document.createElement('div');
3869
+ holder.className = 'msg msg-ai research-reconnect';
3870
+ holder.dataset.researchSession = sessionId;
3871
+ const roleTs = new Date().toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
3872
+ const agentMeta = sessionModule.getSessions().find(s => s.id === sessionModule.getCurrentSessionId());
3873
+ const agentModelLabel = _shortModel(agentMeta?.model);
3874
+ holder.innerHTML = `<div class="role">${agentModelLabel} <span class="role-timestamp">${roleTs}</span></div><div class="body"></div>`;
3875
+ _applyModelColor(holder.querySelector('.role'), agentMeta?.model);
3876
+ box.appendChild(holder);
3877
+
3878
+ const bodyDiv = holder.querySelector('.body');
3879
+ const spinner = spinnerModule.create('Reconnecting to research...', 'right');
3880
+ bodyDiv.appendChild(spinner.createElement());
3881
+ spinner.start();
3882
+
3883
+ // Update spinner with current progress if available
3884
+ function updateSpinnerFromProgress(progress) {
3885
+ if (!progress || !progress.phase) return;
3886
+ const rp = progress;
3887
+ if (rp.phase === 'probing') {
3888
+ spinner.updateMessage(`Verifying model: ${rp.model || '?'}`);
3889
+ } else if (rp.phase === 'planning') {
3890
+ spinner.updateMessage('Analyzing question & planning research strategy');
3891
+ } else if (rp.phase === 'searching') {
3892
+ const q = rp.queries ? `${rp.queries} queries` : '';
3893
+ const s = rp.total_sources ? ` · ${rp.total_sources} sources` : '';
3894
+ spinner.updateMessage(`Round ${rp.round || '?'}: Searching${q ? ' (' + q + ')' : ''}${s}`);
3895
+ } else if (rp.phase === 'reading') {
3896
+ spinner.updateMessage(rp.title ? `Reading: ${rp.title}` : `Round ${rp.round || '?'}: Reading ${rp.new_sources || ''} pages · ${rp.total_sources || 0} sources total`);
3897
+ } else if (rp.phase === 'analyzing') {
3898
+ spinner.updateMessage(`Round ${rp.round || '?'}: Analyzing ${rp.total_findings || 0} findings`);
3899
+ } else if (rp.phase === 'writing') {
3900
+ spinner.updateMessage(`Writing report · ${rp.total_sources || 0} sources`);
3901
+ }
3902
+ }
3903
+
3904
+ updateSpinnerFromProgress(data.progress);
3905
+ _researchingStreamIds.add(sessionId);
3906
+ if (sessionModule && sessionModule.markResearching) sessionModule.markResearching(sessionId);
3907
+
3908
+ // Restore research timer from started_at
3909
+ if (data.started_at && spinner && spinner.element) {
3910
+ _researchStartTime = data.started_at * 1000;
3911
+ _researchAvgDuration = data.avg_duration || null;
3912
+ _researchTimerEl = document.createElement('div');
3913
+ _researchTimerEl.className = 'research-timer';
3914
+ _researchTimerEl.style.cssText = 'font-size:0.8em; opacity:0.6; margin-top:4px; font-family:monospace;';
3915
+ spinner.element.parentNode.insertBefore(_researchTimerEl, spinner.element.nextSibling);
3916
+ _researchTimerInterval = setInterval(() => {
3917
+ if (!_researchTimerEl) return;
3918
+ var elapsed = Math.floor((Date.now() - _researchStartTime) / 1000);
3919
+ var mm = String(Math.floor(elapsed / 60)).padStart(2, '0');
3920
+ var ss = String(elapsed % 60).padStart(2, '0');
3921
+ var txt = mm + ':' + ss;
3922
+ if (_researchAvgDuration) {
3923
+ var avgM = String(Math.floor(_researchAvgDuration / 60)).padStart(2, '0');
3924
+ var avgS = String(Math.round(_researchAvgDuration % 60)).padStart(2, '0');
3925
+ txt += ' / avg ' + avgM + ':' + avgS;
3926
+ }
3927
+ _researchTimerEl.textContent = txt;
3928
+ }, 1000);
3929
+ // Reconnect synapse — seed it with whatever progress is already known
3930
+ try {
3931
+ _researchSynapse = createResearchSynapse(spinner.element.parentNode, {
3932
+ query: data.query || '',
3933
+ startedAt: _researchStartTime,
3934
+ });
3935
+ if (_researchSynapse.element && _researchTimerEl) {
3936
+ spinner.element.parentNode.insertBefore(_researchSynapse.element, _researchTimerEl);
3937
+ }
3938
+ if (data.progress) {
3939
+ _researchSynapse.setPhase(data.progress.phase, data.progress);
3940
+ if (typeof data.progress.round === 'number') _researchSynapse.setRound(data.progress.round);
3941
+ if (typeof data.progress.total_sources === 'number') _researchSynapse.setSourceCount(data.progress.total_sources);
3942
+ }
3943
+ } catch (e) { console.warn('synapse reconnect failed', e); }
3944
+ }
3945
+
3946
+ // Poll for completion
3947
+ const pollInterval = setInterval(async () => {
3948
+ // Stop polling if user switched to a different session
3949
+ if (sessionModule.getCurrentSessionId() !== sessionId) {
3950
+ clearInterval(pollInterval);
3951
+ spinner.destroy();
3952
+ _clearResearchTimer();
3953
+ if (holder.parentNode) holder.remove();
3954
+ _researchingStreamIds.delete(sessionId);
3955
+ if (_researchingStreamIds.size === 0) {
3956
+ var _rToggleP = document.getElementById('research-toggle-btn');
3957
+ if (_rToggleP) _rToggleP.classList.remove('research-running');
3958
+ }
3959
+ return;
3960
+ }
3961
+ try {
3962
+ const pollRes = await fetch(`${API_BASE}/api/research/status/${sessionId}`);
3963
+ if (!pollRes.ok) {
3964
+ clearInterval(pollInterval);
3965
+ spinner.destroy();
3966
+ _clearResearchTimer();
3967
+ _researchingStreamIds.delete(sessionId);
3968
+ if (sessionModule && sessionModule.clearResearching) sessionModule.clearResearching(sessionId);
3969
+ return;
3970
+ }
3971
+ const pollData = await pollRes.json();
3972
+ updateSpinnerFromProgress(pollData.progress);
3973
+ if (_researchSynapse && pollData.progress) {
3974
+ _researchSynapse.setPhase(pollData.progress.phase, pollData.progress);
3975
+ if (typeof pollData.progress.round === 'number') _researchSynapse.setRound(pollData.progress.round);
3976
+ if (typeof pollData.progress.total_sources === 'number') _researchSynapse.setSourceCount(pollData.progress.total_sources);
3977
+ }
3978
+
3979
+ if (pollData.status !== 'running') {
3980
+ clearInterval(pollInterval);
3981
+ spinner.destroy();
3982
+ _clearResearchTimer();
3983
+ _researchingStreamIds.delete(sessionId);
3984
+ if (sessionModule && sessionModule.clearResearching) sessionModule.clearResearching(sessionId);
3985
+
3986
+ if (pollData.status === 'done') {
3987
+ _notifyResearchComplete(sessionId, data.query || '');
3988
+ const rRes = await fetch(`${API_BASE}/api/research/result/${sessionId}`, { method: 'POST' });
3989
+ if (rRes.ok) {
3990
+ const rData = await rRes.json();
3991
+ if (rData.result) {
3992
+ var srcHtml = '';
3993
+ if (rData.sources && rData.sources.length > 0) {
3994
+ srcHtml = _buildSourcesBox(rData.sources, 'research');
3995
+ }
3996
+ var findingsHtml = chatRenderer.buildFindingsBox(rData.raw_findings);
3997
+ bodyDiv.innerHTML = srcHtml + markdownModule.processWithThinking(
3998
+ markdownModule.squashOutsideCode(rData.result)
3999
+ ) + findingsHtml;
4000
+ holder.dataset.raw = rData.result;
4001
+ _appendViewReportLink(holder, sessionId);
4002
+ if (window.hljs) {
4003
+ holder.querySelectorAll('pre code').forEach(b => window.hljs.highlightElement(b));
4004
+ }
4005
+ }
4006
+ }
4007
+ } else {
4008
+ bodyDiv.innerHTML = '<i style="color: var(--color-error);">[Research ' + pollData.status + ']</i>';
4009
+ }
4010
+ }
4011
+ } catch (e) {
4012
+ console.error('Research poll error:', e);
4013
+ }
4014
+ }, 2000);
4015
+ } catch (e) {
4016
+ // No research pending, that's fine
4017
+ }
4018
+ }
4019
+
4020
+ /** Set a display override for the next user message bubble */
4021
+ export function setDisplayOverride(text) {
4022
+ _displayOverride = text;
4023
+ }
4024
+
4025
+ /** Hide the user bubble for the next submit (e.g. continue after stop) */
4026
+ export function setHideUserBubble() {
4027
+ _hideUserBubble = true;
4028
+ }
4029
+
4030
+ /** Set the AI element to merge with the next streamed response (continue after stop) */
4031
+ export function setPendingContinue(el) {
4032
+ _pendingContinue = el;
4033
+ }
4034
+
4035
+ /**
4036
+ * Delete an AI message and its preceding user message from the conversation.
4037
+ */
4038
+ export async function deleteMessage(msgElement) {
4039
+ const box = document.getElementById('chat-history');
4040
+ const allMsgs = Array.from(box.querySelectorAll('.msg'));
4041
+ const clickedIndex = allMsgs.indexOf(msgElement);
4042
+ if (clickedIndex < 0) return;
4043
+
4044
+ // No early-out on a missing session: an output shown before any model was
4045
+ // selected (issue #1428) has no session/persisted rows, but its "x" must
4046
+ // still remove it. We only need the session id for the server-side delete
4047
+ // below; without one we fall back to removing the DOM.
4048
+ const sessionId = sessionModule.getCurrentSessionId();
4049
+
4050
+ const clickedIsUser = msgElement.classList.contains('msg-user');
4051
+
4052
+ // Find the user+AI pair
4053
+ let userIndex = -1;
4054
+ let aiIndex = -1;
4055
+ if (clickedIsUser) {
4056
+ userIndex = clickedIndex;
4057
+ // Find the following AI message
4058
+ for (let i = clickedIndex + 1; i < allMsgs.length; i++) {
4059
+ if (allMsgs[i].classList.contains('msg-ai') && !allMsgs[i].classList.contains('msg-continuation')) {
4060
+ aiIndex = i;
4061
+ break;
4062
+ }
4063
+ if (allMsgs[i].classList.contains('msg-user')) break; // next user msg, no AI response
4064
+ }
4065
+ } else {
4066
+ // If clicked on a continuation, walk back to the main AI message
4067
+ let mainAiIndex = clickedIndex;
4068
+ if (allMsgs[mainAiIndex].classList.contains('msg-continuation')) {
4069
+ for (let i = mainAiIndex - 1; i >= 0; i--) {
4070
+ if (allMsgs[i].classList.contains('msg-ai') && !allMsgs[i].classList.contains('msg-continuation')) {
4071
+ mainAiIndex = i;
4072
+ break;
4073
+ }
4074
+ }
4075
+ }
4076
+ aiIndex = mainAiIndex;
4077
+ // Find the preceding user message
4078
+ for (let i = aiIndex - 1; i >= 0; i--) {
4079
+ if (allMsgs[i].classList.contains('msg-user')) {
4080
+ userIndex = i;
4081
+ break;
4082
+ }
4083
+ }
4084
+ }
4085
+
4086
+ // Collect DB message IDs and DOM elements to remove
4087
+ const msgIds = [];
4088
+ const domToRemove = [];
4089
+
4090
+ // Add the user message if found
4091
+ if (userIndex >= 0) {
4092
+ domToRemove.push(allMsgs[userIndex]);
4093
+ const uid = allMsgs[userIndex].dataset.dbId;
4094
+ if (uid) msgIds.push(uid);
4095
+ }
4096
+
4097
+ // Add the AI message if found
4098
+ if (aiIndex >= 0) {
4099
+ domToRemove.push(allMsgs[aiIndex]);
4100
+ const aid = allMsgs[aiIndex].dataset.dbId;
4101
+ if (aid) msgIds.push(aid);
4102
+
4103
+ const aiEl = allMsgs[aiIndex];
4104
+ // Also remove agent-thread elements BETWEEN user and AI
4105
+ if (userIndex >= 0) {
4106
+ let between = allMsgs[userIndex].nextElementSibling;
4107
+ while (between && between !== aiEl) {
4108
+ domToRemove.push(between);
4109
+ between = between.nextElementSibling;
4110
+ }
4111
+ }
4112
+ // Walk forward from the AI element to remove continuations and tool bubbles
4113
+ let sibling = aiEl.nextElementSibling;
4114
+ while (sibling) {
4115
+ if (sibling.classList.contains('msg-user') ||
4116
+ (sibling.classList.contains('msg-ai') && !sibling.classList.contains('msg-continuation'))) {
4117
+ break;
4118
+ }
4119
+ domToRemove.push(sibling);
4120
+ sibling = sibling.nextElementSibling;
4121
+ }
4122
+ }
4123
+
4124
+ if (!msgIds.length || !sessionId) {
4125
+ // No persisted rows to delete (no DB IDs, or no session at all — e.g. an
4126
+ // error output shown before a model was selected, #1428). Just remove the
4127
+ // DOM so the "x" works regardless.
4128
+ domToRemove.forEach(el => el.remove());
4129
+ if (uiModule) uiModule.showToast('Message deleted');
4130
+ return;
4131
+ }
4132
+
4133
+ try {
4134
+ const res = await fetch(`${API_BASE}/api/session/${sessionId}/delete-messages`, {
4135
+ method: 'POST',
4136
+ headers: { 'Content-Type': 'application/json' },
4137
+ body: JSON.stringify({ msg_ids: msgIds })
4138
+ });
4139
+ if (!res.ok) throw new Error('Server error ' + res.status);
4140
+ domToRemove.forEach(el => el.remove());
4141
+ if (uiModule) uiModule.showToast('Message deleted');
4142
+ } catch (err) {
4143
+ console.error('Delete failed:', err);
4144
+ if (uiModule) uiModule.showError('Delete failed: ' + err.message);
4145
+ }
4146
+ }
4147
+
4148
+ /**
4149
+ * Edit an AI message inline. Makes the body contentEditable, saves to DB on confirm.
4150
+ */
4151
+ export async function editAIMessage(msgElement) {
4152
+ const body = msgElement.querySelector('.body');
4153
+ if (!body) return;
4154
+
4155
+ const isEditing = body.contentEditable === 'true' || body.contentEditable === 'plaintext-only';
4156
+ if (isEditing) return; // already editing
4157
+
4158
+ const originalRaw = msgElement.dataset.raw || body.textContent || '';
4159
+
4160
+ // Create editable textarea overlay
4161
+ const textarea = document.createElement('textarea');
4162
+ textarea.className = 'msg-edit-textarea';
4163
+ textarea.value = originalRaw;
4164
+ textarea.style.width = '100%';
4165
+ textarea.style.minHeight = Math.max(100, body.offsetHeight) + 'px';
4166
+ body.style.display = 'none';
4167
+ body.parentNode.insertBefore(textarea, body.nextSibling);
4168
+ textarea.focus();
4169
+
4170
+ // Add save/cancel bar
4171
+ const bar = document.createElement('div');
4172
+ bar.className = 'msg-edit-bar';
4173
+ const saveBtn = document.createElement('button');
4174
+ saveBtn.className = 'msg-edit-save';
4175
+ saveBtn.textContent = 'Save';
4176
+ const cancelBtn = document.createElement('button');
4177
+ cancelBtn.className = 'msg-edit-cancel';
4178
+ cancelBtn.textContent = 'Cancel';
4179
+ bar.appendChild(saveBtn);
4180
+ bar.appendChild(cancelBtn);
4181
+ textarea.parentNode.insertBefore(bar, textarea.nextSibling);
4182
+
4183
+ function cleanup() {
4184
+ textarea.remove();
4185
+ bar.remove();
4186
+ body.style.display = '';
4187
+ }
4188
+
4189
+ cancelBtn.addEventListener('click', (e) => {
4190
+ e.stopPropagation();
4191
+ cleanup();
4192
+ });
4193
+
4194
+ saveBtn.addEventListener('click', async (e) => {
4195
+ e.stopPropagation();
4196
+ const newContent = textarea.value;
4197
+ if (newContent === originalRaw) { cleanup(); return; }
4198
+
4199
+ const msgId = msgElement.dataset.dbId;
4200
+ if (!msgId) { if (uiModule) uiModule.showError('Cannot edit: message ID not found'); cleanup(); return; }
4201
+
4202
+ const sessionId = sessionModule.getCurrentSessionId();
4203
+ if (!sessionId) { cleanup(); return; }
4204
+
4205
+ try {
4206
+ const res = await fetch(`${API_BASE}/api/session/${sessionId}/edit-message`, {
4207
+ method: 'POST',
4208
+ headers: { 'Content-Type': 'application/json' },
4209
+ body: JSON.stringify({ msg_id: msgId, content: newContent }),
4210
+ });
4211
+ if (!res.ok) throw new Error('Server error ' + res.status);
4212
+
4213
+ // Re-render body with markdown
4214
+ body.innerHTML = markdownModule.processWithThinking(markdownModule.squashOutsideCode(newContent));
4215
+ msgElement.dataset.raw = newContent;
4216
+
4217
+ // Add edited indicator if not already present
4218
+ if (!msgElement.querySelector('.edited-indicator')) {
4219
+ const indicator = document.createElement('div');
4220
+ indicator.className = 'edited-indicator';
4221
+ indicator.textContent = '[Message edited]';
4222
+ body.parentNode.insertBefore(indicator, body.nextSibling);
4223
+ }
4224
+
4225
+ cleanup();
4226
+ if (uiModule) uiModule.showToast('Message edited');
4227
+ } catch (err) {
4228
+ console.error('Edit failed:', err);
4229
+ if (uiModule) uiModule.showError('Edit failed: ' + err.message);
4230
+ }
4231
+ });
4232
+ }
4233
+
4234
+ /**
4235
+ * Rewrite the AI's last response with a specific instruction.
4236
+ * Uses the lightweight /api/rewrite endpoint — no tools, no agent loop.
4237
+ * Just rewrites the text of the last AI bubble.
4238
+ */
4239
+ export async function rewriteWith(aiMsgElement, instruction) {
4240
+ const sessionId = sessionModule.getCurrentSessionId();
4241
+ if (!sessionId) return;
4242
+
4243
+ // Get the original text from the AI bubble
4244
+ const oldRaw = aiMsgElement.dataset.raw || aiMsgElement.querySelector('.body')?.textContent || '';
4245
+ const oldHtml = aiMsgElement.querySelector('.body')?.innerHTML || '';
4246
+
4247
+ if (!oldRaw.trim()) {
4248
+ if (uiModule) uiModule.showError('No text to rewrite');
4249
+ return;
4250
+ }
4251
+
4252
+ // Save current response as a variant
4253
+ let variants = [];
4254
+ try { variants = JSON.parse(aiMsgElement.dataset.variants || '[]'); } catch(_) {}
4255
+ if (variants.length === 0) {
4256
+ variants.push({ raw: oldRaw, html: oldHtml, label: 'original' });
4257
+ }
4258
+
4259
+ // Determine label from instruction
4260
+ let varLabel = 'rewrite';
4261
+ if (instruction.includes('shorter')) varLabel = 'shorter';
4262
+ else if (instruction.includes('simpler')) varLabel = 'simpler';
4263
+
4264
+ // Clear the bubble and show a whirlpool spinner while we wait for the
4265
+ // rewrite (replaces the old "Rewriting..." text).
4266
+ const bodyEl = aiMsgElement.querySelector('.body');
4267
+ let _rwSpin = null;
4268
+ if (bodyEl) {
4269
+ bodyEl.innerHTML = '';
4270
+ _rwSpin = spinnerModule.createWhirlpool(18);
4271
+ _rwSpin.element.style.margin = '4px 0';
4272
+ bodyEl.appendChild(_rwSpin.element);
4273
+ }
4274
+ // Stop + detach the spinner (called once real content starts rendering, and
4275
+ // on the failure path so it never spins forever).
4276
+ const _killRwSpin = () => { if (_rwSpin) { try { _rwSpin.destroy(); } catch (_) {} _rwSpin = null; } };
4277
+
4278
+ try {
4279
+ const res = await fetch(`${API_BASE}/api/rewrite`, {
4280
+ method: 'POST',
4281
+ headers: { 'Content-Type': 'application/json' },
4282
+ body: JSON.stringify({
4283
+ session_id: sessionId,
4284
+ original_text: oldRaw,
4285
+ instruction: instruction,
4286
+ }),
4287
+ });
4288
+
4289
+ if (!res.ok) {
4290
+ throw new Error(`HTTP ${res.status}`);
4291
+ }
4292
+
4293
+ const reader = res.body.getReader();
4294
+ const decoder = new TextDecoder();
4295
+ let buffer = '';
4296
+ let newText = '';
4297
+
4298
+ while (true) {
4299
+ const { done, value } = await reader.read();
4300
+ if (done) break;
4301
+ buffer += decoder.decode(value, { stream: true });
4302
+ const lines = buffer.split('\n');
4303
+ buffer = lines.pop() || '';
4304
+
4305
+ for (const line of lines) {
4306
+ if (!line.startsWith('data: ')) continue;
4307
+ const payload = line.slice(6).trim();
4308
+ if (payload === '[DONE]') continue;
4309
+ try {
4310
+ const data = JSON.parse(payload);
4311
+ // The endpoint streams `event: error\ndata: {error,status}` on
4312
+ // failure — surface it instead of silently hanging on "Rewriting…".
4313
+ if (data.error) {
4314
+ throw new Error(data.error || ('HTTP ' + (data.status || 500)));
4315
+ }
4316
+ // Reasoning tokens (vLLM --reasoning-parser: Qwen3 / DeepSeek-R1)
4317
+ // arrive as separate {delta, thinking:true} chunks. They are NOT
4318
+ // the rewrite — fold them away so they don't pollute the result.
4319
+ if (data.thinking) continue;
4320
+ if (data.delta) {
4321
+ newText += data.delta;
4322
+ _killRwSpin();
4323
+ if (bodyEl) {
4324
+ bodyEl.innerHTML = markdownModule.processWithThinking(
4325
+ markdownModule.squashOutsideCode(newText)
4326
+ );
4327
+ }
4328
+ }
4329
+ } catch (e) {
4330
+ if (e instanceof Error && e.message) throw e; // re-throw real errors
4331
+ /* ignore JSON parse noise */
4332
+ }
4333
+ }
4334
+ }
4335
+
4336
+ // Strip any thinking markup from the answer. A reasoning model may emit
4337
+ // an inline <think>…</think> block, a bare </think> (no opener), or — when
4338
+ // its reasoning came via reasoning_content — a stray leading <think> that
4339
+ // never closes (so it would otherwise hide the whole answer). Peel all of
4340
+ // those off so what's left is just the rewritten text.
4341
+ const _stripThink = (t) => {
4342
+ t = t.replace(/<think>[\s\S]*?<\/think>/gi, ''); // complete blocks
4343
+ if (/<\/think>/i.test(t)) t = t.replace(/^[\s\S]*?<\/think>/i, ''); // reasoning w/o opener
4344
+ return t.replace(/<\/?think>/gi, '').trim(); // any orphan tag
4345
+ };
4346
+ newText = _stripThink(newText);
4347
+
4348
+ // Nothing left after stripping (or an empty stream) → real failure, not a
4349
+ // blank bubble.
4350
+ if (!newText.trim()) {
4351
+ throw new Error('model returned no rewritten text');
4352
+ }
4353
+
4354
+ // Update the element's raw text
4355
+ if (newText) {
4356
+ aiMsgElement.dataset.raw = newText;
4357
+ // Final render with proper markdown
4358
+ if (bodyEl) {
4359
+ bodyEl.innerHTML = markdownModule.processWithThinking(
4360
+ markdownModule.squashOutsideCode(newText)
4361
+ );
4362
+ }
4363
+
4364
+ // Save the new response as a variant
4365
+ variants.push({ raw: newText, html: bodyEl ? bodyEl.innerHTML : '', label: varLabel });
4366
+ aiMsgElement.dataset.variants = JSON.stringify(variants);
4367
+ aiMsgElement.dataset.variantIndex = String(variants.length - 1);
4368
+
4369
+ // Persist variant metadata to server
4370
+ try {
4371
+ await fetch(`${API_BASE}/api/session/${sessionId}/update-last-meta`, {
4372
+ method: 'POST',
4373
+ headers: { 'Content-Type': 'application/json' },
4374
+ body: JSON.stringify({ metadata: { variants: variants, variantIndex: variants.length - 1 } }),
4375
+ });
4376
+ } catch (_) {}
4377
+
4378
+ // Re-render variant navigation
4379
+ _renderVariantNav(aiMsgElement, variants, variants.length - 1);
4380
+ }
4381
+
4382
+ if (uiModule) uiModule.scrollHistory();
4383
+
4384
+ } catch (err) {
4385
+ console.error('Rewrite failed:', err);
4386
+ _killRwSpin();
4387
+ // Restore original content on failure
4388
+ if (bodyEl) bodyEl.innerHTML = oldHtml;
4389
+ if (uiModule) uiModule.showError('Rewrite failed: ' + err.message);
4390
+ }
4391
+ }
4392
+
4393
+ /**
4394
+ * Continue the AI's response from where it left off.
4395
+ */
4396
+ export async function continueFrom(aiMsgElement) {
4397
+ const sessionId = sessionModule.getCurrentSessionId();
4398
+ if (!sessionId) return;
4399
+
4400
+ const messageInput = uiModule.el('message');
4401
+ if (messageInput) {
4402
+ messageInput.value = 'Continue from where you left off.';
4403
+ const submitBtn = document.querySelector('.send-btn');
4404
+ if (submitBtn) submitBtn.click();
4405
+ }
4406
+ }
4407
+
4408
+ // Open a chat attachment in the right place: images → Gallery editor; PDFs &
4409
+ // text/code/markdown → Documents viewer; anything else → raw file. A given
4410
+ // upload's imported document is reused (cached by upload id) so clicking it
4411
+ // again re-opens the same doc instead of making duplicates.
4412
+ const _attachDocCache = new Map(); // upload id -> doc id
4413
+ function _attachLang(name) {
4414
+ const m = (name || '').toLowerCase().match(/\.([a-z0-9]+)$/);
4415
+ const ext = m ? m[1] : '';
4416
+ const map = { md:'markdown', markdown:'markdown', js:'javascript', ts:'typescript',
4417
+ jsx:'javascript', tsx:'typescript', py:'python', rb:'ruby', go:'go', rs:'rust',
4418
+ java:'java', c:'c', cpp:'cpp', h:'c', hpp:'cpp', cs:'csharp', php:'php', html:'html',
4419
+ htm:'html', css:'css', scss:'scss', json:'json', yaml:'yaml', yml:'yaml', sh:'bash',
4420
+ bash:'bash', sql:'sql', csv:'csv', xml:'xml' };
4421
+ return map[ext] || '';
4422
+ }
4423
+ async function openAttachment(att, isImage) {
4424
+ if (!att || !att.id) return;
4425
+ const id = att.id, name = att.name || '', mime = att.mime || '';
4426
+ const url = `${API_BASE}/api/upload/${id}`;
4427
+
4428
+ // Images → Gallery editor.
4429
+ if (isImage) {
4430
+ try {
4431
+ const gx = await import('./galleryEditor.js');
4432
+ if (gx.openEditor) { gx.openEditor(url, id, null, name); return; }
4433
+ } catch (e) { console.warn('gallery open failed', e); }
4434
+ window.open(url, '_blank');
4435
+ return;
4436
+ }
4437
+
4438
+ const isPdf = mime === 'application/pdf' || /\.pdf$/i.test(name);
4439
+ const TEXT_EXT = /\.(txt|md|markdown|js|ts|jsx|tsx|py|rb|go|rs|java|c|cpp|h|hpp|cs|php|html?|css|scss|sass|less|json|ya?ml|toml|ini|conf|env|sh|bash|sql|csv|tsv|xml|log|vue|svelte)$/i;
4440
+ const isTextDoc = TEXT_EXT.test(name) || /^text\//.test(mime);
4441
+ if (!isPdf && !isTextDoc) { window.open(url, '_blank'); return; } // binary/unknown → raw
4442
+
4443
+ // Reuse the doc we already imported for this upload, if it still loads.
4444
+ const cached = _attachDocCache.get(id);
4445
+ if (cached) {
4446
+ try {
4447
+ documentModule.openPanel && documentModule.openPanel();
4448
+ await documentModule.loadDocument(cached);
4449
+ return;
4450
+ } catch (_) { _attachDocCache.delete(id); }
4451
+ }
4452
+
4453
+ // Need a session to attach the doc to (bare-session fallback, same as compose).
4454
+ let sid = '';
4455
+ try { sid = sessionModule.getCurrentSessionId() || ''; } catch (_) {}
4456
+ if (!sid) {
4457
+ try {
4458
+ const _fd = new FormData();
4459
+ _fd.append('name', name || 'Attachment');
4460
+ _fd.append('skip_validation', 'true');
4461
+ const r = await fetch(`${API_BASE}/api/session`, { method: 'POST', body: _fd, credentials: 'same-origin' });
4462
+ if (r.ok) { const d = await r.json(); if (d && d.id) { sid = d.id; if (sessionModule.loadSessions) await sessionModule.loadSessions(); } }
4463
+ } catch (_) {}
4464
+ }
4465
+
4466
+ try {
4467
+ let doc;
4468
+ if (isPdf) {
4469
+ // import-pdf wants a fresh file upload — re-fetch the stored blob and post it.
4470
+ const blob = await (await fetch(url)).blob();
4471
+ const fd = new FormData();
4472
+ fd.append('file', blob, name || 'document.pdf');
4473
+ if (sid) fd.append('session_id', sid);
4474
+ const res = await fetch(`${API_BASE}/api/documents/import-pdf`, { method: 'POST', body: fd, credentials: 'same-origin' });
4475
+ if (!res.ok) throw new Error('import-pdf ' + res.status);
4476
+ doc = await res.json();
4477
+ } else {
4478
+ const text = await (await fetch(url)).text();
4479
+ const res = await fetch(`${API_BASE}/api/document`, {
4480
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
4481
+ body: JSON.stringify({ session_id: sid || null, title: name.replace(/\.[^.]+$/, '') || 'Document', content: text, language: _attachLang(name) }),
4482
+ });
4483
+ if (!res.ok) throw new Error('document ' + res.status);
4484
+ doc = await res.json();
4485
+ }
4486
+ if (doc && doc.id) {
4487
+ _attachDocCache.set(id, doc.id);
4488
+ documentModule.openPanel && documentModule.openPanel();
4489
+ if (documentModule.injectFreshDoc) documentModule.injectFreshDoc(doc);
4490
+ else await documentModule.loadDocument(doc.id);
4491
+ }
4492
+ } catch (e) {
4493
+ console.error('open attachment as document failed', e);
4494
+ import('./ui.js').then(m => m.showError && m.showError('Could not open attachment')).catch(() => {});
4495
+ window.open(url, '_blank'); // fallback so the file is still reachable
4496
+ }
4497
+ }
4498
+
4499
+ // Public API
4500
+ const chatModule = {
4501
+ init,
4502
+ initListeners,
4503
+ openAttachment,
4504
+ addMessage: chatRenderer.addMessage,
4505
+ displayMetrics: chatRenderer.displayMetrics,
4506
+ handleChatSubmit,
4507
+ abortCurrentRequest,
4508
+ detachCurrentStream,
4509
+ checkBackgroundStream,
4510
+ hideWelcomeScreen: chatRenderer.hideWelcomeScreen,
4511
+ showWelcomeScreen: chatRenderer.showWelcomeScreen,
4512
+ checkPendingResearch,
4513
+ getImageCost: chatRenderer.getImageCost,
4514
+ setDisplayOverride,
4515
+ setHideUserBubble,
4516
+ setPendingContinue,
4517
+ regenerateFrom,
4518
+ forkFrom,
4519
+ editUserMessage,
4520
+ editAIMessage,
4521
+ resendUserMessage,
4522
+ deleteMessage,
4523
+ rewriteWith,
4524
+ continueFrom,
4525
+ _appendViewReportLink,
4526
+ hasActiveStream,
4527
+ };
4528
+
4529
+ // Single delegated handler for tool-call fold/expand. One listener on
4530
+ // document.body covers every .agent-thread-node — running, completed,
4531
+ // streaming, history-rendered, compare-mode, all of them. Re-attaching
4532
+ // per-node listeners on every innerHTML rewrite was the source of the
4533
+ // "needs many clicks" bug.
4534
+ if (!window.__zero_thread_click_bound) {
4535
+ document.body.addEventListener('click', (e) => {
4536
+ const header = e.target.closest('.agent-thread-header');
4537
+ if (!header) return;
4538
+ const node = header.closest('.agent-thread-node');
4539
+ if (!node) return;
4540
+ node.classList.toggle('open');
4541
+ });
4542
+ window.__zero_thread_click_bound = true;
4543
+ }
4544
+
4545
+ export default chatModule;
4546
+ window.chatModule = chatModule;