@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,2294 @@
1
+ // static/js/chatRenderer.js
2
+ // Extracted from chat.js — message rendering, sources, images, metrics
3
+
4
+ import uiModule from './ui.js';
5
+ import markdownModule from './markdown.js';
6
+ import { addAITTSButton } from './tts-ai.js';
7
+ import { providerLogo } from './providers.js';
8
+ import settingsModule from './settings.js';
9
+ import spinnerModule from './spinner.js';
10
+ import { bindMenuDismiss } from './escMenuStack.js';
11
+ import { matchModelKey } from './model/matchKey.js';
12
+
13
+ const SEARCH_ICON = '<svg 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>';
14
+ const REPORT_ICON = '<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="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>';
15
+ const CHAT_ABOUT_ICON = '<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="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
16
+ const COPY_ICON = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
17
+ const CHECK_ICON = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
18
+
19
+ /** Sanitize a URL for use in href — only allow http(s) and protocol-relative. */
20
+ function _safeHref(url) {
21
+ if (!url) return '#';
22
+ try {
23
+ var parsed = new URL(url, window.location.origin);
24
+ if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return uiModule.esc(url);
25
+ } catch(e) { /* invalid URL */ }
26
+ return '#';
27
+ }
28
+
29
+ function _makeActionBtn(className, title, text, handler) {
30
+ const btn = document.createElement('button');
31
+ btn.className = className;
32
+ btn.type = 'button';
33
+ btn.title = title;
34
+ btn.textContent = text;
35
+ btn.addEventListener('click', handler);
36
+ return btn;
37
+ }
38
+
39
+ // Attachment card helpers
40
+ function _attachIcon(mimeOrName) {
41
+ const s = (mimeOrName || '').toLowerCase();
42
+ if (s.startsWith('image/') || /\.(png|jpg|jpeg|gif|webp|svg)$/i.test(s))
43
+ return '<svg class="attach-card-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>';
44
+ if (s.startsWith('audio/') || /\.(mp3|wav|ogg|m4a|webm)$/i.test(s))
45
+ return '<svg class="attach-card-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>';
46
+ if (s === 'application/pdf' || /\.pdf$/i.test(s))
47
+ return '<svg class="attach-card-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>';
48
+ // Default: generic document
49
+ return '<svg class="attach-card-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>';
50
+ }
51
+ function _formatSize(bytes) {
52
+ if (bytes < 1024) return bytes + ' B';
53
+ if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
54
+ return (bytes / 1048576).toFixed(1) + ' MB';
55
+ }
56
+
57
+ // Build the `.attach-cards` element for a message's attachment list. Shared by
58
+ // addMessage and updateMessageAttachments so a live (optimistic) user bubble
59
+ // can be re-rendered with real upload ids once the upload resolves.
60
+ function buildAttachCards(attachments) {
61
+ const attachWrap = document.createElement('div');
62
+ attachWrap.className = 'attach-cards';
63
+ for (const att of attachments) {
64
+ const isImage = (att.mime || '').startsWith('image/') || /\.(png|jpg|jpeg|gif|webp|svg|bmp)$/i.test(att.name || '');
65
+ if (isImage) {
66
+ // Image preview. Shown for both uploaded (att.id present) and still-
67
+ // uploading attachments. A shimmering skeleton + whirlpool fills the
68
+ // space until either the upload resolves (no id yet) or the thumbnail
69
+ // image finishes loading, so the photo doesn't pop in abruptly.
70
+ const imgWrap = document.createElement('div');
71
+ imgWrap.className = 'attach-image-preview';
72
+ imgWrap.style.cursor = att.id ? 'zoom-in' : 'default';
73
+ if (att.id) imgWrap.dataset.fileId = att.id;
74
+ if (att.id) {
75
+ imgWrap.addEventListener('click', (e) => {
76
+ // Tapping the corner OCR button shouldn't also open the lightbox.
77
+ if (e.target.closest('.attach-ocr-btn')) return;
78
+ _openImageLightbox(att);
79
+ });
80
+ }
81
+
82
+ let skel = null;
83
+ let sp = null;
84
+ if (!att.previewUrl) {
85
+ // Skeleton placeholder with a centered whirlpool. Self-stops when removed.
86
+ skel = document.createElement('div');
87
+ skel.className = 'attach-image-skeleton';
88
+ // Match the photo's aspect ratio when the backend knew it at upload
89
+ // time, so the skeleton doesn't sit at a 4:3 default and then snap to
90
+ // a portrait shape when the image arrives.
91
+ if (att.width && att.height) {
92
+ skel.style.aspectRatio = att.width + ' / ' + att.height;
93
+ skel.style.width = 'auto';
94
+ skel.style.height = 'auto';
95
+ skel.style.maxWidth = '300px';
96
+ skel.style.maxHeight = '200px';
97
+ skel.style.minWidth = '80px';
98
+ }
99
+ sp = spinnerModule.createWhirlpool(20);
100
+ skel.appendChild(sp.element);
101
+ imgWrap.appendChild(skel);
102
+ }
103
+
104
+ if (att.id || att.previewUrl) {
105
+ const img = document.createElement('img');
106
+ // Small cached thumbnail — the preview is tiny, no need to pull the
107
+ // full-resolution photo. Click still opens the full image.
108
+ img.alt = att.name || 'Image';
109
+ img.loading = 'lazy';
110
+ img.style.cssText = 'max-width:300px;max-height:200px;border-radius:6px;display:' + (att.previewUrl ? 'block' : 'none') + ';';
111
+ let _revealed = false;
112
+ let _revealTimer = null;
113
+ const _reveal = () => {
114
+ if (_revealed) return;
115
+ _revealed = true;
116
+ if (_revealTimer) { clearTimeout(_revealTimer); _revealTimer = null; }
117
+ img.style.display = 'block';
118
+ try { sp && sp.stop(); } catch {}
119
+ if (skel) skel.remove();
120
+ };
121
+ img.addEventListener('load', _reveal);
122
+ img.addEventListener('error', _reveal);
123
+ img.src = att.previewUrl || `/api/upload/${att.id}?thumb=1`;
124
+ // Cached images can be complete before the load listener attaches.
125
+ if (img.complete && img.naturalWidth) _reveal();
126
+ // Failsafe: if neither load nor error fires within 8s, reveal anyway.
127
+ // The timer is cleared on reveal AND when updateMessageAttachments
128
+ // replaces the card (which scrubs the img / skel from the DOM), so
129
+ // repeated re-renders don't accumulate stranded timers.
130
+ if (!att.previewUrl) _revealTimer = setTimeout(_reveal, 8000);
131
+ imgWrap.appendChild(img);
132
+
133
+ if (att.id) {
134
+ // Small corner button → opens the vision/OCR editor so the user can
135
+ // correct what the vision model extracted. The edit is cached on the
136
+ // server keyed by file id, so any later message referencing this same
137
+ // image picks up the corrected text instead of re-running the model.
138
+ const ocrBtn = document.createElement('button');
139
+ ocrBtn.type = 'button';
140
+ ocrBtn.className = 'attach-ocr-btn';
141
+ ocrBtn.title = 'View / edit OCR text';
142
+ ocrBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg><span class="attach-ocr-label">Caption</span>';
143
+ ocrBtn.addEventListener('click', (e) => {
144
+ e.stopPropagation();
145
+ _openVisionEditor(att, ocrBtn.closest('.msg'));
146
+ });
147
+ imgWrap.appendChild(ocrBtn);
148
+ }
149
+ }
150
+
151
+ if (att.vision_model) {
152
+ const visionLabel = document.createElement('div');
153
+ visionLabel.className = 'attach-vision-model';
154
+ visionLabel.textContent = 'Vision: ' + String(att.vision_model).split('/').pop();
155
+ imgWrap.appendChild(visionLabel);
156
+ }
157
+ if (att.name) {
158
+ const label = document.createElement('div');
159
+ label.className = 'attach-image-name';
160
+ label.textContent = att.name;
161
+ imgWrap.appendChild(label);
162
+ }
163
+ attachWrap.appendChild(imgWrap);
164
+ } else {
165
+ // Non-image file card
166
+ const card = document.createElement('div');
167
+ card.className = 'attach-card';
168
+ card.dataset.name = att.name;
169
+ if (att.id) {
170
+ card.dataset.fileId = att.id;
171
+ card.style.cursor = 'pointer';
172
+ card.addEventListener('click', () => {
173
+ // PDFs & text/code/markdown → open in the Documents viewer
174
+ // (others fall back to the raw file).
175
+ if (window.chatModule?.openAttachment) window.chatModule.openAttachment(att, false);
176
+ else window.open(`/api/upload/${att.id}`, '_blank');
177
+ });
178
+ }
179
+ const icon = _attachIcon(att.mime || att.name);
180
+ const nameSpan = document.createElement('span');
181
+ nameSpan.className = 'attach-card-name';
182
+ nameSpan.textContent = att.name;
183
+ card.innerHTML = icon;
184
+ card.appendChild(nameSpan);
185
+ if (att.size) {
186
+ const sizeSpan = document.createElement('span');
187
+ sizeSpan.className = 'attach-card-size';
188
+ sizeSpan.textContent = _formatSize(att.size);
189
+ card.appendChild(sizeSpan);
190
+ }
191
+ attachWrap.appendChild(card);
192
+ }
193
+ }
194
+ return attachWrap;
195
+ }
196
+
197
+ // Re-render the attachment cards of an already-rendered message. Used to swap
198
+ // in real upload ids (and image thumbnails) on the optimistic user bubble once
199
+ // uploadPending() resolves — otherwise image previews only appear after a
200
+ // refresh, because the bubble is rendered before the upload assigns ids.
201
+ export function updateMessageAttachments(msgWrap, attachments) {
202
+ if (!msgWrap || !attachments?.length) return;
203
+ const body = msgWrap.querySelector('.body') || msgWrap;
204
+ const existing = body.querySelector('.attach-cards');
205
+ const fresh = buildAttachCards(attachments);
206
+ if (existing) existing.replaceWith(fresh);
207
+ else body.appendChild(fresh);
208
+ }
209
+
210
+ // Quick full-size preview when the user taps a chat photo thumbnail. Just an
211
+ // overlay with the original image centered — no Gallery panel, no editor.
212
+ function _openImageLightbox(att) {
213
+ if (!att?.id) return;
214
+ const overlay = document.createElement('div');
215
+ overlay.className = 'attach-lightbox';
216
+ // Show the cached thumb immediately so the overlay doesn't sit blank
217
+ // while a 25MB original streams in. The full image swaps in once loaded;
218
+ // if the full load fails (404 / network), we keep the thumb + show an
219
+ // error label rather than a blank overlay forever.
220
+ const img = document.createElement('img');
221
+ img.alt = att.name || '';
222
+ img.src = `/api/upload/${att.id}?thumb=1`;
223
+ overlay.appendChild(img);
224
+ const full = new Image();
225
+ full.addEventListener('load', () => { img.src = full.src; });
226
+ full.addEventListener('error', () => {
227
+ const err = document.createElement('div');
228
+ err.className = 'attach-lightbox-err';
229
+ err.textContent = 'Failed to load full-resolution image.';
230
+ overlay.appendChild(err);
231
+ });
232
+ full.src = `/api/upload/${att.id}`;
233
+
234
+ const _onKey = (e) => { if (e.key === 'Escape') _close(); };
235
+ const _close = () => {
236
+ document.removeEventListener('keydown', _onKey);
237
+ if (_overlayObs) { try { _overlayObs.disconnect(); } catch {} }
238
+ overlay.remove();
239
+ };
240
+ // If the overlay is removed via any path other than our close handler
241
+ // (session switch, parent re-render, external cleanup), still drop the
242
+ // document-level keydown listener so it doesn't leak.
243
+ let _overlayObs = null;
244
+ try {
245
+ _overlayObs = new MutationObserver(() => {
246
+ if (!document.body.contains(overlay)) {
247
+ document.removeEventListener('keydown', _onKey);
248
+ _overlayObs.disconnect();
249
+ }
250
+ });
251
+ _overlayObs.observe(document.body, { childList: true, subtree: false });
252
+ } catch {}
253
+ overlay.addEventListener('click', _close);
254
+ document.addEventListener('keydown', _onKey);
255
+ document.body.appendChild(overlay);
256
+ }
257
+
258
+ // Vision/OCR editor modal — opened from the corner "Aa" button on a chat photo
259
+ // thumbnail. Lets the user view and correct the text the vision model fed to
260
+ // the LLM (e.g. when OCR misreads a word). Persists to the server's vision
261
+ // cache (PUT /api/upload/{id}/vision), so any subsequent message that
262
+ // references the same file picks up the corrected text.
263
+ let _visionEditorEl = null;
264
+ let _visionEditorEsc = null;
265
+ function _closeVisionEditor() {
266
+ if (_visionEditorEsc) { document.removeEventListener('keydown', _visionEditorEsc); _visionEditorEsc = null; }
267
+ if (_visionEditorEl) { _visionEditorEl.remove(); _visionEditorEl = null; }
268
+ }
269
+ function _openVisionEditor(att, userMsgEl) {
270
+ if (!att?.id) return;
271
+ _closeVisionEditor();
272
+ const overlay = document.createElement('div');
273
+ overlay.className = 'vision-editor-overlay';
274
+ overlay.addEventListener('click', (e) => { if (e.target === overlay) _closeVisionEditor(); });
275
+ const panel = document.createElement('div');
276
+ panel.className = 'vision-editor-panel';
277
+ const title = document.createElement('div');
278
+ title.className = 'vision-editor-title';
279
+ // Eye icon matches the one in Settings → Vision so users recognise where
280
+ // this text originates.
281
+ title.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.7;flex-shrink:0"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg><span>Vision text</span>';
282
+ panel.appendChild(title);
283
+ const desc = document.createElement('div');
284
+ desc.className = 'vision-editor-desc';
285
+ desc.textContent = 'Edit text and save, new chats will have the new context. Regenerate or continue from there.';
286
+ panel.appendChild(desc);
287
+ const ta = document.createElement('textarea');
288
+ ta.className = 'vision-editor-text';
289
+ ta.rows = 10;
290
+ ta.placeholder = 'Loading…';
291
+ ta.disabled = true;
292
+ panel.appendChild(ta);
293
+ const actions = document.createElement('div');
294
+ actions.className = 'vision-editor-actions';
295
+ const closeBtn = document.createElement('button');
296
+ closeBtn.type = 'button';
297
+ closeBtn.className = 'vision-editor-btn';
298
+ closeBtn.innerHTML = '<span class="vision-btn-label">Close</span>';
299
+ closeBtn.addEventListener('click', _closeVisionEditor);
300
+ const _saveVisionText = async () => {
301
+ const res = await fetch(`/api/upload/${att.id}/vision`, {
302
+ method: 'PUT',
303
+ headers: { 'Content-Type': 'application/json' },
304
+ credentials: 'same-origin',
305
+ body: JSON.stringify({ text: ta.value }),
306
+ });
307
+ if (!res.ok) throw new Error('save failed');
308
+ };
309
+ const saveBtn = document.createElement('button');
310
+ saveBtn.type = 'button';
311
+ saveBtn.className = 'vision-editor-btn vision-editor-btn-primary';
312
+ saveBtn.innerHTML = '<span class="vision-btn-label">Save</span>';
313
+ saveBtn.disabled = true;
314
+ saveBtn.addEventListener('click', async () => {
315
+ saveBtn.disabled = true;
316
+ saveBtn.innerHTML = '<span class="vision-btn-label">Saving…</span>';
317
+ try {
318
+ await _saveVisionText();
319
+ if (uiModule?.showToast) uiModule.showToast('Saved');
320
+ _closeVisionEditor();
321
+ } catch (e) {
322
+ saveBtn.disabled = false;
323
+ saveBtn.innerHTML = '<span class="vision-btn-label">Save</span>';
324
+ if (uiModule?.showError) uiModule.showError('Failed to save OCR text');
325
+ }
326
+ });
327
+ // Regenerate-message: save the edited text, close, then trigger a resend of
328
+ // the user message so the new AI reply uses the edit immediately.
329
+ const regenBtn = document.createElement('button');
330
+ regenBtn.type = 'button';
331
+ regenBtn.className = 'vision-editor-btn vision-editor-btn-primary';
332
+ regenBtn.title = 'Save and regenerate the message';
333
+ regenBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.74 9.74 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg><span class="vision-btn-label">Regenerate message</span>';
334
+ regenBtn.disabled = true;
335
+ regenBtn.addEventListener('click', async () => {
336
+ regenBtn.disabled = true;
337
+ saveBtn.disabled = true;
338
+ try {
339
+ await _saveVisionText();
340
+ _closeVisionEditor();
341
+ if (userMsgEl && window.chatModule?.resendUserMessage) {
342
+ window.chatModule.resendUserMessage(userMsgEl);
343
+ } else if (uiModule?.showToast) {
344
+ uiModule.showToast('Saved');
345
+ }
346
+ } catch (e) {
347
+ regenBtn.disabled = false;
348
+ saveBtn.disabled = false;
349
+ if (uiModule?.showError) uiModule.showError('Failed to save OCR text');
350
+ }
351
+ });
352
+ actions.appendChild(closeBtn);
353
+ actions.appendChild(saveBtn);
354
+ actions.appendChild(regenBtn);
355
+ panel.appendChild(actions);
356
+ overlay.appendChild(panel);
357
+ document.body.appendChild(overlay);
358
+ _visionEditorEl = overlay;
359
+
360
+ // ESC closes the popup. Registered on document so it works regardless of
361
+ // focus (the textarea swallows the event otherwise).
362
+ _visionEditorEsc = (e) => { if (e.key === 'Escape') _closeVisionEditor(); };
363
+ document.addEventListener('keydown', _visionEditorEsc);
364
+
365
+ fetch(`/api/upload/${att.id}/vision`, { credentials: 'same-origin' })
366
+ .then(r => r.ok ? r.json() : Promise.reject(r))
367
+ .then(data => {
368
+ ta.value = data.text || '';
369
+ ta.placeholder = '';
370
+ ta.disabled = false;
371
+ saveBtn.disabled = false;
372
+ regenBtn.disabled = !userMsgEl;
373
+ ta.focus();
374
+ })
375
+ .catch(() => {
376
+ ta.value = '';
377
+ ta.placeholder = 'Could not load OCR text — type your correction and save.';
378
+ ta.disabled = false;
379
+ saveBtn.disabled = false;
380
+ regenBtn.disabled = !userMsgEl;
381
+ });
382
+ }
383
+
384
+ // Tool call syntax patterns to strip from displayed text
385
+ const TOOL_CALL_RE = /\[TOOL_CALL\][\s\S]*?\[\/TOOL_CALL\]/gi;
386
+ // Only strip fenced tool-call blocks that look like structured invocations, not regular code examples
387
+ const EXEC_FENCE_RE = /```(?:web_search|read_file|write_file|create_document|edit_document|update_document)\s*\n[\s\S]*?```/gi;
388
+ // XML-style tool calls: <minimax:tool_call>, <tool_call>, <function_call>, bare <invoke>
389
+ const XML_TOOL_CALL_RE = /<(?:[\w]+:)?(?:tool_call|function_call)>[\s\S]*?<\/(?:[\w]+:)?(?:tool_call|function_call)>/gi;
390
+ const XML_INVOKE_RE = /<invoke\s+name=['"][^'"]*['"]>[\s\S]*?<\/invoke>/gi;
391
+ // DeepSeek "DSML" tool-call markup (fullwidth-pipe | or ascii | delimited) that
392
+ // leaks into content when the model emits a text tool call instead of a native
393
+ // one. Strip the whole block; the second pattern catches stray/partial tags
394
+ // (e.g. mid-stream before the closing tag arrives).
395
+ const DSML_TOOL_RE = /<\s*[||]+\s*DSML\s*[||]+\s*tool_calls\s*>[\s\S]*?(?:<\s*\/\s*[||]+\s*DSML\s*[||]+\s*tool_calls\s*>|$)/gi;
396
+ const DSML_STRAY_RE = /<\s*\/?\s*[||]+\s*DSML\s*[||]+[^>]*>/gi;
397
+ // Self-narration about tool results (model echoing stdout/exit_code)
398
+ const TOOL_NARRATION_RE = /(?:The (?:result|output) shows?:?\s*)?-?\s*(?:stdout|stderr|exit_code):\s*.+/gi;
399
+
400
+
401
+ // Model pricing table — per million tokens
402
+ // Model info: pricing (per 1M tokens) + context window length
403
+ const MODEL_INFO = {
404
+ // --- Anthropic ---
405
+ 'claude-sonnet-4-5': { input: 3.00, output: 15.00, ctx: 200000 },
406
+ 'claude-sonnet-4-6': { input: 3.00, output: 15.00, ctx: 200000 },
407
+ 'claude-sonnet-4': { input: 3.00, output: 15.00, ctx: 200000 },
408
+ 'claude-opus-4': { input: 15.00, output: 75.00, ctx: 200000 },
409
+ 'claude-opus-4-6': { input: 15.00, output: 75.00, ctx: 200000 },
410
+ 'claude-haiku-4': { input: 0.80, output: 4.00, ctx: 200000 },
411
+ 'claude-haiku-3-5': { input: 0.80, output: 4.00, ctx: 200000 },
412
+ 'claude-3-5-sonnet': { input: 3.00, output: 15.00, ctx: 200000 },
413
+ 'claude-3-5-haiku': { input: 0.80, output: 4.00, ctx: 200000 },
414
+ 'claude-3-opus': { input: 15.00, output: 75.00, ctx: 200000 },
415
+ 'claude-3-sonnet': { input: 3.00, output: 15.00, ctx: 200000 },
416
+ 'claude-3-haiku': { input: 0.25, output: 1.25, ctx: 200000 },
417
+ // --- OpenAI ---
418
+ 'gpt-5': { input: 2.00, output: 8.00, ctx: 400000 },
419
+ 'gpt-4.1': { input: 2.00, output: 8.00, ctx: 1047576 },
420
+ 'gpt-4.1-mini': { input: 0.40, output: 1.60, ctx: 1047576 },
421
+ 'gpt-4.1-nano': { input: 0.10, output: 0.40, ctx: 1047576 },
422
+ 'gpt-4o': { input: 2.50, output: 10.00, ctx: 128000 },
423
+ 'gpt-4o-mini': { input: 0.15, output: 0.60, ctx: 128000 },
424
+ 'gpt-4-turbo': { input: 10.00, output: 30.00, ctx: 128000 },
425
+ 'o1': { input: 15.00, output: 60.00, ctx: 200000 },
426
+ 'o1-mini': { input: 3.00, output: 12.00, ctx: 128000 },
427
+ 'o1-pro': { input: 150.0, output: 600.0, ctx: 200000 },
428
+ 'o3': { input: 2.00, output: 8.00, ctx: 200000 },
429
+ 'o3-mini': { input: 1.10, output: 4.40, ctx: 200000 },
430
+ 'o4-mini': { input: 1.10, output: 4.40, ctx: 200000 },
431
+ // --- DeepSeek ---
432
+ 'deepseek-chat': { input: 0.27, output: 1.10, ctx: 64000 },
433
+ 'deepseek-coder': { input: 0.27, output: 1.10, ctx: 64000 },
434
+ 'deepseek-reasoner': { input: 0.55, output: 2.19, ctx: 64000 },
435
+ 'deepseek-r1': { input: 0.55, output: 2.19, ctx: 64000 },
436
+ 'deepseek-v3': { input: 0.27, output: 1.10, ctx: 64000 },
437
+ 'deepseek-v2': { input: 0.14, output: 0.28, ctx: 64000 },
438
+ // --- Google ---
439
+ 'gemini-2.5-pro': { input: 1.25, output: 10.00, ctx: 1048576 },
440
+ 'gemini-2.5-flash': { input: 0.15, output: 0.60, ctx: 1048576 },
441
+ 'gemini-2.0-flash': { input: 0.10, output: 0.40, ctx: 1048576 },
442
+ 'gemini-1.5-pro': { input: 1.25, output: 5.00, ctx: 1048576 },
443
+ 'gemini-1.5-flash': { input: 0.075, output: 0.30, ctx: 1048576 },
444
+ 'gemma-3': { input: 0.10, output: 0.10, ctx: 128000 },
445
+ // --- Mistral ---
446
+ 'mistral-large': { input: 2.00, output: 6.00, ctx: 128000 },
447
+ 'mistral-medium': { input: 2.00, output: 6.00, ctx: 32000 },
448
+ 'mistral-small': { input: 0.20, output: 0.60, ctx: 32000 },
449
+ 'mistral-nemo': { input: 0.15, output: 0.15, ctx: 128000 },
450
+ 'mixtral': { input: 0.24, output: 0.24, ctx: 32000 },
451
+ 'codestral': { input: 0.30, output: 0.90, ctx: 32000 },
452
+ 'pixtral': { input: 2.00, output: 6.00, ctx: 128000 },
453
+ // --- xAI ---
454
+ 'grok-4': { input: 3.00, output: 15.00, ctx: 131072 },
455
+ 'grok-3': { input: 3.00, output: 15.00, ctx: 131072 },
456
+ 'grok-2': { input: 2.00, output: 10.00, ctx: 131072 },
457
+ // --- Meta ---
458
+ 'llama-4': { input: 0.20, output: 0.20, ctx: 1048576 },
459
+ 'llama-3.3': { input: 0.20, output: 0.20, ctx: 131072 },
460
+ 'llama-3.2': { input: 0.20, output: 0.20, ctx: 131072 },
461
+ 'llama-3.1': { input: 0.20, output: 0.20, ctx: 131072 },
462
+ 'llama-3': { input: 0.20, output: 0.20, ctx: 131072 },
463
+ // --- Qwen ---
464
+ 'qwen3': { input: 0.30, output: 1.20, ctx: 131072 },
465
+ 'qwen2.5': { input: 0.30, output: 1.20, ctx: 131072 },
466
+ 'qwq': { input: 0.30, output: 1.20, ctx: 32768 },
467
+ // --- Cohere ---
468
+ 'command-a': { input: 2.50, output: 10.00, ctx: 256000 },
469
+ 'command-r-plus': { input: 2.50, output: 10.00, ctx: 128000 },
470
+ 'command-r': { input: 0.15, output: 0.60, ctx: 128000 },
471
+ // --- Perplexity ---
472
+ 'sonar-pro': { input: 3.00, output: 15.00, ctx: 200000 },
473
+ 'sonar': { input: 1.00, output: 1.00, ctx: 128000 },
474
+ // --- MiniMax ---
475
+ 'minimax': { input: 0.70, output: 0.70, ctx: 1000000 },
476
+ // --- Kimi / Moonshot ---
477
+ 'moonshot': { input: 1.00, output: 1.00, ctx: 128000 },
478
+ 'kimi': { input: 1.00, output: 1.00, ctx: 128000 },
479
+ // --- Microsoft ---
480
+ 'phi-4': { input: 0.07, output: 0.14, ctx: 16000 },
481
+ 'phi-3': { input: 0.07, output: 0.14, ctx: 128000 },
482
+ // --- Nvidia ---
483
+ 'nemotron': { input: 0.30, output: 1.20, ctx: 131072 },
484
+ // --- Nous ---
485
+ 'hermes': { input: 0.20, output: 0.20, ctx: 131072 },
486
+ };
487
+
488
+ // Compat alias
489
+ const MODEL_PRICING = MODEL_INFO;
490
+
491
+ // Image generation cost lookup (per-image, by model × quality × size)
492
+ const IMAGE_PRICING = {
493
+ 'gpt-image-1.5': { 'low': { '1024x1024': 0.009, '1024x1536': 0.013, '1536x1024': 0.013 }, 'medium': { '1024x1024': 0.034, '1024x1536': 0.05, '1536x1024': 0.05 }, 'high': { '1024x1024': 0.133, '1024x1536': 0.2, '1536x1024': 0.2 } },
494
+ 'gpt-image-1': { 'low': { '1024x1024': 0.011, '1024x1536': 0.016, '1536x1024': 0.016 }, 'medium': { '1024x1024': 0.042, '1024x1536': 0.063, '1536x1024': 0.063 }, 'high': { '1024x1024': 0.167, '1024x1536': 0.25, '1536x1024': 0.25 } },
495
+ 'gpt-image-1-mini': { 'low': { '1024x1024': 0.005, '1024x1536': 0.006, '1536x1024': 0.006 }, 'medium': { '1024x1024': 0.011, '1024x1536': 0.015, '1536x1024': 0.015 }, 'high': { '1024x1024': 0.036, '1024x1536': 0.052, '1536x1024': 0.052 } },
496
+ };
497
+
498
+ export function shortModel(name) {
499
+ if (!name) return '...';
500
+ if (typeof name !== 'string') name = String(name);
501
+ let short = name.split('/').pop();
502
+ // Strip .gguf extension
503
+ short = short.replace(/\.gguf$/i, '');
504
+ // Strip quantization suffixes (Q4_K_M, Q8_0, etc.) and shard numbers
505
+ short = short.replace(/-0000\d-of-\d+$/, '');
506
+ short = short.replace(/[-_](Q\d[_A-Z\d]*|F16|F32|BF16|fp16|fp32)$/i, '');
507
+ // Truncate if still too long (keep first meaningful part)
508
+ if (short.length > 25) {
509
+ // Try to find a natural break point (dash after model size like -35B or -7B)
510
+ const sizeMatch = short.match(/^(.+?-\d+[BbMm])/);
511
+ if (sizeMatch) short = sizeMatch[1];
512
+ else short = short.substring(0, 22) + '…';
513
+ }
514
+ return short;
515
+ }
516
+
517
+ /**
518
+ * Generate a consistent HSL color for a model name.
519
+ * Returns an hsl() string. The hue is derived from a string hash,
520
+ * saturation and lightness are fixed for readability on dark/light themes.
521
+ */
522
+ export function modelColor(name) {
523
+ if (!name) return null;
524
+ const key = name.toLowerCase();
525
+ let hash = 0;
526
+ for (let i = 0; i < key.length; i++) {
527
+ hash = ((hash << 5) - hash + key.charCodeAt(i)) | 0;
528
+ }
529
+ const hue = ((hash % 360) + 360) % 360;
530
+ return `hsl(${hue}, 55%, 65%)`;
531
+ }
532
+
533
+ /** Look up model info (pricing + context) by substring match */
534
+ export function getModelInfo(modelName) {
535
+ if (!modelName) return null;
536
+ const key = matchModelKey(modelName, Object.keys(MODEL_INFO));
537
+ return key ? { key, ...MODEL_INFO[key] } : null;
538
+ }
539
+
540
+ function _fmtCtx(n) {
541
+ if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
542
+ return Math.round(n / 1000) + 'K';
543
+ }
544
+
545
+ /**
546
+ * Apply model color to a role element (sets color + dot color).
547
+ */
548
+ export function applyModelColor(roleEl, modelName) {
549
+ if (!modelName) return;
550
+ const color = modelColor(modelName);
551
+ if (color) {
552
+ roleEl.style.color = color;
553
+ roleEl.style.setProperty('--model-dot', color);
554
+ }
555
+ // Replace generic dot with provider logo if available
556
+ const logo = providerLogo(modelName);
557
+ if (logo && !roleEl.querySelector('.role-provider-logo')) {
558
+ const span = document.createElement('span');
559
+ span.className = 'role-provider-logo';
560
+ span.innerHTML = logo;
561
+ roleEl.classList.add('has-logo');
562
+ roleEl.prepend(span);
563
+ }
564
+ // Click to show model info popup
565
+ if (!roleEl._hasInfoClick) {
566
+ roleEl._hasInfoClick = true;
567
+ roleEl.style.cursor = 'pointer';
568
+ roleEl.addEventListener('click', (e) => {
569
+ e.stopPropagation();
570
+ document.querySelectorAll('.ctx-popup').forEach(p => { if (typeof p._dismiss === 'function') p._dismiss(); else p.remove(); });
571
+ const info = getModelInfo(modelName);
572
+ const short = shortModel(modelName);
573
+ const logoHtml = providerLogo(modelName);
574
+ const popup = document.createElement('div');
575
+ popup.className = 'ctx-popup';
576
+ let html = '<div style="font-weight:600;margin-bottom:6px;color:var(--fg);display:flex;align-items:center;gap:6px;">';
577
+ if (logoHtml) html += '<span class="role-provider-logo" style="opacity:0.7">' + logoHtml + '</span>';
578
+ html += short + '</div>';
579
+ html += '<div><span class="ctx-label">Model</span> ' + modelName.split('/').pop() + '</div>';
580
+ // Show static context initially, then fetch real from server
581
+ const _realCtx = window._realContextLengths && window._realContextLengths[modelName];
582
+ if (_realCtx) {
583
+ html += '<div><span class="ctx-label">Context</span> ' + _fmtCtx(_realCtx) + ' tokens';
584
+ if (info && info.ctx && info.ctx !== _realCtx) html += ' <span style="opacity:0.35">(spec: ' + _fmtCtx(info.ctx) + ')</span>';
585
+ html += '</div>';
586
+ } else if (info && info.ctx) {
587
+ html += '<div><span class="ctx-label">Context</span> <span id="_ctx-val">' + _fmtCtx(info.ctx) + ' tokens</span></div>';
588
+ }
589
+ // Fetch real context from server async
590
+ if (!_realCtx && window.sessionModule) {
591
+ const _sid = window.sessionModule.getCurrentSessionId();
592
+ if (_sid) {
593
+ fetch('/api/session/' + _sid + '/context_info').then(r => r.ok ? r.json() : null).then(d => {
594
+ if (d && d.context_length) {
595
+ if (!window._realContextLengths) window._realContextLengths = {};
596
+ window._realContextLengths[modelName] = d.context_length;
597
+ const el = document.getElementById('_ctx-val');
598
+ if (el) {
599
+ el.innerHTML = _fmtCtx(d.context_length) + ' tokens';
600
+ if (info && info.ctx && info.ctx !== d.context_length) {
601
+ el.innerHTML += ' <span style="opacity:0.35">(spec: ' + _fmtCtx(info.ctx) + ')</span>';
602
+ }
603
+ }
604
+ }
605
+ }).catch(() => {});
606
+ }
607
+ }
608
+ // Show configured max tokens if set
609
+ if (window.presetsModule) {
610
+ const _pid = window.presetsModule.getSelectedPreset();
611
+ const _preset = _pid ? window.presetsModule.getPreset(_pid) : null;
612
+ const _mt = _preset?.max_tokens;
613
+ if (_mt && _mt > 0 && _mt <= 8192) {
614
+ html += '<div><span class="ctx-label">Max tokens</span> ' + _mt.toLocaleString() + ' <span style="opacity:0.4">(configured)</span></div>';
615
+ }
616
+ }
617
+ if (info && info.input != null) html += '<div><span class="ctx-label">Input</span> $' + info.input.toFixed(2) + ' / 1M</div>';
618
+ if (info && info.output != null) html += '<div><span class="ctx-label">Output</span> $' + info.output.toFixed(2) + ' / 1M</div>';
619
+ if (!info) html += '<div style="opacity:0.4;font-size:0.85em;margin-top:4px;">No pricing data available</div>';
620
+ popup.innerHTML = html;
621
+ const rect = roleEl.getBoundingClientRect();
622
+ popup.style.top = (rect.bottom + 4) + 'px';
623
+ popup.style.left = rect.left + 'px';
624
+ document.body.appendChild(popup);
625
+ const pr = popup.getBoundingClientRect();
626
+ if (pr.bottom > window.innerHeight - 8) popup.style.top = (rect.top - pr.height - 4) + 'px';
627
+ if (pr.right > window.innerWidth - 8) popup.style.left = (window.innerWidth - pr.width - 8) + 'px';
628
+ bindMenuDismiss(popup, () => popup.remove());
629
+ });
630
+ }
631
+ }
632
+
633
+ export function getModelCost(modelName, inputTokens, outputTokens) {
634
+ if (!modelName) return null;
635
+ const key = matchModelKey(modelName, Object.keys(MODEL_PRICING));
636
+ if (!key) return null;
637
+ const price = MODEL_PRICING[key];
638
+ return (inputTokens * price.input + outputTokens * price.output) / 1_000_000;
639
+ }
640
+
641
+ /**
642
+ * Is this endpoint a local / self-hosted model server (vLLM, Ollama, …)?
643
+ * Local models are free, so we must NOT bill them at cloud rates — the
644
+ * pricing table matches on a name substring, so a local `qwen2.5-coder`
645
+ * would otherwise be charged like cloud `qwen2.5`. When the serving host is
646
+ * loopback, a private LAN range, Tailscale CGNAT (100.64–100.127.x), a
647
+ * `.local` name, or the app's own host, the model is local → free.
648
+ * Unknown / missing endpoint also counts as local (bias to not over-bill).
649
+ */
650
+ export function isLocalEndpoint(url) {
651
+ if (!url) return true;
652
+ let host;
653
+ try { host = new URL(url).hostname; } catch (_e) { return true; }
654
+ if (!host) return true;
655
+ if (host === 'localhost' || host === '0.0.0.0' || host === 'host.docker.internal' || host.endsWith('.local')) return true;
656
+ if (typeof window !== 'undefined' && window.location && host === window.location.hostname) return true;
657
+ // A single-label hostname (no dot) is an internal/Docker service name
658
+ // (e.g. "nim-nano", "llamaswap", "nemotron-super-49b") or a LAN shortname —
659
+ // never a public API, which always needs an FQDN. Treat as local → free.
660
+ // (Without this, container-name endpoints get billed at cloud rates because
661
+ // the pricing table matches on a name substring, e.g. "nemotron".)
662
+ if (!host.includes('.')) return true;
663
+ if (/^127\./.test(host)) return true;
664
+ if (/^10\./.test(host)) return true;
665
+ if (/^192\.168\./.test(host)) return true;
666
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(host)) return true;
667
+ const cg = host.match(/^100\.(\d+)\./); // Tailscale CGNAT
668
+ if (cg && +cg[1] >= 64 && +cg[1] <= 127) return true;
669
+ return false;
670
+ }
671
+
672
+ /** Cost for the current turn, returning null (free) for local endpoints. */
673
+ function _billableCost(model, inputTokens, outputTokens) {
674
+ const url = (window.sessionModule && window.sessionModule.getCurrentEndpointUrl)
675
+ ? window.sessionModule.getCurrentEndpointUrl() : null;
676
+ if (isLocalEndpoint(url)) return null;
677
+ return getModelCost(model, inputTokens, outputTokens);
678
+ }
679
+
680
+ export function getImageCost(model, quality, size) {
681
+ if (!model) return null;
682
+ const m = model.toLowerCase();
683
+ for (const [key, quals] of Object.entries(IMAGE_PRICING)) {
684
+ if (m.includes(key)) {
685
+ const q = quals[(quality || 'medium').toLowerCase()] || quals['medium'];
686
+ return q ? (q[size] || q['1024x1024'] || null) : null;
687
+ }
688
+ }
689
+ return null;
690
+ }
691
+
692
+ /* ── Session cost helpers ─────────────────────────────────────────── */
693
+ const _COST_KEY = 'ody-session-cost';
694
+
695
+ /** Return the accumulated cost for the current (or given) session. */
696
+ export function getSessionCost(sessionId) {
697
+ const sid = sessionId || (window.sessionModule && window.sessionModule.getCurrentSessionId());
698
+ if (!sid) return 0;
699
+ try {
700
+ const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
701
+ return costs[sid] || 0;
702
+ } catch (_e) { return 0; }
703
+ }
704
+
705
+ /** Reset session cost for the given session (defaults to current). */
706
+ export function resetSessionCost(sessionId) {
707
+ const sid = sessionId || (window.sessionModule && window.sessionModule.getCurrentSessionId());
708
+ if (!sid) return;
709
+ try {
710
+ const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
711
+ delete costs[sid];
712
+ localStorage.setItem(_COST_KEY, JSON.stringify(costs));
713
+ } catch (_e) { /* ignore */ }
714
+ updateSessionCostUI();
715
+ }
716
+
717
+ /** Update the persistent session-cost badge in the input bar. */
718
+ export function updateSessionCostUI() {
719
+ const el = document.getElementById('session-cost-display');
720
+ if (!el) return;
721
+ // Local model? It's free — hide the badge and clear any stale cost that a
722
+ // previous (buggy) cloud-rate billing left in localStorage for this session.
723
+ const _url = (window.sessionModule && window.sessionModule.getCurrentEndpointUrl)
724
+ ? window.sessionModule.getCurrentEndpointUrl() : null;
725
+ if (isLocalEndpoint(_url)) {
726
+ const sid = window.sessionModule && window.sessionModule.getCurrentSessionId();
727
+ if (sid && getSessionCost(sid) > 0) {
728
+ try {
729
+ const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
730
+ delete costs[sid];
731
+ localStorage.setItem(_COST_KEY, JSON.stringify(costs));
732
+ } catch (_e) { /* ignore */ }
733
+ }
734
+ el.style.display = 'none';
735
+ return;
736
+ }
737
+ const cost = getSessionCost();
738
+ if (cost > 0) {
739
+ el.textContent = '$' + (cost < 0.01 ? cost.toFixed(4) : cost < 1 ? cost.toFixed(3) : cost.toFixed(2));
740
+ el.style.display = '';
741
+ } else {
742
+ el.style.display = 'none';
743
+ }
744
+ }
745
+
746
+ /** Create a timestamp span for role labels.
747
+ * Pass an ISO string / Date / epoch-ms to render the message's own time
748
+ * (used when replaying history). Falls back to "now" when no value is given. */
749
+ export function roleTimestamp(when) {
750
+ const ts = document.createElement('span');
751
+ ts.className = 'role-timestamp';
752
+ let d;
753
+ if (when instanceof Date) d = when;
754
+ else if (typeof when === 'number') d = new Date(when);
755
+ else if (typeof when === 'string' && when) d = new Date(when);
756
+ else d = new Date();
757
+ if (isNaN(d.getTime())) d = new Date();
758
+ ts.textContent = d.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
759
+ ts.title = d.toLocaleString();
760
+ return ts;
761
+ }
762
+
763
+ /**
764
+ * Strip tool invocation blocks from text before rendering.
765
+ */
766
+ export function stripToolBlocks(text) {
767
+ let cleaned = text.replace(TOOL_CALL_RE, '');
768
+ cleaned = cleaned.replace(EXEC_FENCE_RE, '');
769
+ cleaned = cleaned.replace(DSML_TOOL_RE, '');
770
+ cleaned = cleaned.replace(DSML_STRAY_RE, '');
771
+ cleaned = cleaned.replace(XML_TOOL_CALL_RE, '');
772
+ cleaned = cleaned.replace(XML_INVOKE_RE, '');
773
+ cleaned = cleaned.replace(TOOL_NARRATION_RE, '');
774
+ cleaned = cleaned.replace(/\n{3,}/g, '\n\n');
775
+ return cleaned.trim();
776
+ }
777
+
778
+ /**
779
+ * Build a collapsible sources box (used by both research and web search).
780
+ */
781
+ export function buildSourcesBox(sources, type, expanded) {
782
+ var esc = uiModule.esc;
783
+ var id = 'sources-' + Date.now() + '-' + Math.random().toString(36).substr(2, 5);
784
+ var count = sources.length;
785
+ var label = type === 'research' ? 'Research sources' : 'Web sources';
786
+ var lines = '';
787
+ for (var i = 0; i < count; i++) {
788
+ var s = sources[i];
789
+ var domain = '';
790
+ try { domain = new URL(s.url).hostname.replace('www.', ''); } catch(e) { domain = s.url; }
791
+ var title = esc(s.title || domain || '');
792
+ var safeUrl = _safeHref(s.url);
793
+ lines += '<a href="' + safeUrl + '" target="_blank" rel="noopener noreferrer" class="source-link">'
794
+ + '<span class="source-num">' + (i + 1) + '</span>'
795
+ + '<span class="source-title">' + title + '</span>'
796
+ + '<span class="source-domain">' + esc(domain) + '</span>'
797
+ + '</a>';
798
+ }
799
+ var arrow = expanded ? 'down' : 'right';
800
+ var expandedClass = expanded ? ' expanded' : '';
801
+ return '<div class="sources-section">'
802
+ + '<div class="sources-header" data-sources-id="' + id + '" onclick="window.toggleSources(\'' + id + '\')">'
803
+ + '<div class="sources-header-left">' + SEARCH_ICON + '<span>' + count + ' ' + label + '</span></div>'
804
+ + '<span class="sources-toggle" id="' + id + '-toggle" data-arrow="' + arrow + '"></span>'
805
+ + '</div>'
806
+ + '<div class="sources-content' + expandedClass + '" id="' + id + '">'
807
+ + '<div class="sources-content-inner">' + lines + '</div>'
808
+ + '</div></div>';
809
+ }
810
+
811
+ /**
812
+ * Build the RAG "Sources (N documents)" box — mirrors the live render in
813
+ * chat.js so persisted rag_sources survive a refresh. Items carry a
814
+ * filename, similarity %, and snippet (not URLs, unlike web sources).
815
+ * @param {Array<{filename, similarity, snippet}>} sources
816
+ */
817
+ export function buildRagSourcesBox(sources) {
818
+ if (!sources || !sources.length) return '';
819
+ var esc = uiModule.esc;
820
+ var items = '';
821
+ for (var i = 0; i < sources.length; i++) {
822
+ var s = sources[i] || {};
823
+ var pct = (typeof s.similarity === 'number') ? (s.similarity * 100).toFixed(1) + '%' : '';
824
+ items += '<div class="rag-source-item"><strong>' + esc(s.filename || '') + '</strong>'
825
+ + (pct ? ' <span class="rag-similarity">' + pct + '</span>' : '')
826
+ + '<div class="rag-snippet">' + esc(s.snippet || '') + '</div></div>';
827
+ }
828
+ return '<details class="rag-sources"><summary>Sources (' + sources.length + ' documents)</summary>' + items + '</details>';
829
+ }
830
+
831
+ /**
832
+ * Build a collapsible "Raw collected findings" section, styled like the sources box.
833
+ * @param {Array<{url, title, summary}>} findings
834
+ * @param {boolean} [expanded=false]
835
+ */
836
+ export function buildFindingsBox(findings, expanded) {
837
+ if (!findings || !findings.length) return '';
838
+ var esc = uiModule.esc;
839
+ var id = 'findings-' + Date.now() + '-' + Math.random().toString(36).substr(2, 5);
840
+ var count = findings.length;
841
+ var lines = '';
842
+ for (var i = 0; i < count; i++) {
843
+ var f = findings[i];
844
+ var domain = '';
845
+ try { domain = new URL(f.url).hostname.replace('www.', ''); } catch(e) { domain = f.url; }
846
+ var title = esc(f.title || domain || '');
847
+ var summary = esc(f.summary || '');
848
+ var safeUrl = _safeHref(f.url);
849
+ lines += '<div class="finding-item">'
850
+ + '<a href="' + safeUrl + '" target="_blank" rel="noopener noreferrer" class="source-link">'
851
+ + '<span class="source-num">' + (i + 1) + '</span>'
852
+ + '<span class="source-title">' + title + '</span>'
853
+ + '<span class="source-domain">' + esc(domain) + '</span>'
854
+ + '</a>'
855
+ + '<div class="finding-summary">' + summary + '</div>'
856
+ + '</div>';
857
+ }
858
+ var FINDINGS_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>';
859
+ var arrow = expanded ? 'down' : 'right';
860
+ var expandedClass = expanded ? ' expanded' : '';
861
+ return '<div class="sources-section">'
862
+ + '<div class="sources-header" data-sources-id="' + id + '" onclick="window.toggleSources(\'' + id + '\')">'
863
+ + '<div class="sources-header-left">' + FINDINGS_ICON + '<span>' + count + ' Raw collected findings</span></div>'
864
+ + '<span class="sources-toggle" id="' + id + '-toggle" data-arrow="' + arrow + '"></span>'
865
+ + '</div>'
866
+ + '<div class="sources-content' + expandedClass + '" id="' + id + '">'
867
+ + '<div class="sources-content-inner">' + lines + '</div>'
868
+ + '</div></div>';
869
+ }
870
+
871
+ /** Append report button + continue research prompt. */
872
+ export function appendReportButton(container, sessionId) {
873
+ _appendReportButton(container, sessionId);
874
+ _appendContinuePrompt(container);
875
+ }
876
+
877
+ function _appendContinuePrompt(container) {
878
+ var wrap = document.createElement('div');
879
+ wrap.className = 'continue-research-wrap';
880
+ wrap.innerHTML =
881
+ '<div class="continue-research-hint">'
882
+ + '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>'
883
+ + '<span>Dig deeper? Activate Research again and type a follow-up question to continue this research.</span>'
884
+ + '</div>';
885
+ container.appendChild(wrap);
886
+ }
887
+ function _appendReportButton(container, sessionId) {
888
+ var apiBase = window.API_BASE || '';
889
+
890
+ // Wrapper holds report button + chat-about button
891
+ var wrap = document.createElement('div');
892
+ wrap.className = 'report-btn-wrap';
893
+
894
+ var btn = document.createElement('button');
895
+ btn.type = 'button';
896
+ btn.className = 'view-report-btn';
897
+ btn.innerHTML = REPORT_ICON + ' Open Visual Report';
898
+
899
+ var reportUrl = apiBase + '/api/research/report/' + sessionId;
900
+ btn.addEventListener('click', function() {
901
+ window.open(reportUrl, '_blank');
902
+ });
903
+ wrap.appendChild(btn);
904
+
905
+ var chatBtn = document.createElement('button');
906
+ chatBtn.type = 'button';
907
+ chatBtn.className = 'view-report-btn chat-about-btn';
908
+ chatBtn.innerHTML = CHAT_ABOUT_ICON + ' Discuss';
909
+ chatBtn.addEventListener('click', async function() {
910
+ if (chatBtn.disabled) return;
911
+ var origLabel = chatBtn.innerHTML;
912
+ chatBtn.disabled = true;
913
+ chatBtn.innerHTML = CHAT_ABOUT_ICON + ' Creating…';
914
+ try {
915
+ var res = await fetch(apiBase + '/api/research/spinoff/' + sessionId, { method: 'POST' });
916
+ if (!res.ok) {
917
+ var detail = '';
918
+ try { detail = (await res.json()).detail || ''; } catch {}
919
+ throw new Error(detail || ('HTTP ' + res.status));
920
+ }
921
+ var payload = await res.json();
922
+ if (window.sessionModule && payload.session_id) {
923
+ await window.sessionModule.loadSessions().catch(() => {});
924
+ await window.sessionModule.selectSession(payload.session_id);
925
+ }
926
+ } catch (e) {
927
+ chatBtn.disabled = false;
928
+ chatBtn.innerHTML = origLabel;
929
+ if (window.uiModule && uiModule.showError) {
930
+ uiModule.showError('Could not start follow-up chat: ' + e.message);
931
+ } else {
932
+ alert('Could not start follow-up chat: ' + e.message);
933
+ }
934
+ }
935
+ });
936
+ wrap.appendChild(chatBtn);
937
+
938
+ container.appendChild(wrap);
939
+ }
940
+
941
+ window.toggleSources = function(id) {
942
+ // Debounce to prevent double-fire from both inline onclick and delegation
943
+ var now = Date.now();
944
+ if (window._lastSourcesToggle && now - window._lastSourcesToggle < 100) return;
945
+ window._lastSourcesToggle = now;
946
+
947
+ var content = document.getElementById(id);
948
+ var toggle = document.getElementById(id + '-toggle');
949
+ if (content && toggle) {
950
+ var expanded = content.classList.contains('expanded');
951
+ content.classList.toggle('expanded', !expanded);
952
+ toggle.dataset.arrow = expanded ? 'right' : 'down';
953
+ }
954
+ };
955
+
956
+ // Event delegation for sources toggle (capture phase, handles SVG targets)
957
+ document.addEventListener('click', function(e) {
958
+ // Walk up from target manually to handle SVG elements that may not support closest()
959
+ var el = e.target;
960
+ while (el && el !== document) {
961
+ if (el.classList && el.classList.contains('sources-header') && el.dataset && el.dataset.sourcesId) {
962
+ e.stopPropagation();
963
+ window.toggleSources(el.dataset.sourcesId);
964
+ return;
965
+ }
966
+ el = el.parentElement || el.parentNode;
967
+ }
968
+ }, true);
969
+
970
+ // Jump-to-entity anchors — the agent emits links like
971
+ // [New Chat](#session-89effa28)
972
+ // [Notes](#document-abc123)
973
+ // [Reminder](#note-42)
974
+ // and the chat-history click delegate turns them into navigation
975
+ // instead of default in-page anchor jumps. Each prefix routes to the
976
+ // matching module via a dynamic import (avoids circular deps —
977
+ // sessions.js itself imports chatRenderer.js).
978
+ document.addEventListener('click', function(e) {
979
+ const a = e.target && e.target.closest && e.target.closest('a[href]');
980
+ if (!a) return;
981
+ const href = a.getAttribute('href') || '';
982
+ if (!href.startsWith('#')) return;
983
+ const m = href.match(/^#(session|document|note|image|email|event|task|skill|research)-(.+)$/);
984
+ if (!m) return;
985
+ e.preventDefault();
986
+ e.stopPropagation();
987
+ const [, kind, id] = m;
988
+ if (kind === 'session') {
989
+ import('./sessions.js').then(mod => {
990
+ const fn = mod.selectSession || (mod.default && mod.default.selectSession);
991
+ if (fn) fn(id);
992
+ });
993
+ } else if (kind === 'document') {
994
+ import('./document.js').then(mod => {
995
+ const open = mod.loadDocument
996
+ || mod.openDocument
997
+ || (mod.default && (mod.default.loadDocument || mod.default.openDocument));
998
+ if (open) open(id);
999
+ }).catch(() => {});
1000
+ } else if (kind === 'note') {
1001
+ import('./notes.js').then(mod => {
1002
+ const open = mod.openNote || (mod.default && mod.default.openNote);
1003
+ if (open) open(id);
1004
+ }).catch(() => {});
1005
+ } else if (kind === 'image') {
1006
+ import('./gallery.js').then(mod => {
1007
+ const open = mod.openGalleryImage || (mod.default && mod.default.openGalleryImage);
1008
+ if (open) open(id);
1009
+ }).catch(() => {});
1010
+ } else if (kind === 'email') {
1011
+ import('./emailLibrary.js').then(mod => {
1012
+ const open = mod.openEmailLibrary || (mod.default && mod.default.openEmailLibrary);
1013
+ if (open) open({ uid: id });
1014
+ }).catch(() => {});
1015
+ } else if (kind === 'event') {
1016
+ import('./calendar.js').then(mod => {
1017
+ const open = mod.openCalendarTo || (mod.default && mod.default.openCalendarTo);
1018
+ if (open) open(id);
1019
+ }).catch(() => {});
1020
+ } else if (kind === 'task') {
1021
+ import('./tasks.js').then(mod => {
1022
+ const open = mod.openTasks || (mod.default && mod.default.openTasks);
1023
+ if (open) open(id);
1024
+ else { const b = document.getElementById('tasks-btn'); if (b) b.click(); }
1025
+ }).catch(() => { const b = document.getElementById('tasks-btn'); if (b) b.click(); });
1026
+ } else if (kind === 'skill') {
1027
+ import('./skills.js').then(mod => {
1028
+ const open = mod.openSkill || (mod.default && mod.default.openSkill);
1029
+ if (open) open(id);
1030
+ }).catch(() => {});
1031
+ } else if (kind === 'research') {
1032
+ import('./research/panel.js').then(mod => {
1033
+ const open = mod.openPanel || (mod.default && mod.default.openPanel);
1034
+ if (open) open(id);
1035
+ }).catch(() => {});
1036
+ }
1037
+ });
1038
+
1039
+ /**
1040
+ * Build a generated-image bubble element.
1041
+ */
1042
+ export function buildImageBubble(imageUrl, prompt, model, size, quality, imageId) {
1043
+ var esc = uiModule.esc;
1044
+ const wrap = document.createElement('div');
1045
+ wrap.className = 'msg msg-ai generated-image-wrap';
1046
+
1047
+ const role = document.createElement('div');
1048
+ role.className = 'role';
1049
+ role.textContent = (model || 'image').split('/').pop();
1050
+ wrap.appendChild(role);
1051
+
1052
+ const body = document.createElement('div');
1053
+ body.className = 'body';
1054
+
1055
+ const img = document.createElement('img');
1056
+ img.className = 'generated-image';
1057
+ img.alt = prompt || 'Generated image';
1058
+ img.title = prompt || 'Generated image';
1059
+ img.src = imageUrl;
1060
+ img.addEventListener('click', () => { window.open(img.src, '_blank'); });
1061
+ body.appendChild(img);
1062
+
1063
+ if (prompt) {
1064
+ const caption = document.createElement('div');
1065
+ caption.className = 'generated-image-caption';
1066
+ caption.textContent = prompt;
1067
+ body.appendChild(caption);
1068
+ }
1069
+
1070
+ wrap.appendChild(body);
1071
+
1072
+ const footer = document.createElement('div');
1073
+ footer.className = 'msg-footer';
1074
+
1075
+ const actions = document.createElement('span');
1076
+ actions.className = 'msg-actions';
1077
+
1078
+ const copyBtn = document.createElement('button');
1079
+ copyBtn.className = 'footer-copy-btn';
1080
+ copyBtn.type = 'button';
1081
+ copyBtn.title = 'Copy prompt';
1082
+ copyBtn.innerHTML = COPY_ICON;
1083
+ copyBtn.addEventListener('click', (e) => {
1084
+ e.stopPropagation();
1085
+ uiModule.copyToClipboard(prompt || '');
1086
+ copyBtn.innerHTML = CHECK_ICON;
1087
+ setTimeout(() => { copyBtn.innerHTML = COPY_ICON; }, 1500);
1088
+ });
1089
+ actions.appendChild(copyBtn);
1090
+
1091
+ const dlBtn = document.createElement('button');
1092
+ dlBtn.className = 'footer-copy-btn';
1093
+ dlBtn.type = 'button';
1094
+ dlBtn.title = 'Download image';
1095
+ dlBtn.textContent = '\u2913';
1096
+ dlBtn.addEventListener('click', async (e) => {
1097
+ e.stopPropagation();
1098
+ try {
1099
+ const resp = await fetch(imageUrl);
1100
+ const blob = await resp.blob();
1101
+ const a = document.createElement('a');
1102
+ a.href = URL.createObjectURL(blob);
1103
+ a.download = (prompt || 'image').slice(0, 40).replace(/[^a-zA-Z0-9 ]/g, '') + '.png';
1104
+ document.body.appendChild(a);
1105
+ a.click();
1106
+ a.remove();
1107
+ URL.revokeObjectURL(a.href);
1108
+ dlBtn.textContent = '\u2713';
1109
+ setTimeout(() => { dlBtn.textContent = '\u2913'; }, 1500);
1110
+ } catch { dlBtn.textContent = '\u2717'; setTimeout(() => { dlBtn.textContent = '\u2913'; }, 1500); }
1111
+ });
1112
+ actions.appendChild(dlBtn);
1113
+
1114
+ const editBtn = document.createElement('button');
1115
+ editBtn.className = 'footer-copy-btn';
1116
+ editBtn.type = 'button';
1117
+ editBtn.title = 'Edit in image editor';
1118
+ editBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>';
1119
+ editBtn.addEventListener('click', async (e) => {
1120
+ e.stopPropagation();
1121
+ try {
1122
+ const [galleryMod, editorMod] = await Promise.all([
1123
+ import('./gallery.js'),
1124
+ import('./galleryEditor.js'),
1125
+ ]);
1126
+ // Ensure the Gallery modal is open so the editor has a container
1127
+ // to render into; switch its tabs to the Edit tab.
1128
+ galleryMod.default.openGallery();
1129
+ const modal = document.getElementById('gallery-modal');
1130
+ if (modal) {
1131
+ modal.querySelectorAll('.gallery-tab').forEach(t => t.classList.remove('active'));
1132
+ modal.querySelector('.gallery-tab[data-tab="editor"]')?.classList.add('active');
1133
+ }
1134
+ const imagesContainer = document.getElementById('gallery-images-container');
1135
+ const albumsContainer = document.getElementById('gallery-albums-container');
1136
+ if (imagesContainer) imagesContainer.style.display = 'none';
1137
+ if (albumsContainer) albumsContainer.style.display = 'none';
1138
+ const editorContainer = document.getElementById('gallery-editor-container');
1139
+ if (editorContainer) editorContainer.style.display = 'flex';
1140
+ const label = (prompt || '').trim().slice(0, 60) || 'Generated image';
1141
+ editorMod.openEditor(imageUrl, null, null, label);
1142
+ } catch (err) {
1143
+ console.error('[chat] open in editor failed', err);
1144
+ }
1145
+ });
1146
+ actions.appendChild(editBtn);
1147
+
1148
+ const delBtn = document.createElement('button');
1149
+ delBtn.className = 'footer-copy-btn footer-delete-btn';
1150
+ delBtn.type = 'button';
1151
+ delBtn.title = 'Delete image';
1152
+ delBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>';
1153
+ delBtn.addEventListener('click', async (e) => {
1154
+ e.stopPropagation();
1155
+ const ok = await uiModule.styledConfirm('Delete this image?', {
1156
+ confirmText: 'Delete',
1157
+ cancelText: 'Cancel',
1158
+ danger: true,
1159
+ });
1160
+ if (!ok) return;
1161
+ // If we have a gallery id, delete server-side; otherwise just remove
1162
+ // the bubble from chat (e.g. external DALL-E url that wasn't saved).
1163
+ if (imageId) {
1164
+ try {
1165
+ const res = await fetch(`/api/gallery/${encodeURIComponent(imageId)}`, {
1166
+ method: 'DELETE', credentials: 'same-origin',
1167
+ });
1168
+ if (!res.ok && res.status !== 404) {
1169
+ uiModule.showToast?.('Delete failed', 4000);
1170
+ return;
1171
+ }
1172
+ window.dispatchEvent(new CustomEvent('gallery-refresh'));
1173
+ } catch (_) {
1174
+ uiModule.showToast?.('Delete failed', 4000);
1175
+ return;
1176
+ }
1177
+ }
1178
+ wrap.remove();
1179
+ });
1180
+ actions.appendChild(delBtn);
1181
+
1182
+ footer.appendChild(actions);
1183
+
1184
+ const metrics = document.createElement('span');
1185
+ metrics.className = 'response-metrics';
1186
+ const parts = [];
1187
+ if (model) parts.push(model.split('/').pop());
1188
+ if (size) parts.push(size);
1189
+ if (quality) parts.push(quality);
1190
+ const cost = getImageCost(model, quality, size);
1191
+ if (cost !== null) parts.push('$' + (cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)));
1192
+ metrics.textContent = parts.join(' \u00B7 ');
1193
+ footer.appendChild(metrics);
1194
+
1195
+ wrap.appendChild(footer);
1196
+ return wrap;
1197
+ }
1198
+
1199
+ export function hideWelcomeScreen() {
1200
+ const ws = document.getElementById('welcome-screen');
1201
+ const cc = document.getElementById('chat-container');
1202
+ if (ws) ws.classList.add('hidden');
1203
+ if (cc) cc.classList.remove('welcome-active');
1204
+ // Update send button — switches from muted arrow to + Chat
1205
+ if (window._updateSendBtnIcon) setTimeout(window._updateSendBtnIcon, 50);
1206
+ const ib = document.getElementById('incognito-btn');
1207
+ if (ib) ib.style.display = ib.classList.contains('active') ? '' : 'none';
1208
+ }
1209
+
1210
+ export function showWelcomeScreen() {
1211
+ const ws = document.getElementById('welcome-screen');
1212
+ const cc = document.getElementById('chat-container');
1213
+ if (ws) ws.classList.remove('hidden');
1214
+ if (cc) cc.classList.add('welcome-active');
1215
+ // Entering the New Chat / welcome state: discard any stale draft left in the
1216
+ // composer from the previous session so the input starts empty (issue #1343).
1217
+ // Switching between existing sessions loads them directly and does NOT call
1218
+ // this, so genuine drafts are not erased. Reset the autosized height and fire
1219
+ // an `input` event so the send button + autosize listeners update.
1220
+ const _msg = document.getElementById('message');
1221
+ if (_msg) {
1222
+ _msg.value = '';
1223
+ _msg.style.height = '';
1224
+ _msg.dispatchEvent(new Event('input', { bubbles: true }));
1225
+ }
1226
+ // Re-trigger the L→R clip-wipe reveal on the welcome name each time the
1227
+ // welcome screen is shown (new session, deleted last session, etc.) — without
1228
+ // this, the CSS animation only fires on initial DOM insertion.
1229
+ const wn = document.querySelector('.welcome-name');
1230
+ if (wn) {
1231
+ wn.style.animation = 'none';
1232
+ // force reflow so the next assignment registers as a new animation
1233
+ void wn.offsetHeight;
1234
+ wn.style.animation = '';
1235
+ }
1236
+ // Update send button — switches from + Chat to muted arrow on empty session
1237
+ if (window._updateSendBtnIcon) setTimeout(window._updateSendBtnIcon, 50);
1238
+ const ib = document.getElementById('incognito-btn');
1239
+ const _researchChk = document.getElementById('research-toggle');
1240
+ if (ib && !(_researchChk && _researchChk.checked)) ib.style.display = '';
1241
+ if (window.innerWidth > 768) {
1242
+ const msg = document.getElementById('message');
1243
+ if (msg) msg.focus();
1244
+ }
1245
+ }
1246
+
1247
+ // ── Dynamic action buttons (show 3 most recent, rest under ···) ──
1248
+ const _ACTION_RECENTS_KEY = 'zero-msg-actions-recent';
1249
+ const _MAX_VISIBLE = 2;
1250
+
1251
+ function _getRecentActions() {
1252
+ try { return JSON.parse(localStorage.getItem(_ACTION_RECENTS_KEY) || '[]'); } catch { return []; }
1253
+ }
1254
+ function _trackAction(id) {
1255
+ let recent = _getRecentActions().filter(x => x !== id);
1256
+ recent.unshift(id);
1257
+ if (recent.length > 10) recent.length = 10;
1258
+ localStorage.setItem(_ACTION_RECENTS_KEY, JSON.stringify(recent));
1259
+ }
1260
+
1261
+ /**
1262
+ * Create a footer row for an AI message with timestamp and action buttons.
1263
+ */
1264
+ export function createMsgFooter(msgElement) {
1265
+ const footer = document.createElement('div');
1266
+ footer.className = 'msg-footer';
1267
+
1268
+ const actions = document.createElement('span');
1269
+ actions.className = 'msg-actions';
1270
+
1271
+ // Define all available actions: { id, icon, title, className, handler }
1272
+ const allActions = [
1273
+ { id: 'copy', icon: COPY_ICON, title: 'Copy message', cls: 'footer-copy-btn', html: true, handler(e) {
1274
+ e.stopPropagation();
1275
+ const btn = e.currentTarget;
1276
+ uiModule.copyToClipboard(msgElement.dataset.raw || msgElement.querySelector('.body')?.textContent || '');
1277
+ btn.innerHTML = CHECK_ICON;
1278
+ setTimeout(() => { btn.innerHTML = COPY_ICON; }, 1500);
1279
+ }},
1280
+ { id: 'edit', icon: '\u270E', title: 'Edit', cls: 'msg-action-btn', handler(e) {
1281
+ e.stopPropagation();
1282
+ if (window.chatModule?.editAIMessage) window.chatModule.editAIMessage(msgElement);
1283
+ }},
1284
+ { id: 'regen', icon: '\u21BB', title: 'Regenerate from here', cls: 'msg-action-btn', handler(e) {
1285
+ e.stopPropagation();
1286
+ if (window.chatModule?.regenerateFrom) window.chatModule.regenerateFrom(msgElement);
1287
+ }},
1288
+ { id: 'shorten', icon: '\u2702', title: 'Rewrite shorter', cls: 'msg-action-btn', handler(e) {
1289
+ e.stopPropagation();
1290
+ if (window.chatModule?.rewriteWith) window.chatModule.rewriteWith(msgElement, 'Rewrite your last response to be shorter and more concise. Keep the key information but cut the fluff.');
1291
+ }},
1292
+ { id: 'explain', icon: '?', title: 'Explain simpler', cls: 'msg-action-btn', handler(e) {
1293
+ e.stopPropagation();
1294
+ if (window.chatModule?.rewriteWith) window.chatModule.rewriteWith(msgElement, 'Explain your last response in simpler terms. Use plain language and short sentences.');
1295
+ }},
1296
+ { id: 'fork', icon: '\u2ADD', title: 'Fork conversation', cls: 'msg-action-btn', handler(e) {
1297
+ e.stopPropagation();
1298
+ if (window.chatModule?.forkFrom) window.chatModule.forkFrom(msgElement);
1299
+ }},
1300
+ { id: 'delete', icon: '\u2715', title: 'Delete message', cls: 'msg-action-btn msg-delete-btn', handler(e) {
1301
+ e.stopPropagation();
1302
+ if (window.chatModule?.deleteMessage) window.chatModule.deleteMessage(msgElement);
1303
+ }},
1304
+ ];
1305
+
1306
+ // Filter out unavailable actions (e.g. TTS when not enabled)
1307
+ const availableActions = allActions.filter(a => !a.available || a.available());
1308
+
1309
+ // Determine which 3 to show: use recent order, fallback to defaults
1310
+ const recent = _getRecentActions();
1311
+ const defaults = ['copy', 'delete', 'fork'];
1312
+ const order = recent.length > 0 ? recent : defaults;
1313
+ const sorted = [...availableActions].sort((a, b) => {
1314
+ const ai = order.indexOf(a.id), bi = order.indexOf(b.id);
1315
+ if (ai >= 0 && bi >= 0) return ai - bi;
1316
+ if (ai >= 0) return -1;
1317
+ if (bi >= 0) return 1;
1318
+ return 0;
1319
+ });
1320
+ const visible = sorted.slice(0, _MAX_VISIBLE);
1321
+ const overflow = sorted.slice(_MAX_VISIBLE);
1322
+
1323
+ // Render visible buttons
1324
+ function _addBtn(action, container) {
1325
+ const btn = _makeActionBtn(action.cls, action.title, action.html ? '' : action.icon, (e) => {
1326
+ _trackAction(action.id);
1327
+ action.handler(e);
1328
+ });
1329
+ if (action.html) btn.innerHTML = action.icon;
1330
+ btn.dataset.action = action.id;
1331
+ container.appendChild(btn);
1332
+ }
1333
+
1334
+ visible.forEach(a => _addBtn(a, actions));
1335
+
1336
+ // Overflow "···" button
1337
+ if (overflow.length > 0) {
1338
+ const moreBtn = document.createElement('button');
1339
+ moreBtn.className = 'msg-action-btn msg-more-btn';
1340
+ moreBtn.type = 'button';
1341
+ moreBtn.title = 'More actions';
1342
+ moreBtn.textContent = '\u00B7\u00B7\u00B7';
1343
+ moreBtn.addEventListener('click', (e) => {
1344
+ e.stopPropagation();
1345
+ // Toggle overflow menu — close any existing one first (through its own
1346
+ // dismiss so the Escape registry entry goes with it).
1347
+ const existing = document.querySelector('.msg-overflow-menu');
1348
+ if (existing) {
1349
+ if (typeof existing._dismiss === 'function') existing._dismiss(); else existing.remove();
1350
+ if (existing._trigger === moreBtn) return;
1351
+ }
1352
+
1353
+ const menu = document.createElement('div');
1354
+ menu.className = 'msg-overflow-menu';
1355
+ let closeMenu = () => menu.remove();
1356
+ overflow.forEach(a => {
1357
+ const item = document.createElement('button');
1358
+ item.className = 'msg-overflow-item';
1359
+ item.type = 'button';
1360
+ item.title = a.title;
1361
+ item.innerHTML = `<span class="overflow-icon">${a.icon}</span> ${a.title}`;
1362
+ item.addEventListener('click', (ev) => {
1363
+ ev.stopPropagation();
1364
+ _trackAction(a.id);
1365
+ closeMenu();
1366
+ a.handler(ev);
1367
+ });
1368
+ menu.appendChild(item);
1369
+ });
1370
+ menu._trigger = moreBtn;
1371
+ document.body.appendChild(menu);
1372
+ // Position fixed relative to the ··· button
1373
+ const btnRect = moreBtn.getBoundingClientRect();
1374
+ menu.style.top = (btnRect.top - menu.offsetHeight - 4) + 'px';
1375
+ menu.style.left = btnRect.left + 'px';
1376
+ // Flip down if above viewport
1377
+ if (parseFloat(menu.style.top) < 8) menu.style.top = (btnRect.bottom + 4) + 'px';
1378
+ // Keep within right edge
1379
+ const mr = menu.getBoundingClientRect();
1380
+ if (mr.right > window.innerWidth - 8) menu.style.left = (window.innerWidth - mr.width - 8) + 'px';
1381
+ // Close on outside click or Escape. The trigger button is treated as
1382
+ // "inside" so its own click toggles rather than double-fires.
1383
+ closeMenu = bindMenuDismiss(menu, () => menu.remove(), (ev) => !menu.contains(ev.target) && ev.target !== moreBtn); });
1384
+ actions.appendChild(moreBtn);
1385
+ }
1386
+
1387
+ // Memory-used indicator pill
1388
+ const mems = msgElement._memoriesUsed;
1389
+ if (mems && mems.length > 0) {
1390
+ const pill = document.createElement('button');
1391
+ pill.className = 'memory-used-pill';
1392
+ pill.type = 'button';
1393
+ const pinnedCount = mems.filter(m => m.type === 'pinned').length;
1394
+ const recalledCount = mems.filter(m => m.type === 'recalled').length;
1395
+ const parts = [];
1396
+ if (pinnedCount) parts.push(`${pinnedCount} pinned`);
1397
+ if (recalledCount) parts.push(`${recalledCount} recalled`);
1398
+ pill.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:3px"><path d="M12 2a7 7 0 0 1 7 7c0 2.5-1.3 4.8-3.5 6-.3.2-.5.5-.5.9V18h-6v-2.1c0-.4-.2-.7-.5-.9C6.3 13.8 5 11.5 5 9a7 7 0 0 1 7-7z"/><path d="M9 18h6v1a3 3 0 0 1-6 0v-1z"/><path d="M12 2v7"/><path d="M8.5 6.5L12 9l3.5-2.5"/></svg><span class="memory-used-pill-text">${parts.join(', ')}</span>`;
1399
+ pill.title = mems.map(m => `[${m.type}] ${m.text}`).join('\n');
1400
+
1401
+ pill.addEventListener('click', (e) => {
1402
+ e.stopPropagation();
1403
+ let detail = pill._openDetail || document.querySelector('.memory-used-detail');
1404
+ if (detail) {
1405
+ if (typeof detail._dismiss === 'function') detail._dismiss();
1406
+ else { detail.remove(); pill._openDetail = null; }
1407
+ return;
1408
+ }
1409
+ detail = document.createElement('div');
1410
+ detail.className = 'memory-used-detail';
1411
+ let closeDetail = () => { detail.remove(); pill._openDetail = null; };
1412
+ mems.forEach(m => {
1413
+ const row = document.createElement('div');
1414
+ row.className = 'memory-used-row';
1415
+ row.style.cursor = 'pointer';
1416
+ row.title = 'Click to open memory manager';
1417
+ const badge = document.createElement('span');
1418
+ badge.className = 'memory-used-badge ' + (m.type === 'pinned' ? 'pinned' : 'recalled');
1419
+ badge.textContent = m.type === 'pinned' ? '\u25CF' : '\u21BB';
1420
+ const text = document.createElement('span');
1421
+ text.className = 'memory-used-text';
1422
+ text.textContent = m.text;
1423
+ row.appendChild(badge);
1424
+ row.appendChild(text);
1425
+ row.addEventListener('click', (ev) => {
1426
+ ev.stopPropagation();
1427
+ closeDetail();
1428
+ const memModal = document.getElementById('memory-modal');
1429
+ if (memModal) memModal.classList.remove('hidden');
1430
+ });
1431
+ detail.appendChild(row);
1432
+ });
1433
+ detail.style.visibility = 'hidden';
1434
+ document.body.appendChild(detail);
1435
+ const pillRect = pill.getBoundingClientRect();
1436
+ const detailRect = detail.getBoundingClientRect();
1437
+ const spaceAbove = pillRect.top;
1438
+ const spaceBelow = window.innerHeight - pillRect.bottom;
1439
+ if (spaceAbove >= detailRect.height + 8 || spaceAbove > spaceBelow) {
1440
+ detail.style.top = (pillRect.top - detailRect.height - 8) + 'px';
1441
+ } else {
1442
+ detail.style.top = (pillRect.bottom + 8) + 'px';
1443
+ }
1444
+ detail.style.left = pillRect.left + 'px';
1445
+ if (pillRect.left + detailRect.width > window.innerWidth - 8) {
1446
+ detail.style.left = (window.innerWidth - detailRect.width - 8) + 'px';
1447
+ }
1448
+ if (parseFloat(detail.style.left) < 8) detail.style.left = '8px';
1449
+ detail.style.visibility = '';
1450
+ pill._openDetail = detail;
1451
+ // Close on outside click or Escape (pill click toggles, so it's inside).
1452
+ closeDetail = bindMenuDismiss(detail, () => { detail.remove(); pill._openDetail = null; }, (ev) => !detail.contains(ev.target) && ev.target !== pill); });
1453
+
1454
+ footer.appendChild(pill);
1455
+ }
1456
+
1457
+ footer.appendChild(actions);
1458
+ return footer;
1459
+ }
1460
+
1461
+ /**
1462
+ * Create a footer row for a user message with action buttons (same system as AI footer).
1463
+ */
1464
+ const _USER_ACTION_RECENTS_KEY = 'zero-user-actions-recent';
1465
+
1466
+ function _getUserRecentActions() {
1467
+ try { return JSON.parse(localStorage.getItem(_USER_ACTION_RECENTS_KEY) || '[]'); } catch { return []; }
1468
+ }
1469
+ function _trackUserAction(id) {
1470
+ let recent = _getUserRecentActions().filter(x => x !== id);
1471
+ recent.unshift(id);
1472
+ if (recent.length > 10) recent.length = 10;
1473
+ localStorage.setItem(_USER_ACTION_RECENTS_KEY, JSON.stringify(recent));
1474
+ }
1475
+
1476
+ export function createUserMsgFooter(msgElement) {
1477
+ const footer = document.createElement('div');
1478
+ footer.className = 'msg-footer';
1479
+
1480
+ const actions = document.createElement('span');
1481
+ actions.className = 'msg-actions';
1482
+
1483
+ const allActions = [
1484
+ { id: 'edit', icon: '\u270E', title: 'Edit message', cls: 'msg-action-btn', handler(e) {
1485
+ e.stopPropagation();
1486
+ if (window.chatModule?.editUserMessage) window.chatModule.editUserMessage(msgElement);
1487
+ }},
1488
+ { id: 'delete', icon: '\u2715', title: 'Delete message', cls: 'msg-action-btn msg-delete-btn', handler(e) {
1489
+ e.stopPropagation();
1490
+ if (window.chatModule?.deleteMessage) window.chatModule.deleteMessage(msgElement);
1491
+ }},
1492
+ { id: 'copy', icon: COPY_ICON, title: 'Copy message', cls: 'footer-copy-btn', html: true, handler(e) {
1493
+ e.stopPropagation();
1494
+ const btn = e.currentTarget;
1495
+ uiModule.copyToClipboard(msgElement.querySelector('.body')?.textContent || '');
1496
+ btn.innerHTML = CHECK_ICON;
1497
+ setTimeout(() => { btn.innerHTML = COPY_ICON; }, 1500);
1498
+ }},
1499
+ { id: 'resend', icon: '\u21BB', title: 'Resend message', cls: 'msg-action-btn', handler(e) {
1500
+ e.stopPropagation();
1501
+ if (window.chatModule?.resendUserMessage) window.chatModule.resendUserMessage(msgElement);
1502
+ }},
1503
+ ];
1504
+
1505
+ const recent = _getUserRecentActions();
1506
+ const defaults = ['edit', 'delete', 'copy'];
1507
+ const order = recent.length > 0 ? recent : defaults;
1508
+ const sorted = [...allActions].sort((a, b) => {
1509
+ const ai = order.indexOf(a.id), bi = order.indexOf(b.id);
1510
+ if (ai >= 0 && bi >= 0) return ai - bi;
1511
+ if (ai >= 0) return -1;
1512
+ if (bi >= 0) return 1;
1513
+ return 0;
1514
+ });
1515
+ const visible = sorted.slice(0, _MAX_VISIBLE);
1516
+ const overflow = sorted.slice(_MAX_VISIBLE);
1517
+
1518
+ visible.forEach(a => {
1519
+ const btn = _makeActionBtn(a.cls, a.title, a.html ? '' : a.icon, (ev) => {
1520
+ _trackUserAction(a.id);
1521
+ a.handler(ev);
1522
+ });
1523
+ if (a.html) btn.innerHTML = a.icon;
1524
+ btn.dataset.action = a.id;
1525
+ actions.appendChild(btn);
1526
+ });
1527
+
1528
+ if (overflow.length > 0) {
1529
+ const moreBtn = document.createElement('button');
1530
+ moreBtn.className = 'msg-action-btn msg-more-btn';
1531
+ moreBtn.type = 'button';
1532
+ moreBtn.title = 'More actions';
1533
+ moreBtn.textContent = '\u00B7\u00B7\u00B7';
1534
+ moreBtn.addEventListener('click', (e) => {
1535
+ e.stopPropagation();
1536
+ const existing = document.querySelector('.msg-overflow-menu');
1537
+ if (existing) {
1538
+ if (typeof existing._dismiss === 'function') existing._dismiss(); else existing.remove();
1539
+ if (existing._trigger === moreBtn) return;
1540
+ }
1541
+
1542
+ const menu = document.createElement('div');
1543
+ menu.className = 'msg-overflow-menu';
1544
+ let closeMenu = () => menu.remove();
1545
+ overflow.forEach(a => {
1546
+ const item = document.createElement('button');
1547
+ item.className = 'msg-overflow-item';
1548
+ item.type = 'button';
1549
+ item.title = a.title;
1550
+ item.innerHTML = `<span class="overflow-icon">${a.icon}</span> ${a.title}`;
1551
+ item.addEventListener('click', (ev) => {
1552
+ ev.stopPropagation();
1553
+ _trackUserAction(a.id);
1554
+ closeMenu();
1555
+ a.handler(ev);
1556
+ });
1557
+ menu.appendChild(item);
1558
+ });
1559
+ menu._trigger = moreBtn;
1560
+ document.body.appendChild(menu);
1561
+ const btnRect = moreBtn.getBoundingClientRect();
1562
+ menu.style.top = (btnRect.top - menu.offsetHeight - 4) + 'px';
1563
+ menu.style.left = btnRect.left + 'px';
1564
+ if (parseFloat(menu.style.top) < 8) menu.style.top = (btnRect.bottom + 4) + 'px';
1565
+ const mr = menu.getBoundingClientRect();
1566
+ if (mr.right > window.innerWidth - 8) menu.style.left = (window.innerWidth - mr.width - 8) + 'px';
1567
+ closeMenu = bindMenuDismiss(menu, () => menu.remove(), (ev) => !menu.contains(ev.target) && ev.target !== moreBtn); });
1568
+ actions.appendChild(moreBtn);
1569
+ }
1570
+
1571
+ footer.appendChild(actions);
1572
+ return footer;
1573
+ }
1574
+
1575
+ /**
1576
+ * Display performance metrics for a message.
1577
+ */
1578
+ export function displayMetrics(messageElement, metrics) {
1579
+ const existingMetrics = messageElement.querySelector('.response-metrics');
1580
+ if (existingMetrics) existingMetrics.remove();
1581
+
1582
+ const metricsContainer = document.createElement('span');
1583
+ metricsContainer.className = 'response-metrics';
1584
+
1585
+ const responseTime = metrics.response_time;
1586
+ const inputTokens = metrics.input_tokens || 0;
1587
+ const outputTokens = metrics.output_tokens || 0;
1588
+ const tps = metrics.tokens_per_second;
1589
+ const isReal = metrics.usage_source === 'real';
1590
+ const ctxPct = metrics.context_percent;
1591
+ const model = metrics.model || 'Unknown';
1592
+ const cost = _billableCost(model, inputTokens, outputTokens);
1593
+
1594
+ // Nothing useful to show — bail out (only if ALL metrics are missing)
1595
+ if (!responseTime && !outputTokens && tps == null && !ctxPct) return;
1596
+
1597
+ // Accumulate session cost (only on fresh metrics, not history reload)
1598
+ if (!metrics._fromHistory) {
1599
+ const _sid = window.sessionModule && window.sessionModule.getCurrentSessionId();
1600
+ if (_sid && cost !== null) {
1601
+ try {
1602
+ const _costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
1603
+ _costs[_sid] = (_costs[_sid] || 0) + cost;
1604
+ localStorage.setItem(_COST_KEY, JSON.stringify(_costs));
1605
+ } catch (_e) { /* ignore */ }
1606
+ updateSessionCostUI();
1607
+ }
1608
+ }
1609
+
1610
+ // Default: show tok/s if available, else fall back to other stats
1611
+ const costStr0 = cost !== null ? `$${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}` : null;
1612
+ const metricsLabel = tps != null && tps !== 'undefined'
1613
+ ? `${tps} tok/s`
1614
+ : costStr0
1615
+ ? `${outputTokens} tok · ${costStr0}`
1616
+ : outputTokens
1617
+ ? `${outputTokens} tok · ${responseTime != null ? responseTime + 's' : ''}`
1618
+ : responseTime != null
1619
+ ? `${responseTime}s`
1620
+ : '';
1621
+ if (!metricsLabel) return;
1622
+ metricsContainer.textContent = metricsLabel;
1623
+ metricsContainer.style.cursor = 'pointer';
1624
+ metricsContainer.title = 'Click for details';
1625
+ const metricsDivider = document.createElement('span');
1626
+ metricsDivider.textContent = ' | ';
1627
+ metricsDivider.style.color = 'var(--color-muted-alt)';
1628
+ metricsDivider.style.pointerEvents = 'none';
1629
+ metricsContainer.addEventListener('click', (e) => {
1630
+ e.stopPropagation();
1631
+ document.querySelectorAll('.ctx-popup').forEach(p => { if (typeof p._dismiss === 'function') p._dismiss(); else p.remove(); });
1632
+
1633
+ const costStr = cost !== null ? `$${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}` : 'n/a';
1634
+ const speedStr = tps != null && tps !== 'undefined' ? `${tps} tok/s` : 'n/a';
1635
+ const totalTok = inputTokens + outputTokens;
1636
+ const ctxColor = ctxPct >= 85 ? 'var(--red, #e06c75)' : ctxPct >= 70 ? '#ff9900' : 'var(--color-muted-alt, #6b7280)';
1637
+ const prepTime = metrics.agent_prep_time;
1638
+ const modelWaitTime = metrics.agent_model_wait_time;
1639
+ const prepBreakdown = metrics.agent_prep_breakdown || null;
1640
+ const prepDetails = prepBreakdown
1641
+ ? Object.entries(prepBreakdown).map(([k, v]) => `${k}: ${v}s`).join('<br>')
1642
+ : '';
1643
+
1644
+ // Session total cost
1645
+ let sessionCostStr = '';
1646
+ const sc = getSessionCost();
1647
+ if (sc > 0) {
1648
+ sessionCostStr = `<div><span class="ctx-label">Session</span> $${sc < 0.01 ? sc.toFixed(4) : sc.toFixed(3)}</div>`;
1649
+ }
1650
+
1651
+ const popup = document.createElement('div');
1652
+ popup.className = 'ctx-popup';
1653
+ popup.innerHTML = `
1654
+ <div style="font-weight:600;margin-bottom:6px;color:var(--fg);">Message Stats</div>
1655
+ <div><span class="ctx-label">Model</span> ${model.split('/').pop()}</div>
1656
+ <div><span class="ctx-label">Input</span> ${inputTokens.toLocaleString()} tokens${isReal ? '' : '~'}</div>
1657
+ <div><span class="ctx-label">Output</span> ${outputTokens.toLocaleString()} tokens${isReal ? '' : '~'}</div>
1658
+ <div><span class="ctx-label">Total</span> ${totalTok.toLocaleString()} tokens</div>
1659
+ <div><span class="ctx-label">Speed</span> ${speedStr}</div>
1660
+ <div><span class="ctx-label">Time</span> ${responseTime}s</div>
1661
+ ${prepTime != null ? `<div><span class="ctx-label">Prep</span> ${prepTime}s</div>` : ''}
1662
+ ${modelWaitTime != null ? `<div><span class="ctx-label">Model wait</span> ${modelWaitTime}s</div>` : ''}
1663
+ <div><span class="ctx-label">Cost</span> ${costStr}</div>
1664
+ ${sessionCostStr}
1665
+ ${prepDetails ? `<div style="margin-top:6px;padding-top:6px;border-top:1px solid var(--border);font-size:0.85em;opacity:0.8;">
1666
+ <div style="font-weight:600;margin-bottom:4px;color:var(--fg);">Agent prep</div>
1667
+ ${prepDetails}
1668
+ </div>` : ''}
1669
+ ${ctxPct !== undefined && ctxPct > 0 ? `<div style="margin-top:6px;padding-top:6px;border-top:1px solid var(--border);">
1670
+ <span class="ctx-label">Context</span> <span style="color:${ctxColor};font-weight:600;">${ctxPct}%</span> used
1671
+ </div>` : ''}
1672
+ ${isReal ? '' : '<div style="margin-top:4px;font-size:0.8em;opacity:0.4;">~ estimated token count</div>'}
1673
+ `;
1674
+
1675
+ const rect = metricsContainer.getBoundingClientRect();
1676
+ popup.style.left = rect.left + 'px';
1677
+ popup.style.visibility = 'hidden';
1678
+ document.body.appendChild(popup);
1679
+ const pr = popup.getBoundingClientRect();
1680
+ const spaceAbove = rect.top;
1681
+ const spaceBelow = window.innerHeight - rect.bottom;
1682
+ if (spaceAbove >= pr.height + 8 || spaceAbove > spaceBelow) {
1683
+ popup.style.top = (rect.top - pr.height - 8) + 'px';
1684
+ } else {
1685
+ popup.style.top = (rect.bottom + 8) + 'px';
1686
+ }
1687
+ if (pr.right > window.innerWidth - 8) popup.style.left = (window.innerWidth - pr.width - 8) + 'px';
1688
+ if (parseFloat(popup.style.left) < 8) popup.style.left = '8px';
1689
+ popup.style.visibility = '';
1690
+
1691
+ bindMenuDismiss(popup, () => popup.remove());
1692
+ });
1693
+
1694
+ // Store real context length for model info popup
1695
+ if (metrics.context_length && metrics.model) {
1696
+ if (!window._realContextLengths) window._realContextLengths = {};
1697
+ window._realContextLengths[metrics.model] = metrics.context_length;
1698
+ }
1699
+
1700
+ // Context usage ring
1701
+ let ctxRing = null;
1702
+ const ctxLen = metrics.context_length || 0;
1703
+ if (ctxPct !== undefined && ctxPct > 0) {
1704
+ const r = 6, stroke = 1.5;
1705
+ const circ = 2 * Math.PI * r;
1706
+ const fill = circ * (ctxPct / 100);
1707
+ const ctxColor = ctxPct >= 85 ? 'var(--red, #e06c75)' : ctxPct >= 70 ? '#ff9900' : 'var(--green, #98c379)';
1708
+ ctxRing = document.createElement('span');
1709
+ ctxRing.className = 'ctx-ring';
1710
+ ctxRing.title = `${ctxPct}% context used — click for details`;
1711
+ ctxRing.style.cursor = 'pointer';
1712
+ ctxRing.style.setProperty('--ctx-color', ctxColor);
1713
+ ctxRing.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14">
1714
+ <circle cx="7" cy="7" r="${r}" fill="none" stroke="var(--border, #333)" stroke-width="${stroke}" opacity="0.3"/>
1715
+ <circle cx="7" cy="7" r="${r}" fill="none" stroke="var(--ctx-stroke)" stroke-width="${stroke}"
1716
+ stroke-dasharray="${fill} ${circ - fill}" stroke-dashoffset="${circ * 0.25}"
1717
+ stroke-linecap="round" transform="rotate(-90 7 7)"/>
1718
+ </svg><span class="ctx-ring-pct">${Math.round(ctxPct)}%</span>`;
1719
+
1720
+ ctxRing.addEventListener('click', (e) => {
1721
+ e.stopPropagation();
1722
+ document.querySelectorAll('.ctx-detail-popup').forEach(p => { if (typeof p._dismiss === 'function') p._dismiss(); else p.remove(); });
1723
+
1724
+ const usedTokens = inputTokens || 0;
1725
+ const totalCtx = ctxLen || 0;
1726
+ const modelShort = model.split('/').pop();
1727
+ const fmtNum = n => n ? n.toLocaleString() : '?';
1728
+
1729
+ const popup = document.createElement('div');
1730
+ popup.className = 'ctx-detail-popup';
1731
+ popup.innerHTML = `
1732
+ <div style="font-weight:600;margin-bottom:8px;color:var(--fg);">Context Window</div>
1733
+ <div class="ctx-bar-wrap">
1734
+ <div class="ctx-bar-fill" style="width:${Math.min(ctxPct, 100)}%;background:${ctxColor};"></div>
1735
+ </div>
1736
+ <div style="display:flex;justify-content:space-between;font-size:0.75rem;margin-top:4px;opacity:0.6;">
1737
+ <span>${fmtNum(usedTokens)} used</span>
1738
+ <span>${fmtNum(totalCtx)} total</span>
1739
+ </div>
1740
+ <div style="margin-top:8px;font-size:0.8rem;">
1741
+ <div><span class="ctx-label">Model</span> ${modelShort}</div>
1742
+ <div><span class="ctx-label">Usage</span> <span style="color:${ctxColor};font-weight:600;">${ctxPct}%</span></div>
1743
+ <div><span class="ctx-label">Window</span> ${fmtNum(totalCtx)} tokens</div>
1744
+ </div>
1745
+ ${ctxPct >= 70 ? `<button class="ctx-compact-btn" title="Summarize older messages to free up context">Compact context</button>` : ''}
1746
+ `;
1747
+
1748
+ const compactBtn = popup.querySelector('.ctx-compact-btn');
1749
+ if (compactBtn) {
1750
+ compactBtn.addEventListener('click', async (e) => {
1751
+ e.stopPropagation();
1752
+ const sid = window.sessionModule && window.sessionModule.getCurrentSessionId();
1753
+ if (!sid) return;
1754
+ popup.remove();
1755
+
1756
+ // Add a spinner bubble at the bottom of chat
1757
+ const chatBox = document.getElementById('chat-history');
1758
+ if (!chatBox) return;
1759
+ const compactMsg = document.createElement('div');
1760
+ compactMsg.className = 'msg msg-ai';
1761
+ const compactRole = document.createElement('div');
1762
+ compactRole.className = 'role';
1763
+ compactRole.textContent = 'Zero';
1764
+ const compactBody = document.createElement('div');
1765
+ compactBody.className = 'body';
1766
+ compactBody.innerHTML = 'Compacting context <span class="compact-wave">▁▂▃▅▂▁</span>';
1767
+ compactMsg.appendChild(compactRole);
1768
+ compactMsg.appendChild(compactBody);
1769
+ chatBox.appendChild(compactMsg);
1770
+ chatBox.scrollTop = chatBox.scrollHeight;
1771
+
1772
+ // Animate the wave
1773
+ const waveFrames = ['▁▂▃▅▂▁', '▂▃▅▃▂▁', '▃▅▃▂▁▂', '▅▃▂▁▂▃', '▃▂▁▂▃▅', '▂▁▂▃▅▃'];
1774
+ let frame = 0;
1775
+ const waveEl = compactBody.querySelector('.compact-wave');
1776
+ const waveInterval = setInterval(() => {
1777
+ frame = (frame + 1) % waveFrames.length;
1778
+ if (waveEl) waveEl.textContent = waveFrames[frame];
1779
+ }, 150);
1780
+
1781
+ try {
1782
+ const res = await fetch(window.location.origin + '/api/session/' + sid + '/compact', {
1783
+ method: 'POST',
1784
+ headers: { 'Content-Type': 'application/json' },
1785
+ });
1786
+ clearInterval(waveInterval);
1787
+ if (res.ok) {
1788
+ const data = await res.json();
1789
+ // Reload session — the compacted history will show
1790
+ if (window.sessionModule) await window.sessionModule.selectSession(sid);
1791
+ // Scroll to the compacted message (first msg with compacted metadata)
1792
+ setTimeout(() => {
1793
+ const msgs = document.querySelectorAll('#chat-history .msg');
1794
+ for (const m of msgs) {
1795
+ if (m.querySelector('.body')?.textContent.includes('Conversation compacted')) {
1796
+ m.scrollIntoView({ behavior: 'smooth', block: 'center' });
1797
+ break;
1798
+ }
1799
+ }
1800
+ }, 200);
1801
+ } else {
1802
+ compactBody.innerHTML = '<span style="color:var(--red);">Compaction failed. Try again later.</span>';
1803
+ }
1804
+ } catch (err) {
1805
+ clearInterval(waveInterval);
1806
+ console.warn('compact failed:', err);
1807
+ compactBody.innerHTML = '<span style="color:var(--red);">Compaction failed: ' + err.message + '</span>';
1808
+ }
1809
+ });
1810
+ }
1811
+
1812
+ const rect = ctxRing.getBoundingClientRect();
1813
+ popup.style.visibility = 'hidden';
1814
+ document.body.appendChild(popup);
1815
+ const pr = popup.getBoundingClientRect();
1816
+ // Position above the ring, right-aligned
1817
+ popup.style.left = Math.max(8, rect.right - pr.width) + 'px';
1818
+ const spaceAbove = rect.top;
1819
+ if (spaceAbove >= pr.height + 8) {
1820
+ popup.style.top = (rect.top - pr.height - 8) + 'px';
1821
+ } else {
1822
+ popup.style.top = (rect.bottom + 8) + 'px';
1823
+ }
1824
+ popup.style.visibility = '';
1825
+
1826
+ bindMenuDismiss(popup, () => popup.remove(), (ev) => !popup.contains(ev.target) && ev.target !== ctxRing && !ctxRing.contains(ev.target));
1827
+ });
1828
+ }
1829
+
1830
+ let footer = messageElement.querySelector('.msg-footer');
1831
+ if (footer) {
1832
+ const actions = footer.querySelector('.msg-actions');
1833
+ if (actions) {
1834
+ footer.insertBefore(metricsDivider, actions);
1835
+ footer.insertBefore(metricsContainer, metricsDivider);
1836
+ } else {
1837
+ footer.appendChild(metricsContainer);
1838
+ footer.appendChild(metricsDivider);
1839
+ }
1840
+ if (ctxRing) {
1841
+ const ctxDiv = document.createElement('span');
1842
+ ctxDiv.textContent = ' | ';
1843
+ ctxDiv.style.color = 'var(--color-muted-alt)';
1844
+ ctxDiv.style.pointerEvents = 'none';
1845
+ ctxDiv.className = 'ctx-divider';
1846
+ footer.appendChild(ctxDiv);
1847
+ footer.appendChild(ctxRing);
1848
+ }
1849
+ } else {
1850
+ messageElement.appendChild(metricsContainer);
1851
+ if (ctxRing) messageElement.appendChild(ctxRing);
1852
+ }
1853
+
1854
+ if (uiModule) uiModule.scrollHistory();
1855
+ }
1856
+
1857
+ /**
1858
+ * Add a message to the chat history.
1859
+ */
1860
+ export function addMessage(role, content, modelName, metadata) {
1861
+ try {
1862
+ hideWelcomeScreen();
1863
+ const box = document.getElementById('chat-history');
1864
+ if (!box) { console.error('Chat history element not found'); return; }
1865
+
1866
+ var esc = uiModule.esc;
1867
+ const textRaw = Array.isArray(content) ? markdownModule.renderContent(content) : content;
1868
+
1869
+ // --- Agent multi-bubble reconstruction from saved metadata ---
1870
+ if (role === 'assistant' && metadata && metadata.tool_events && metadata.tool_events.length > 0) {
1871
+ const roundTexts = metadata.round_texts || [];
1872
+ const toolEvents = metadata.tool_events;
1873
+ let lastWrap = null;
1874
+ let firstMsgAi = null;
1875
+ let lastMsgAi = null;
1876
+
1877
+ const toolsByRound = {};
1878
+ for (const ev of toolEvents) {
1879
+ const r = ev.round || 1;
1880
+ if (!toolsByRound[r]) toolsByRound[r] = [];
1881
+ toolsByRound[r].push(ev);
1882
+ }
1883
+
1884
+ const maxRound = Math.max(...Object.keys(toolsByRound).map(Number), roundTexts.length);
1885
+
1886
+ for (let r = 0; r < maxRound; r++) {
1887
+ const roundNum = r + 1;
1888
+ const txt = (roundTexts[r] || '').trim();
1889
+
1890
+ if (txt) {
1891
+ const wrap = document.createElement('div');
1892
+ wrap.className = 'msg msg-ai' + (r > 0 ? ' msg-continuation' : '');
1893
+ const roleEl = document.createElement('div');
1894
+ roleEl.className = 'role';
1895
+ const contModel = modelName || metadata?.model;
1896
+ roleEl.textContent = shortModel(contModel);
1897
+ applyModelColor(roleEl, contModel);
1898
+ if (r === 0) roleEl.appendChild(roleTimestamp(metadata?.timestamp));
1899
+ wrap.appendChild(roleEl);
1900
+ const body = document.createElement('div');
1901
+ body.className = 'body';
1902
+ // Check if this is the last text round — sources go on top of final response
1903
+ var agentSourcesPrefix = '';
1904
+ var isLastTextRound = true;
1905
+ for (let rr = r + 1; rr < maxRound; rr++) {
1906
+ if ((roundTexts[rr] || '').trim()) { isLastTextRound = false; break; }
1907
+ }
1908
+ var agentFindingsSuffix = '';
1909
+ if (isLastTextRound && metadata?.web_sources?.length) {
1910
+ agentSourcesPrefix = buildSourcesBox(metadata.web_sources, 'web');
1911
+ } else if (isLastTextRound && metadata?.research_sources?.length) {
1912
+ agentSourcesPrefix = buildSourcesBox(metadata.research_sources, 'research');
1913
+ }
1914
+ if (isLastTextRound && metadata?.research_findings?.length) {
1915
+ agentFindingsSuffix = buildFindingsBox(metadata.research_findings);
1916
+ }
1917
+ // RAG document sources — restored on the final text round.
1918
+ if (isLastTextRound && metadata?.rag_sources?.length) {
1919
+ agentFindingsSuffix += buildRagSourcesBox(metadata.rag_sources);
1920
+ }
1921
+ body.innerHTML = agentSourcesPrefix + markdownModule.processWithThinking(markdownModule.squashOutsideCode(txt)) + agentFindingsSuffix;
1922
+ wrap.appendChild(body);
1923
+ wrap.dataset.raw = txt;
1924
+ if (metadata?._db_id) wrap.dataset.dbId = metadata._db_id;
1925
+ box.appendChild(wrap);
1926
+ lastWrap = wrap;
1927
+ if (!firstMsgAi) firstMsgAi = wrap;
1928
+ lastMsgAi = wrap;
1929
+ }
1930
+
1931
+ const roundTools = toolsByRound[roundNum] || [];
1932
+ if (roundTools.length > 0) {
1933
+ // Reuse previous thread if no text separated us (merge consecutive tool rounds)
1934
+ let threadWrap = null;
1935
+ if (!txt && lastWrap && lastWrap.classList.contains('agent-thread')) {
1936
+ threadWrap = lastWrap;
1937
+ } else {
1938
+ threadWrap = document.createElement('div');
1939
+ threadWrap.className = 'agent-thread';
1940
+ // Extend line up if there's a chat bubble above
1941
+ if (txt) threadWrap.classList.add('has-top');
1942
+ box.appendChild(threadWrap);
1943
+ }
1944
+ for (const ev of roundTools) {
1945
+ const ok = (ev.exit_code === 0 || ev.exit_code == null);
1946
+ let outHtml = '';
1947
+ if (ev.output && ev.output.trim()) {
1948
+ outHtml = `<details class="agent-tool-output"><summary>Output</summary><pre>${esc(ev.output)}</pre></details>`;
1949
+ }
1950
+ if (ev.screenshot) {
1951
+ outHtml += `<details class="agent-tool-output"><summary>Screenshot</summary><img src="${esc(ev.screenshot)}" style="max-width:100%;border-radius:6px;margin-top:6px;border:1px solid var(--border)" /></details>`;
1952
+ }
1953
+ const node = document.createElement('div');
1954
+ node.className = 'agent-thread-node' + (ok ? '' : ' error');
1955
+ const evCmdHtml = ev.command ? `<pre class="agent-thread-cmd">${esc(ev.command)}</pre>` : '';
1956
+ node.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(ev.tool)}</span><span class="agent-thread-status">${ok ? 'done' : 'failed'}</span><span class="agent-thread-chevron">\u25B6</span></div><div class="agent-thread-content">${evCmdHtml}${outHtml}</div>`;
1957
+ // Click handling is delegated globally \u2014 see chat.js init.
1958
+ threadWrap.appendChild(node);
1959
+ }
1960
+ // Check if next round has text — extend line down to connect
1961
+ const nextTxt = (roundTexts[r + 1] || '').trim();
1962
+ if (nextTxt) threadWrap.classList.add('has-bottom');
1963
+ lastWrap = threadWrap;
1964
+
1965
+ for (const ev of roundTools) {
1966
+ if (ev.image_url) {
1967
+ box.appendChild(buildImageBubble(ev.image_url, ev.image_prompt, ev.image_model, ev.image_size, ev.image_quality, ev.image_id));
1968
+ }
1969
+ }
1970
+ }
1971
+ }
1972
+
1973
+ const firstWrap = lastMsgAi || lastWrap;
1974
+ if (firstWrap && firstWrap.classList.contains('msg-ai')) {
1975
+ if (metadata?.memories_used?.length) firstWrap._memoriesUsed = metadata.memories_used;
1976
+ firstWrap.appendChild(createMsgFooter(firstWrap));
1977
+ if (metadata) displayMetrics(firstWrap, metadata);
1978
+ }
1979
+
1980
+ if (window.hljs) {
1981
+ box.querySelectorAll('pre code:not(.hljs)').forEach(b => window.hljs.highlightElement(b));
1982
+ }
1983
+ if (markdownModule.renderMermaid) markdownModule.renderMermaid(box);
1984
+ return lastWrap;
1985
+ }
1986
+
1987
+ // --- Standard single-bubble message ---
1988
+ const wrap = document.createElement('div');
1989
+ wrap.className = 'msg ' + (role === 'user' ? 'msg-user' : 'msg-ai');
1990
+
1991
+ const r = document.createElement('div');
1992
+ r.className = 'role';
1993
+ const isSlash = metadata?.source === 'slash';
1994
+ const isCompacted = metadata?.compacted;
1995
+ const resolvedModel = modelName || metadata?.model;
1996
+ var _roleText = role === 'user' ? 'You' : (isSlash || isCompacted) ? 'Zero' : shortModel(resolvedModel);
1997
+ if (role === 'assistant' && (metadata?.research || metadata?.research_clarification)) {
1998
+ _roleText += ' (Research)';
1999
+ }
2000
+ if (metadata?.group_model && role !== 'user') {
2001
+ _roleText = metadata.group_model;
2002
+ } else if (metadata?.character_name && role !== 'user' && !isSlash && !isCompacted) {
2003
+ _roleText = metadata.character_name;
2004
+ }
2005
+ r.textContent = _roleText;
2006
+ if (role !== 'user') {
2007
+ if (!isSlash && !isCompacted) applyModelColor(r, resolvedModel);
2008
+ r.appendChild(roleTimestamp(metadata?.timestamp));
2009
+ }
2010
+
2011
+ const b = document.createElement('div');
2012
+ b.className = 'body';
2013
+
2014
+ let text = markdownModule.squashOutsideCode(stripToolBlocks(textRaw || ''));
2015
+
2016
+ // For user messages, pull out vision-model image descriptions ([Image: name]\n
2017
+ // <multi-line desc>) into a collapsible "image description" section. Done for
2018
+ // ALL user messages (not just ones with attachment metadata) so it rebuilds
2019
+ // from the stored text even after a browser restart drops the cached attachments.
2020
+ const attachments = metadata?.attachments;
2021
+ const _visionBlocks = [];
2022
+ if (role === 'user') {
2023
+ text = text.replace(
2024
+ /\n*\[Image: ([^\]]+)\]\n([\s\S]*?)(?=\n*\[Image: |\n*\[Image attached: |\n*=== File: |\n*\[PDF content\]:|$)/g,
2025
+ (_m, name, desc) => { const d = desc.trim(); if (d) _visionBlocks.push({ name: name, desc: d }); return ''; }
2026
+ );
2027
+ }
2028
+ // With attachments present, also strip the embedded file/PDF/image-marker text.
2029
+ if (role === 'user' && attachments?.length) {
2030
+ // Strip === File: ... === blocks, [PDF content]: blocks, and [Image attached: ...] lines
2031
+ text = text
2032
+ .replace(/\n*=== File: .+? ===\n\[Type: .+?\]\n+```[\s\S]*?```/g, '')
2033
+ .replace(/\n*=== File: .+? ===\n\[Type: .+?\]\n+[\s\S]*?(?=\n*=== File:|$)/g, '')
2034
+ .replace(/\n*\[PDF content\]:[\s\S]*?(?=\n*\[PDF content\]|\n*=== File:|$)/g, '')
2035
+ .replace(/\n*\[Image attached: [^\]]+\]/g, '')
2036
+ .replace(/\n*\[Attached (?:document|non-text) file\]/g, '')
2037
+ .trim();
2038
+ }
2039
+
2040
+ wrap.dataset.raw = text;
2041
+ if (metadata?._db_id) wrap.dataset.dbId = metadata._db_id;
2042
+ // Prepend sources box if saved in metadata
2043
+ var sourcesPrefix = '';
2044
+ var findingsSuffix = '';
2045
+ if (role === 'assistant' && metadata?.research_sources?.length) {
2046
+ sourcesPrefix = buildSourcesBox(metadata.research_sources, 'research');
2047
+ } else if (role === 'assistant' && metadata?.web_sources?.length) {
2048
+ sourcesPrefix = buildSourcesBox(metadata.web_sources, 'web');
2049
+ }
2050
+ if (role === 'assistant' && metadata?.research_findings?.length) {
2051
+ findingsSuffix = buildFindingsBox(metadata.research_findings);
2052
+ }
2053
+ // RAG document sources — restored from metadata so they survive refresh.
2054
+ if (role === 'assistant' && metadata?.rag_sources?.length) {
2055
+ findingsSuffix += buildRagSourcesBox(metadata.rag_sources);
2056
+ }
2057
+ // If thinking is stored in metadata (not in text), reconstruct the full display
2058
+ if (role === 'assistant' && metadata?.thinking) {
2059
+ const thinkTime = metadata.thinking_time || null;
2060
+ const thinkHtml = markdownModule.processWithThinking(
2061
+ '<think' + (thinkTime ? ` time="${thinkTime}"` : '') + '>' + metadata.thinking + '</think>\n\n' + text
2062
+ );
2063
+ b.innerHTML = sourcesPrefix + thinkHtml + findingsSuffix;
2064
+ } else {
2065
+ b.innerHTML = sourcesPrefix + markdownModule.processWithThinking(text) + findingsSuffix;
2066
+ }
2067
+
2068
+ // The vision/OCR caption is stripped from the displayed text above (so the
2069
+ // bubble doesn't show the raw model output) but no longer rendered as an
2070
+ // inline collapsible — the user can still view/edit it via the "Caption"
2071
+ // button on the photo thumbnail. _visionBlocks is intentionally left unused
2072
+ // so the parsing-and-strip side-effect on `text` still happens.
2073
+ void _visionBlocks;
2074
+
2075
+ // Add "Open Visual Report" button for persisted research messages
2076
+ if (role === 'assistant' && metadata?.research) {
2077
+ var _sid = window.sessionModule?.getCurrentSessionId?.();
2078
+ if (_sid) _appendReportButton(b, _sid);
2079
+ }
2080
+
2081
+ // Style [Doc edit: ...] prefix in user messages
2082
+ if (role === 'user') {
2083
+ // Match compact format: [Doc edit: line X] instruction
2084
+ b.innerHTML = b.innerHTML.replace(
2085
+ /\[Doc edit: (lines? [\d–\-]+)\]\s*/,
2086
+ '<span class="doc-edit-tag">Doc edit: $1</span> '
2087
+ );
2088
+ // Match raw format: "In the document, edit this specific text (line X):\n```\n...\n```\n\nInstruction: ..."
2089
+ // After markdown processing this becomes a <p> + <pre><code> block + <p>Instruction: text</p>
2090
+ const rawDocMatch = b.innerHTML.match(/In the document, edit this specific text \((lines? [\d–\-]+)\)/);
2091
+ if (rawDocMatch) {
2092
+ const lineRef = rawDocMatch[1];
2093
+ // Extract instruction text (after "Instruction: ")
2094
+ const instrMatch = b.textContent.match(/Instruction:\s*([\s\S]*)$/);
2095
+ const instrText = instrMatch ? instrMatch[1].trim() : '';
2096
+ b.innerHTML = '<span class="doc-edit-tag">Doc edit: ' + lineRef + '</span> ' + markdownModule.processWithThinking(instrText);
2097
+ }
2098
+
2099
+ // Render attachment cards
2100
+ if (attachments?.length) {
2101
+ b.appendChild(buildAttachCards(attachments));
2102
+ }
2103
+ }
2104
+
2105
+ wrap.appendChild(r);
2106
+ wrap.appendChild(b);
2107
+
2108
+ // Add stopped indicator + continue button for messages that were stopped by user
2109
+ if (role === 'assistant' && metadata?.stopped) {
2110
+ const stoppedIndicator = document.createElement('div');
2111
+ stoppedIndicator.className = 'stopped-indicator';
2112
+ const stoppedLabel = document.createElement('span');
2113
+ // Differentiate between "stopped mid-stream" (had content, can continue)
2114
+ // and "cancelled before any content" — the latter has no Continue affordance.
2115
+ stoppedLabel.textContent = metadata.cancelled
2116
+ ? '[Cancelled by user]'
2117
+ : '[Message interrupted]';
2118
+ stoppedIndicator.appendChild(stoppedLabel);
2119
+ // Continue button only makes sense when there's partial content to
2120
+ // resume from \u2014 skip it for fully-cancelled (empty) turns.
2121
+ if (!metadata.cancelled) {
2122
+ const continueBtn = document.createElement('button');
2123
+ continueBtn.className = 'continue-btn';
2124
+ continueBtn.title = 'Continue';
2125
+ continueBtn.textContent = '\u25B8';
2126
+ continueBtn.addEventListener('click', () => {
2127
+ stoppedIndicator.remove();
2128
+ if (window.chatModule) {
2129
+ window.chatModule.setHideUserBubble();
2130
+ window.chatModule.setPendingContinue(wrap);
2131
+ const rawText = wrap.dataset.raw || wrap.querySelector('.body')?.textContent || '';
2132
+ const cutoff = rawText;
2133
+ const msgInput = document.getElementById('message');
2134
+ if (msgInput) {
2135
+ 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.';
2136
+ const sb = document.querySelector('.send-btn');
2137
+ if (sb) sb.click();
2138
+ }
2139
+ }
2140
+ });
2141
+ stoppedIndicator.appendChild(continueBtn);
2142
+ }
2143
+ b.appendChild(stoppedIndicator);
2144
+ }
2145
+
2146
+ if (metadata?.edited) {
2147
+ const editedIndicator = document.createElement('div');
2148
+ editedIndicator.className = 'edited-indicator';
2149
+ editedIndicator.textContent = '[Message edited]';
2150
+ b.appendChild(editedIndicator);
2151
+ }
2152
+
2153
+ // Restore variant navigation from saved metadata
2154
+ if (role === 'assistant' && metadata?.variants && metadata.variants.length > 1) {
2155
+ wrap.dataset.variants = JSON.stringify(metadata.variants);
2156
+ const idx = metadata.variantIndex ?? metadata.variants.length - 1;
2157
+ wrap.dataset.variantIndex = String(idx);
2158
+
2159
+ // Re-render from `raw` markdown rather than trusting cached `v.html`.
2160
+ // Variants ride through localStorage / chat export-import; cached HTML
2161
+ // would let an attacker-controlled session JSON inject markup.
2162
+ const _renderVariant = (v) => (v && v.raw)
2163
+ ? markdownModule.processWithThinking(markdownModule.squashOutsideCode(v.raw))
2164
+ : (v && v.html) || '';
2165
+
2166
+ // Show the selected variant's content
2167
+ const v = metadata.variants[idx];
2168
+ if (v) {
2169
+ b.innerHTML = _renderVariant(v);
2170
+ wrap.dataset.raw = v.raw;
2171
+ }
2172
+
2173
+ // Render nav
2174
+ const nav = document.createElement('span');
2175
+ nav.className = 'variant-nav';
2176
+ nav.addEventListener('click', (e) => e.stopPropagation());
2177
+
2178
+ const divider = document.createElement('span');
2179
+ divider.className = 'variant-divider';
2180
+ divider.textContent = '|';
2181
+ nav.appendChild(divider);
2182
+
2183
+ const tagLabel = document.createElement('span');
2184
+ const _icons = { regen: '\u21BB', shorter: '\u2702', simpler: '?', original: '\u25CB' };
2185
+ const _tl0 = metadata.variants[idx]?.label;
2186
+ tagLabel.className = 'variant-tag' + (_tl0 === 'shorter' ? ' variant-tag-scissors' : '');
2187
+ tagLabel.textContent = _icons[_tl0] || '';
2188
+ nav.appendChild(tagLabel);
2189
+
2190
+ const prevBtn = document.createElement('button');
2191
+ prevBtn.className = 'variant-btn';
2192
+ prevBtn.textContent = '<';
2193
+ prevBtn.disabled = idx === 0;
2194
+ nav.appendChild(prevBtn);
2195
+
2196
+ const numLeft = document.createElement('button');
2197
+ numLeft.className = 'variant-num';
2198
+ numLeft.textContent = String(idx + 1);
2199
+ numLeft.disabled = idx === 0;
2200
+ nav.appendChild(numLeft);
2201
+
2202
+ const slash = document.createElement('span');
2203
+ slash.className = 'variant-slash';
2204
+ slash.textContent = '/';
2205
+ nav.appendChild(slash);
2206
+
2207
+ const numRight = document.createElement('button');
2208
+ numRight.className = 'variant-num';
2209
+ numRight.textContent = String(metadata.variants.length);
2210
+ numRight.disabled = idx === metadata.variants.length - 1;
2211
+ nav.appendChild(numRight);
2212
+
2213
+ const nextBtn = document.createElement('button');
2214
+ nextBtn.className = 'variant-btn';
2215
+ nextBtn.textContent = '>';
2216
+ nextBtn.disabled = idx === metadata.variants.length - 1;
2217
+ nav.appendChild(nextBtn);
2218
+
2219
+ const switchFn = (newIdx) => {
2220
+ const vars = metadata.variants;
2221
+ if (newIdx < 0 || newIdx >= vars.length) return;
2222
+ const sv = vars[newIdx];
2223
+ b.innerHTML = _renderVariant(sv);
2224
+ wrap.dataset.raw = sv.raw;
2225
+ wrap.dataset.variantIndex = String(newIdx);
2226
+ if (window.hljs) wrap.querySelectorAll('pre code').forEach(bl => window.hljs.highlightElement(bl));
2227
+ tagLabel.textContent = _icons[sv.label] || '';
2228
+ tagLabel.className = 'variant-tag' + (sv.label === 'shorter' ? ' variant-tag-scissors' : '');
2229
+ numLeft.textContent = String(newIdx + 1);
2230
+ numLeft.disabled = newIdx === 0;
2231
+ numRight.disabled = newIdx === vars.length - 1;
2232
+ prevBtn.disabled = newIdx === 0;
2233
+ nextBtn.disabled = newIdx === vars.length - 1;
2234
+ };
2235
+ prevBtn.addEventListener('click', (e) => { e.stopPropagation(); switchFn(parseInt(wrap.dataset.variantIndex) - 1); });
2236
+ numLeft.addEventListener('click', (e) => { e.stopPropagation(); switchFn(parseInt(wrap.dataset.variantIndex) - 1); });
2237
+ numRight.addEventListener('click', (e) => { e.stopPropagation(); switchFn(parseInt(wrap.dataset.variantIndex) + 1); });
2238
+ nextBtn.addEventListener('click', (e) => { e.stopPropagation(); switchFn(parseInt(wrap.dataset.variantIndex) + 1); });
2239
+
2240
+ r.appendChild(nav);
2241
+ }
2242
+
2243
+ if (role === 'assistant') {
2244
+ // The "N pinned" / "N recalled" pill in the footer reads from
2245
+ // wrap._memoriesUsed — propagate it from saved metadata so the pill
2246
+ // survives a page refresh (live-stream path sets it via SSE, but
2247
+ // history reloads need this assignment).
2248
+ if (metadata?.memories_used?.length) wrap._memoriesUsed = metadata.memories_used;
2249
+ wrap.appendChild(createMsgFooter(wrap));
2250
+ if (metadata) displayMetrics(wrap, metadata);
2251
+ } else {
2252
+ // Add timestamp to user header (like AI messages)
2253
+ r.appendChild(roleTimestamp(metadata?.timestamp));
2254
+
2255
+ wrap.appendChild(createUserMsgFooter(wrap));
2256
+ }
2257
+
2258
+ box.appendChild(wrap);
2259
+
2260
+ // TTS is now part of the msg-actions system
2261
+ if (role === 'assistant' && markdownModule.renderMermaid) {
2262
+ markdownModule.renderMermaid(wrap);
2263
+ }
2264
+ return wrap;
2265
+ } catch (error) {
2266
+ console.error('Error in addMessage:', error);
2267
+ if (uiModule) uiModule.showError('Failed to add message: ' + error.message);
2268
+ }
2269
+ }
2270
+
2271
+ const chatRenderer = {
2272
+ shortModel,
2273
+ modelColor,
2274
+ applyModelColor,
2275
+ getModelCost,
2276
+ getImageCost,
2277
+ getSessionCost,
2278
+ resetSessionCost,
2279
+ updateSessionCostUI,
2280
+ roleTimestamp,
2281
+ stripToolBlocks,
2282
+ buildSourcesBox,
2283
+ buildFindingsBox,
2284
+ appendReportButton,
2285
+ buildImageBubble,
2286
+ hideWelcomeScreen,
2287
+ showWelcomeScreen,
2288
+ createMsgFooter,
2289
+ displayMetrics,
2290
+ addMessage,
2291
+ updateMessageAttachments,
2292
+ };
2293
+
2294
+ export default chatRenderer;