@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,1888 @@
1
+ // ============================================
2
+ // COOKBOOK HWFIT SUB-MODULE
3
+ // "What Fits?" hardware model fitting UI
4
+ // ============================================
5
+
6
+ import {
7
+ _envState,
8
+ _persistEnvState,
9
+ esc,
10
+ modelLogo,
11
+ _detectBackend,
12
+ _runModelDownload,
13
+ _runPanelCmd,
14
+ _buildDownloadCmd,
15
+ _addTask,
16
+ _renderRunningTab,
17
+ _detectToolParser,
18
+ _lastCacheHost,
19
+ _setLastCacheHost,
20
+ _serverByVal,
21
+ _shellQuote,
22
+ _MODELDIR_CHECK_ON,
23
+ _MODELDIR_CHECK_OFF,
24
+ _serverEntryHtml,
25
+ _copyText,
26
+ // Import cookbook.js WITHOUT a ?v= query — the same plain specifier every other
27
+ // importer uses. A query mismatch loads cookbook.js twice as two separate modules
28
+ // (two _envState objects), which silently sent downloads to the wrong server.
29
+ } from './cookbook.js';
30
+ import uiModule from './ui.js';
31
+ import spinnerModule from './spinner.js';
32
+
33
+ // ── What Fits? (hardware model fitting) ──
34
+
35
+ export let _hwfitCache = null;
36
+ export let _hwfitDebounce = null;
37
+ export let _cachedModelIds = null; // repo IDs already downloaded
38
+ // Bumped on every _hwfitFetch; a slow scan (remote SSH probe can take ~10s)
39
+ // checks this before rendering so a stale response can't clobber a newer one
40
+ // after the user has switched servers.
41
+ let _hwfitFetchToken = 0;
42
+ let _dismissedHwChips = new Set();
43
+ // Permanently removed (X-clicked) chips. Separate from _dismissedHwChips
44
+ // so the ranker treats "off" and "removed" the same (both ignore the
45
+ // hardware) but the UI keeps "off" chips visible to toggle back on,
46
+ // while "removed" ones don't render at all until next rescan.
47
+ let _removedHwChips = new Set();
48
+
49
+ export let _gpuToggleTotal = 0; // real GPU count from first scan, never overridden
50
+
51
+ function _firstGgufSource(model) {
52
+ const sources = Array.isArray(model?.gguf_sources) ? model.gguf_sources : [];
53
+ return sources.find(src => src && src.repo) || null;
54
+ }
55
+
56
+ function _looksLikeGgufRepo(model) {
57
+ const haystack = `${model?.quant_repo || ''} ${model?.repo_id || ''} ${model?.path || ''} ${model?.name || ''}`.toLowerCase();
58
+ return !!model?.is_gguf || haystack.includes('gguf') || haystack.includes('.gguf');
59
+ }
60
+
61
+ function _downloadSourceRepo(model, backend) {
62
+ if (backend === 'llamacpp') {
63
+ const ggufSource = _firstGgufSource(model);
64
+ if (ggufSource) return { repo: ggufSource.repo, kind: 'GGUF' };
65
+ if (_looksLikeGgufRepo(model)) {
66
+ const repo = model?.quant_repo || model?.repo_id || model?.name;
67
+ if (repo) return { repo, kind: 'GGUF' };
68
+ }
69
+ }
70
+ return { repo: model?.quant_repo || model?.name || '', kind: '' };
71
+ }
72
+
73
+ // Reset GPU-toggle state so the next scan re-renders the RAM/GPU buttons for a
74
+ // (possibly different) server, WITHOUT clearing the markup now — clearing it made
75
+ // the buttons flicker out and back in. The old buttons stay visible until the
76
+ // fresh scan returns and swaps them in place. Lives here (not cookbook.js) because
77
+ // _gpuToggleTotal is a module-local binding that can't be reassigned by importers.
78
+ export function _resetGpuToggleState(clearDismissed = true) {
79
+ if (clearDismissed) {
80
+ _dismissedHwChips = new Set();
81
+ _removedHwChips = new Set();
82
+ }
83
+ const tc = document.getElementById('hwfit-gpu-toggles');
84
+ if (tc) {
85
+ tc._originalSystem = null;
86
+ tc._activeCount = undefined;
87
+ tc._activeGroup = undefined;
88
+ tc._groups = null;
89
+ tc._builtGroup = undefined;
90
+ delete tc.dataset.rendered;
91
+ }
92
+ _gpuToggleTotal = 0;
93
+ }
94
+
95
+ // Trim vendor noise so a pool label reads "RTX 4090 D" not "NVIDIA GeForce RTX 4090 D".
96
+ function _shortGpuName(name) {
97
+ return String(name || 'GPU')
98
+ .replace(/^NVIDIA\s+GeForce\s+/i, '')
99
+ .replace(/^NVIDIA\s+/i, '')
100
+ .replace(/^AMD\s+(Radeon\s+)?/i, '')
101
+ .trim() || 'GPU';
102
+ }
103
+
104
+ // Powers of two up to the pool size, plus the exact pool size — these are the
105
+ // only safe vLLM --tensor-parallel-size values (TP must divide the GPU count and
106
+ // the model's attention heads). Never offer a count we can't actually serve.
107
+ function _validTpCounts(poolSize) {
108
+ const out = [1, 2, 4, 8, 16].filter(n => n <= poolSize);
109
+ if (poolSize > 0 && !out.includes(poolSize)) out.push(poolSize);
110
+ return out;
111
+ }
112
+
113
+ export function _renderGpuToggles(system) {
114
+ const container = document.getElementById('hwfit-gpu-toggles');
115
+ if (!container) return;
116
+ const groups = Array.isArray(system.gpu_groups) ? system.gpu_groups : [];
117
+ // Box-wide GPU total, stable across fetches. The route shrinks system.gpu_count
118
+ // to the *active pool* once we pin one, so derive the total from the (immutable)
119
+ // group list or the raw detection, never from the possibly-overridden count.
120
+ const total = system.detected_gpu_count
121
+ || (groups.length ? groups.reduce((s, g) => s + (g.count || 0), 0) : (system.gpu_count || 0));
122
+ if (total <= 0 && !system.has_gpu) {
123
+ container.innerHTML = '';
124
+ container._groups = null;
125
+ _gpuToggleTotal = 0;
126
+ return;
127
+ }
128
+ if (!_gpuToggleTotal) _gpuToggleTotal = total;
129
+
130
+ container._groups = groups;
131
+ if (container._activeGroup === undefined) container._activeGroup = 0; // auto = largest pool
132
+ const heterogeneous = groups.length > 1;
133
+
134
+ // Rebuild only when the hardware shape changes OR the chosen pool changes (the
135
+ // count buttons are pool-specific). Otherwise a re-scan would flicker them.
136
+ const sig = `${total}|${groups.map(g => g.count + ':' + g.vram_each).join(',')}`;
137
+ if (container.dataset.rendered === sig && container._builtGroup === container._activeGroup) return;
138
+ container.dataset.rendered = sig;
139
+ container._builtGroup = container._activeGroup;
140
+
141
+ const grp = groups[container._activeGroup] || groups[0]
142
+ || { count: total, vram_each: 0, name: system.gpu_name || 'GPU' };
143
+ const poolSize = grp.count || total;
144
+
145
+ let html = '';
146
+ if (heterogeneous) {
147
+ html += `<select class="hwfit-gpu-group" id="hwfit-gpu-group" title="Which GPU pool to serve from — vLLM can only tensor-parallel across identical GPUs">`;
148
+ groups.forEach((g, i) => {
149
+ const lbl = `${g.count}× ${_shortGpuName(g.name)} (${Math.round(g.vram_total)} GB)`;
150
+ html += `<option value="${i}"${i === container._activeGroup ? ' selected' : ''}>${esc(lbl)}</option>`;
151
+ });
152
+ html += '</select>';
153
+ }
154
+ const validCounts = _validTpCounts(poolSize);
155
+ const maxGpu = validCounts.length ? validCounts[validCounts.length - 1] : 0;
156
+ // Commit the data layer to maxGpu on initial render so it matches the
157
+ // visual highlight. Before this, _activeCount stayed undefined → no
158
+ // gpu_count param sent → backend's fallback could rank against RAM on
159
+ // mixed-resource boxes ("tightest" sorted by RAM instead of GPU).
160
+ if (container._activeCount === undefined && validCounts.length) {
161
+ container._activeCount = maxGpu;
162
+ }
163
+ html += '<button class="hwfit-gpu-btn" data-count="0" title="CPU / RAM only">RAM</button>';
164
+ const hasExplicitCount = typeof container._activeCount === 'number';
165
+ for (const n of validCounts) {
166
+ const text = n === 1 ? 'GPU' : n + ' GPU';
167
+ const isActive = hasExplicitCount && n === container._activeCount;
168
+ html += `<button class="hwfit-gpu-btn${isActive ? ' active' : ''}" data-count="${n}" title="${n} GPU${n > 1 ? 's' : ''}">${text}</button>`;
169
+ }
170
+ // Also mark the RAM button active when the user explicitly chose RAM (0)
171
+ // — the loop above only handles GPU buttons.
172
+ if (container._activeCount === 0) {
173
+ const ramBtn = container.querySelector('.hwfit-gpu-btn[data-count="0"]');
174
+ // (we just set innerHTML so we re-mark below after assignment)
175
+ }
176
+ container.innerHTML = html;
177
+ if (container._activeCount === 0) {
178
+ const ramBtn = container.querySelector('.hwfit-gpu-btn[data-count="0"]');
179
+ if (ramBtn) ramBtn.classList.add('active');
180
+ }
181
+
182
+ // Pool dropdown: switch pools, reset the count to the new pool's max, rebuild.
183
+ const sel = container.querySelector('#hwfit-gpu-group');
184
+ if (sel) {
185
+ sel.addEventListener('change', () => {
186
+ container._activeGroup = parseInt(sel.value) || 0;
187
+ container._activeCount = undefined; // default to the new pool's max
188
+ delete container.dataset.rendered; // force a count-button rebuild
189
+ _renderGpuToggles(system);
190
+ _hwfitCache = null;
191
+ _hwfitFetch();
192
+ });
193
+ }
194
+
195
+ if (!container._gpuBound) {
196
+ container._gpuBound = true;
197
+ container.addEventListener('click', (e) => {
198
+ const btn = e.target.closest('.hwfit-gpu-btn');
199
+ if (!btn) return;
200
+ const count = parseInt(btn.dataset.count);
201
+ const wasActive = btn.classList.contains('active') && container._activeCount === count;
202
+ container.querySelectorAll('.hwfit-gpu-btn').forEach(b => b.classList.remove('active'));
203
+ if (wasActive) {
204
+ container._activeCount = null;
205
+ } else {
206
+ btn.classList.add('active');
207
+ container._activeCount = count;
208
+ // Auto-suggest a quant based on hardware selection — but ONLY when the
209
+ // user has already picked a specific quant. When they're on "All"
210
+ // (value === ""), leave them on All: toggling a GPU shouldn't silently
211
+ // yank them out of the All view they wanted to see.
212
+ const quantSel = document.getElementById('hwfit-quant');
213
+ if (quantSel && quantSel.value !== '') {
214
+ if (count <= 1) {
215
+ quantSel.value = 'Q4_K_M'; // RAM or 1 GPU -> Q4 sweet spot
216
+ } else if (String(system?.backend || '').toLowerCase() === 'rocm') {
217
+ quantSel.value = 'Q4_K_M'; // ROCm default stays GGUF/local-safe; AWQ is explicit only
218
+ } else {
219
+ quantSel.value = 'AWQ-4bit'; // Multi-GPU -> AWQ for vLLM
220
+ }
221
+ }
222
+ }
223
+ _hwfitCache = null;
224
+ _hwfitFetch();
225
+ });
226
+ }
227
+ }
228
+
229
+ // --- Scan persistence (survives page reloads) ----------------------------
230
+ // The backend caches hardware detection per host (~30 min) but that's lost on a
231
+ // service restart, and a reload still shows a spinner while it re-fetches. Cache
232
+ // the last successful /models result per param-signature in localStorage so a
233
+ // reload paints instantly, then we refresh in the background and swap.
234
+ const _SCAN_CACHE_KEY = 'hwfit_scan_cache_v1';
235
+ const _MANUAL_HW_KEY = 'hwfit_manual_hardware_v1';
236
+ const _CTX_KEY = 'hwfit_target_context_v1';
237
+ const _CTX_PRESETS = [8192, 16384, 32768, 50000, 131072, 0]; // 0 = model max
238
+ const _SCAN_CACHE_MAX = 12; // keep the newest N signatures
239
+ const _SCAN_CACHE_TTL = 6 * 3600 * 1000; // 6 h — hardware rarely changes
240
+
241
+ // Ctx slider helpers (ported from origin/main). The slider picks an INDEX into
242
+ // _CTX_PRESETS; _ctxValue() resolves it to a token count (0 = "Max"). The label
243
+ // next to the slider re-renders to "8k" / "16k" / … / "Max".
244
+ function _ctxLabel(value) {
245
+ const n = Number(value) || 0;
246
+ if (!n) return 'Max';
247
+ return n >= 1000 ? Math.round(n / 1000) + 'k' : String(n);
248
+ }
249
+
250
+ function _ctxValue() {
251
+ const slider = document.getElementById('hwfit-context');
252
+ const idx = Math.max(0, Math.min(_CTX_PRESETS.length - 1, Number(slider?.value ?? 3) || 0));
253
+ return _CTX_PRESETS[idx] || 0;
254
+ }
255
+
256
+ function _syncCtxControl() {
257
+ const slider = document.getElementById('hwfit-context');
258
+ const label = document.getElementById('hwfit-context-label');
259
+ if (!slider) return;
260
+ const saved = localStorage.getItem(_CTX_KEY);
261
+ const savedIdx = saved == null ? 3 : _CTX_PRESETS.indexOf(Number(saved));
262
+ slider.value = String(savedIdx >= 0 ? savedIdx : 3);
263
+ if (label) label.textContent = _ctxLabel(_ctxValue());
264
+ }
265
+
266
+ function _manualHwState() {
267
+ try {
268
+ const s = JSON.parse(localStorage.getItem(_MANUAL_HW_KEY) || '{}');
269
+ if (s && (s.mode === 'gpu' || s.mode === 'ram')) return s;
270
+ } catch {}
271
+ return null;
272
+ }
273
+
274
+ function _saveManualHwState(s) {
275
+ try {
276
+ if (!s || !s.mode) localStorage.removeItem(_MANUAL_HW_KEY);
277
+ else localStorage.setItem(_MANUAL_HW_KEY, JSON.stringify(s));
278
+ } catch {}
279
+ }
280
+
281
+ function _manualHwParams() {
282
+ const s = _manualHwState();
283
+ if (!s) return {};
284
+ return {
285
+ manual_mode: s.mode,
286
+ manual_gpu_count: s.mode === 'gpu' ? String(s.gpuCount || 1) : '',
287
+ manual_vram_gb: s.mode === 'gpu' ? String(s.vramGb || 8) : '',
288
+ manual_ram_gb: s.ramGb ? String(s.ramGb) : '',
289
+ manual_backend: s.mode === 'gpu' ? (s.backend || 'cuda') : '',
290
+ };
291
+ }
292
+
293
+ function _manualNumber(value, fallback) {
294
+ const raw = String(value || '').replace(',', '.');
295
+ const match = raw.match(/-?\d+(?:\.\d+)?/);
296
+ if (!match) return fallback;
297
+ const n = Number(match[0]);
298
+ return Number.isFinite(n) && n > 0 ? n : fallback;
299
+ }
300
+
301
+ function _manualOptionalNumber(value) {
302
+ const raw = String(value || '').replace(',', '.');
303
+ const match = raw.match(/-?\d+(?:\.\d+)?/);
304
+ if (!match) return null;
305
+ const n = Number(match[0]);
306
+ return Number.isFinite(n) && n > 0 ? n : null;
307
+ }
308
+
309
+ function _manualHwLabel(s) {
310
+ if (!s) return '';
311
+ // Manual mode is a "what if" SIMULATOR — values REPLACE detected
312
+ // hardware (matches server-side _apply_manual_hardware). Label
313
+ // phrased as plain "X GB" instead of additive "+X GB" so the user
314
+ // sees the simulated TOTAL, not an addition.
315
+ const ram = s.ramGb ? ` · ${s.ramGb} GB RAM` : '';
316
+ if (s.mode === 'ram') return `Manual: ${s.ramGb || 0} GB RAM only`;
317
+ const gpus = `${s.gpuCount || 1} GPU${Number(s.gpuCount || 1) === 1 ? '' : 's'}`;
318
+ return `Manual: ${gpus} · ${s.vramGb || 8} GB VRAM each${ram}`;
319
+ }
320
+
321
+ function _manualDisplaySystem(sys, manual) {
322
+ const base = { ...(sys || {}) };
323
+ if (!manual) return base;
324
+ base.manual_hardware = true;
325
+ // REPLACE detected RAM with the manual total. Previously this added
326
+ // on top of detected, which (a) contradicted the new server-side
327
+ // "replace" behavior and (b) made the chip's displayed total not
328
+ // match what was actually being ranked against.
329
+ if (manual.ramGb) {
330
+ base.available_ram_gb = Number(manual.ramGb);
331
+ base.total_ram_gb = Number(manual.ramGb);
332
+ }
333
+ if (manual.mode === 'ram') {
334
+ // RAM-only simulation — wipe GPU side so the chip display matches
335
+ // what the server is ranking against (CPU/RAM paths only).
336
+ base.has_gpu = false;
337
+ base.gpu_name = null;
338
+ base.gpu_vram_gb = 0;
339
+ base.gpu_count = 0;
340
+ return base;
341
+ }
342
+ if (manual.mode !== 'ram') {
343
+ const count = Number(manual.gpuCount || 1);
344
+ const vram = Number(manual.vramGb || 8);
345
+ const backend = (manual.backend || 'cuda').toUpperCase();
346
+ base.gpu_name = `Simulated ${backend} GPU` + (count > 1 ? ` × ${count}` : '');
347
+ base.gpu_vram_gb = Math.round(vram * count * 10) / 10;
348
+ base.gpu_count = count;
349
+ base.backend = manual.backend || 'cuda';
350
+ }
351
+ return base;
352
+ }
353
+
354
+ // Signature of everything that affects the result list, so we never paint a
355
+ // cached list under mismatched filters.
356
+ function _scanSig() {
357
+ const sortEl = document.getElementById('hwfit-sort');
358
+ const tc = document.getElementById('hwfit-gpu-toggles');
359
+ return JSON.stringify({
360
+ h: _envState.remoteHost || '',
361
+ u: document.getElementById('hwfit-usecase')?.value || '',
362
+ s: document.getElementById('hwfit-search')?.value?.trim() || '',
363
+ o: sortEl?.value || 'score',
364
+ r: sortEl?.dataset.reverse === '1' ? 1 : 0,
365
+ q: document.getElementById('hwfit-quant')?.value || '',
366
+ c: _ctxValue(),
367
+ g: (tc && typeof tc._activeCount === 'number') ? String(tc._activeCount) : '',
368
+ gg: (tc && tc._activeGroup) ? String(tc._activeGroup) : '',
369
+ m: _manualHwParams(),
370
+ d: Array.from(_dismissedHwChips).sort(),
371
+ });
372
+ }
373
+
374
+ function _readScanCache(sig) {
375
+ try {
376
+ const all = JSON.parse(localStorage.getItem(_SCAN_CACHE_KEY) || '{}');
377
+ const e = all[sig];
378
+ if (e && (Date.now() - e.ts) < _SCAN_CACHE_TTL) return e.data;
379
+ } catch {}
380
+ return null;
381
+ }
382
+
383
+ function _writeScanCache(sig, data) {
384
+ try {
385
+ const all = JSON.parse(localStorage.getItem(_SCAN_CACHE_KEY) || '{}');
386
+ all[sig] = { ts: Date.now(), data: { system: data.system, models: data.models } };
387
+ const keys = Object.keys(all);
388
+ if (keys.length > _SCAN_CACHE_MAX) {
389
+ keys.sort((a, b) => (all[a].ts || 0) - (all[b].ts || 0));
390
+ for (const k of keys.slice(0, keys.length - _SCAN_CACHE_MAX)) delete all[k];
391
+ }
392
+ localStorage.setItem(_SCAN_CACHE_KEY, JSON.stringify(all));
393
+ } catch {}
394
+ }
395
+
396
+ // Render a clear scan-failure card into the model list: which server failed, the
397
+ // underlying reason (small), and a Retry button that forces a fresh probe. Used
398
+ // for both the backend-reported error (SSH/probe failure) and network failures,
399
+ // instead of dumping a raw one-line message.
400
+ function _hwfitShowError(list, host, detail) {
401
+ if (!list) return;
402
+ const where = host ? esc(host) : 'this machine';
403
+ const div = document.createElement('div');
404
+ div.className = 'hwfit-loading';
405
+ div.style.cssText = 'flex-direction:column;gap:8px;text-align:center;';
406
+ div.innerHTML =
407
+ `<div style="color:var(--red);font-weight:600;">Couldn't scan ${where}</div>`
408
+ + (detail ? `<div style="opacity:0.6;font-size:11px;max-width:340px;line-height:1.4;">${esc(detail)}</div>` : '')
409
+ + `<button type="button" class="hwfit-gpu-btn" id="hwfit-retry" style="margin-top:2px;height:26px;">↻ Retry</button>`;
410
+ list.innerHTML = '';
411
+ list.appendChild(div);
412
+ const rb = div.querySelector('#hwfit-retry');
413
+ if (rb) rb.addEventListener('click', () => { _resetGpuToggleState(); _hwfitFetch(true); });
414
+ }
415
+
416
+ // Client-side "Engine" filter (llama.cpp / vLLM / SGLang). Empty = show all.
417
+ // Uses the same _detectBackend() the serve commands use, so what you filter to
418
+ // is exactly what would be launched. Pure view filter — no refetch needed.
419
+ function _applyEngineFilter(models) {
420
+ const want = document.getElementById('hwfit-engine')?.value || '';
421
+ if (!want || !Array.isArray(models)) return models || [];
422
+ return models.filter(m => {
423
+ try { return _detectBackend(m).backend === want; } catch { return true; }
424
+ });
425
+ }
426
+
427
+ export async function _hwfitFetch(fresh = false) {
428
+ const _tk = ++_hwfitFetchToken;
429
+ const useCase = document.getElementById('hwfit-usecase')?.value || '';
430
+ const search = document.getElementById('hwfit-search')?.value?.trim() || '';
431
+ const remoteHost = _envState.remoteHost || '';
432
+ const list = document.getElementById('hwfit-list');
433
+ const hw = document.getElementById('hwfit-hw');
434
+ if (!list) return;
435
+ const hasManualOrDismissed = !!_manualHwState() || _dismissedHwChips.size > 0;
436
+ if (hasManualOrDismissed) fresh = true;
437
+ // Instant paint from the persisted cache (skipped on a forced Rescan), so a
438
+ // reload shows the last result with no spinner. We still fetch fresh below and
439
+ // swap it in. If there's no cache hit, fall back to the spinner.
440
+ const _sig = _scanSig();
441
+ const _cached = fresh ? null : _readScanCache(_sig);
442
+ const wp = spinnerModule.createWhirlpool(18);
443
+ if (_cached) {
444
+ _hwfitCache = _cached;
445
+ _hwfitRenderHw(hw, _cached.system);
446
+ _hwfitRenderList(list, _applyEngineFilter(_cached.models));
447
+ } else {
448
+ // Show spinner while scanning — stack the spinner above a text label
449
+ // (the .hwfit-loading class is a centered flex ROW, so force column here).
450
+ const loadingDiv = document.createElement('div');
451
+ loadingDiv.className = 'hwfit-loading';
452
+ loadingDiv.style.flexDirection = 'column';
453
+ loadingDiv.style.gap = '6px';
454
+ loadingDiv.appendChild(wp.element);
455
+ // Text label like the other cookbook tabs: "Loading…", then if the scan runs
456
+ // long (remote SSH hardware probe), switch to "Scanning hardware…".
457
+ const loadingLbl = document.createElement('div');
458
+ loadingLbl.textContent = 'Loading…';
459
+ loadingLbl.style.cssText = 'text-align:center;opacity:0.5;font-size:11px;';
460
+ loadingDiv.appendChild(loadingLbl);
461
+ setTimeout(() => { if (loadingLbl.isConnected) loadingLbl.textContent = 'Scanning hardware…'; }, 2000);
462
+ list.innerHTML = '';
463
+ list.appendChild(loadingDiv);
464
+ _hwfitCache = null; // no instant paint — clear until the fetch returns
465
+ }
466
+ // Only fetch cached model IDs when server changes, not on every search/sort
467
+ if (!_cachedModelIds || _lastCacheHost() !== remoteHost) {
468
+ _setLastCacheHost(remoteHost);
469
+ const _cacheSrv = _envState.servers.find(s => s.host === remoteHost);
470
+ const _cachePort = _cacheSrv?.port || '';
471
+ const _cacheParams = new URLSearchParams({ host: remoteHost }); if (_cachePort) _cacheParams.set('ssh_port', _cachePort); if (_cacheSrv?.platform) _cacheParams.set('platform', _cacheSrv.platform);
472
+ fetch(`/api/model/cached?${_cacheParams}`, { credentials: 'same-origin' })
473
+ .then(r => r.json())
474
+ .then(d => {
475
+ // Exclude stalled (download-shell) entries — a 12 KB README-only
476
+ // folder shouldn't count as "downloaded" in the Scan/Download list.
477
+ _cachedModelIds = new Set((d.models || []).filter(m => m.status !== 'stalled').map(m => m.repo_id));
478
+ // Re-mark rows if already rendered
479
+ list.querySelectorAll('.hwfit-row[data-model]').forEach(row => {
480
+ const name = row.dataset.model;
481
+ if (_cachedModelIds.has(name) || [..._cachedModelIds].some(id => id.endsWith('/' + name?.split('/').pop()))) {
482
+ const nameEl = row.querySelector('.hwfit-name');
483
+ if (nameEl && !nameEl.querySelector('.hwfit-dl-dot')) {
484
+ nameEl.insertAdjacentHTML('beforeend', '<span class="hwfit-dl-dot" title="Downloaded">\u25CF</span>');
485
+ }
486
+ }
487
+ });
488
+ }).catch(() => {});
489
+ }
490
+ try {
491
+ const sortBy = document.getElementById('hwfit-sort')?.value || 'score';
492
+ const quantPref = document.getElementById('hwfit-quant')?.value || '';
493
+ const targetCtx = _ctxValue();
494
+ // Get active GPU count from toggles
495
+ const toggleContainer = document.getElementById('hwfit-gpu-toggles');
496
+ let gpuCountOverride = '';
497
+ if (!hasManualOrDismissed && toggleContainer && typeof toggleContainer._activeCount === 'number') {
498
+ gpuCountOverride = String(toggleContainer._activeCount);
499
+ }
500
+ // Which homogeneous GPU pool to rank against (heterogeneous boxes only).
501
+ let gpuGroupOverride = '';
502
+ if (!hasManualOrDismissed && toggleContainer && toggleContainer._activeGroup) {
503
+ gpuGroupOverride = String(toggleContainer._activeGroup);
504
+ }
505
+ const params = new URLSearchParams({ limit: '80', sort: sortBy });
506
+ if (fresh) params.set('fresh', '1'); // bypass the hardware-scan cache
507
+ if (search) params.set('search', search);
508
+ if (remoteHost) {
509
+ params.set('host', remoteHost);
510
+ const _srv = _envState.servers.find(s => s.host === remoteHost);
511
+ const _hp = _srv?.port || '';
512
+ if (_hp) params.set('ssh_port', _hp);
513
+ if (_srv?.platform) params.set('platform', _srv.platform);
514
+ }
515
+ if (gpuCountOverride !== '') params.set('gpu_count', gpuCountOverride);
516
+ if (gpuGroupOverride !== '') params.set('gpu_group', gpuGroupOverride);
517
+ if (_dismissedHwChips.has('gpu') || _dismissedHwChips.has('vram')) params.set('ignore_detected_gpu', 'true');
518
+ if (_dismissedHwChips.has('ram')) params.set('ignore_detected_ram', 'true');
519
+ const manualParams = _manualHwParams();
520
+ Object.entries(manualParams).forEach(([k, v]) => {
521
+ if (v !== '') params.set(k, v);
522
+ });
523
+ if (hasManualOrDismissed) params.set('_hw_override_ts', String(Date.now()));
524
+ // Image models use a separate registry/endpoint
525
+ const isImageMode = useCase === 'image_gen';
526
+ if (!isImageMode) {
527
+ if (useCase) params.set('use_case', useCase);
528
+ if (quantPref) params.set('quant', quantPref);
529
+ if (targetCtx) params.set('ctx', String(targetCtx));
530
+ // Fit-only filter — set by the dot in the Fit column header.
531
+ const _fitOnly = (() => { try { return localStorage.getItem('hwfit_fit_only_v1') === '1'; } catch { return false; } })();
532
+ if (_fitOnly) params.set('fit_only', '1');
533
+ }
534
+ const endpoint = isImageMode ? `/api/hwfit/image-models?${params}` : `/api/hwfit/models?${params}`;
535
+ const res = await fetch(endpoint);
536
+ // A newer scan started while this one was in flight (user switched servers
537
+ // mid-probe) — drop this stale response so it can't clobber the new one.
538
+ if (_tk !== _hwfitFetchToken) { try { wp.destroy(); } catch {} return; }
539
+ if (!res.ok) throw new Error(res.statusText);
540
+ let data = await res.json();
541
+ if (_tk !== _hwfitFetchToken) { try { wp.destroy(); } catch {} return; }
542
+ if (!isImageMode && quantPref && !data.error && Array.isArray(data.models) && data.models.length === 0) {
543
+ const fallbackParams = new URLSearchParams(params);
544
+ fallbackParams.delete('quant');
545
+ const fallbackRes = await fetch(`/api/hwfit/models?${fallbackParams}`);
546
+ if (_tk !== _hwfitFetchToken) { try { wp.destroy(); } catch {} return; }
547
+ if (fallbackRes.ok) {
548
+ const fallbackData = await fallbackRes.json();
549
+ if (!fallbackData.error && Array.isArray(fallbackData.models) && fallbackData.models.length > 0) {
550
+ data = fallbackData;
551
+ const quantSel = document.getElementById('hwfit-quant');
552
+ if (quantSel) quantSel.value = '';
553
+ }
554
+ }
555
+ }
556
+ // Normalize image model fields to match LLM renderer expectations
557
+ if (isImageMode && data.models) {
558
+ data.models = data.models.map(m => ({
559
+ ...m,
560
+ name: m.id || m.name,
561
+ fit_level: m.fit || 'no_fit',
562
+ parameter_count: m.params_b ? m.params_b + 'B' : '?',
563
+ required_gb: m.vram_needed || 0,
564
+ speed_tps: 0,
565
+ context: 0,
566
+ run_mode: m.capabilities?.[0] || 'image',
567
+ is_image_gen: true,
568
+ quant: m.quant || m.default_quant || 'BF16',
569
+ quant_repo: m.quant_repo || null,
570
+ }));
571
+ }
572
+ wp.destroy();
573
+ if (data.error) {
574
+ // Keep the instantly-painted cache if we had one — don't replace good data
575
+ // with an error on a transient probe failure (stale-while-revalidate).
576
+ if (!_cached) { _hwfitShowError(list, remoteHost, data.error); if (hw) hw.innerHTML = ''; }
577
+ return;
578
+ }
579
+ _hwfitCache = data;
580
+ _hwfitRenderHw(hw, data.system);
581
+ // Sort client-side by the active column so the highest↔lowest toggle is
582
+ // deterministic (the previous array .reverse() didn't reliably flip).
583
+ // 1st click on a column = highest first; clicking it again = lowest first.
584
+ if (!isImageMode) {
585
+ const sortSel = document.getElementById('hwfit-sort');
586
+ const sortKey = sortSel?.value || 'score';
587
+ const asc = sortSel?.dataset.reverse === '1'; // reversed → ascending (lowest first)
588
+ if (sortKey === 'fit') {
589
+ // fit_level is categorical (perfect→good→marginal→too_tight), not numeric,
590
+ // so rank it explicitly instead of falling through to the score column.
591
+ // Tie-break by score so rows within one fit tier stay meaningfully ordered.
592
+ const fitRank = { perfect: 4, good: 3, marginal: 2, too_tight: 1, no_fit: 0 };
593
+ data.models.sort((a, b) => {
594
+ const ar = fitRank[a.fit_level] ?? -1, br = fitRank[b.fit_level] ?? -1;
595
+ if (ar !== br) return asc ? ar - br : br - ar;
596
+ const as = Number(a.score) || 0, bs = Number(b.score) || 0;
597
+ return asc ? as - bs : bs - as;
598
+ });
599
+ } else {
600
+ const field = { score: 'score', vram: 'required_gb', speed: 'speed_tps', params: 'params_b', context: 'context' }[sortKey] || 'score';
601
+ data.models.sort((a, b) => {
602
+ const av = Number(a[field]) || 0, bv = Number(b[field]) || 0;
603
+ return asc ? av - bv : bv - av;
604
+ });
605
+ }
606
+ }
607
+ _hwfitRenderList(list, _applyEngineFilter(data.models));
608
+ // Persist this result so the next page load can paint it instantly.
609
+ _writeScanCache(_sig, data);
610
+ // Render GPU toggles — only on first scan (no override active)
611
+ if (toggleContainer && !toggleContainer._originalSystem) {
612
+ // Only trust the system info if no GPU override was applied
613
+ if (toggleContainer._activeCount === undefined) {
614
+ toggleContainer._originalSystem = { ...data.system };
615
+ _renderGpuToggles(toggleContainer._originalSystem);
616
+ }
617
+ }
618
+ } catch (e) {
619
+ wp.destroy();
620
+ // Same stale-while-revalidate rule: only surface the error if we have nothing
621
+ // already on screen from the cache.
622
+ if (!_cached) _hwfitShowError(list, remoteHost, e.message);
623
+ }
624
+ }
625
+
626
+ export function _hwfitRenderHw(el, sys) {
627
+ if (!el || !sys) return;
628
+ // Cache system info globally so other modules can read VRAM without refetching
629
+ try { window._hwfitSystemCache = sys; } catch {}
630
+ // Show the hardware row when we have data
631
+ const hwRow = document.getElementById('hwfit-hw-row');
632
+ if (hwRow) hwRow.style.display = 'flex';
633
+ const gpuCount = sys.gpu_count || 0;
634
+ // gpu_error = nvidia-smi present but failing (e.g. driver/library version
635
+ // mismatch). Surface it instead of the misleading "No GPU" — plain text
636
+ // label, full error in the tooltip.
637
+ // Chip rendering: split into a clickable body (toggle off / on) and a
638
+ // separate × button (fully remove from view + treat as dismissed for
639
+ // ranking). The body's "off" state is just visually dimmed — the
640
+ // chip stays visible so you can flip it back on without re-scanning.
641
+ const chip = (key, label, title = 'Click to toggle off (X to hide)') => {
642
+ if (_removedHwChips.has(key)) return '';
643
+ const dim = _dismissedHwChips.has(key) ? ' hwfit-hw-chip-off' : '';
644
+ return (
645
+ `<span class="hwfit-hw-chip hwfit-hw-chip-row${dim}" data-hw-chip="${esc(key)}">`
646
+ + `<button type="button" class="hwfit-hw-chip-toggle" data-hw-chip="${esc(key)}" title="${esc(title)}">${label}</button>`
647
+ + `<button type="button" class="hwfit-hw-chip-x" data-hw-chip="${esc(key)}" title="Remove this chip" aria-label="Remove">×</button>`
648
+ + `</span>`
649
+ );
650
+ };
651
+ let gpuChip;
652
+ if (sys.gpu_name) {
653
+ // Mixed-GPU boxes (#711): `${gpuCount}x ${gpu_name}` uses gpus[0].name for
654
+ // every card, so a 4090+3060 reads as "2x RTX 4090". Use gpu_groups (the
655
+ // backend already groups identical cards) to render each pool separately
656
+ // and put the per-card index+VRAM into the tooltip so it's actually
657
+ // useful for picking CUDA_VISIBLE_DEVICES.
658
+ const groups = Array.isArray(sys.gpu_groups) ? sys.gpu_groups : [];
659
+ // Shorten vendor prefixes so a mixed-GPU label fits in the chip row
660
+ // without overflowing. Single-GPU label still shows the full name
661
+ // (that's what users are used to seeing). Tooltip carries the full
662
+ // unmodified names regardless, so no information is lost.
663
+ const _shortGpuName = (n) => String(n || '')
664
+ .replace(/^NVIDIA\s+GeForce\s+/i, '')
665
+ .replace(/^NVIDIA\s+/i, '')
666
+ .replace(/^AMD\s+Radeon\s+/i, '')
667
+ .replace(/^AMD\s+/i, '')
668
+ .replace(/^Intel\s+/i, '');
669
+ let label;
670
+ if (groups.length > 1) {
671
+ // Heterogeneous: "1× RTX 4090 + 1× RTX 3060"
672
+ label = groups.map(g => `${g.count}× ${esc(_shortGpuName(g.name))}`).join(' + ');
673
+ } else if (gpuCount > 1) {
674
+ label = `${gpuCount}× ${esc(sys.gpu_name)}`;
675
+ } else {
676
+ label = esc(sys.gpu_name);
677
+ }
678
+ const gpus = Array.isArray(sys.gpus) ? sys.gpus : [];
679
+ const tip = gpus.length
680
+ ? gpus.map(g => `GPU ${g.index}: ${g.name} · ${(+g.vram_gb).toFixed(1)} GB`).join('\n')
681
+ : 'Click to toggle off (X to hide)';
682
+ gpuChip = chip('gpu', label, tip);
683
+ } else if (sys.gpu_error) {
684
+ gpuChip = _removedHwChips.has('gpu')
685
+ ? ''
686
+ : (() => {
687
+ const dim = _dismissedHwChips.has('gpu') ? ' hwfit-hw-chip-off' : '';
688
+ return (
689
+ `<span class="hwfit-hw-chip hwfit-hw-chip-row hwfit-hw-chip-error${dim}" data-hw-chip="gpu">`
690
+ + `<button type="button" class="hwfit-hw-chip-toggle" data-hw-chip="gpu" title="${esc(sys.gpu_error)}">GPU driver error</button>`
691
+ + `<button type="button" class="hwfit-hw-chip-x" data-hw-chip="gpu" title="Remove this chip" aria-label="Remove">×</button>`
692
+ + `</span>`
693
+ );
694
+ })();
695
+ } else {
696
+ gpuChip = chip('gpu', 'No GPU');
697
+ }
698
+ const vram = sys.gpu_vram_gb ? `${sys.gpu_vram_gb.toFixed(1)} GB VRAM` : '';
699
+ const ram = `${sys.available_ram_gb?.toFixed(1) || '?'} / ${sys.total_ram_gb?.toFixed(1) || '?'} GB RAM`;
700
+ const cores = `${sys.cpu_cores || '?'} cores`;
701
+ const manual = _manualHwState();
702
+ const manualChip = (sys.manual_hardware || manual)
703
+ ? `<span class="hwfit-hw-chip hwfit-hw-chip-row hwfit-hw-chip-manual" data-hw-chip="manual">`
704
+ + `<button type="button" class="hwfit-hw-chip-toggle" data-hw-chip="manual" title="Using manual hardware">${esc(_manualHwLabel(manual) || 'Manual hardware')}</button>`
705
+ + `<button type="button" class="hwfit-hw-chip-x" data-hw-chip="manual" title="Clear manual hardware" aria-label="Clear">×</button>`
706
+ + `</span>`
707
+ : '';
708
+ el.innerHTML = gpuChip
709
+ + (vram ? chip('vram', vram) : '')
710
+ + chip('ram', ram)
711
+ + chip('cores', cores)
712
+ + chip('backend', esc(sys.backend || ''))
713
+ + manualChip;
714
+ // Body click → toggle "off" (dimmed, still visible). Membership of
715
+ // _dismissedHwChips is what the ranker reads, so both add+remove
716
+ // here also flips the model list. The manual chip is excluded —
717
+ // dimming "manual" has no ranking effect (the key isn't checked),
718
+ // so click-to-toggle there would feel broken. Use × to clear it.
719
+ el.querySelectorAll('.hwfit-hw-chip-toggle').forEach(btn => {
720
+ btn.addEventListener('click', (e) => {
721
+ e.stopPropagation();
722
+ const key = btn.dataset.hwChip;
723
+ if (!key || key === 'manual') return;
724
+ const row = btn.closest('.hwfit-hw-chip-row');
725
+ if (_dismissedHwChips.has(key)) {
726
+ _dismissedHwChips.delete(key);
727
+ row?.classList.remove('hwfit-hw-chip-off');
728
+ } else {
729
+ _dismissedHwChips.add(key);
730
+ row?.classList.add('hwfit-hw-chip-off');
731
+ }
732
+ _resetGpuToggleState(false);
733
+ _hwfitCache = null;
734
+ _hwfitFetch(true);
735
+ });
736
+ });
737
+ // × button → fully remove the chip from view AND treat it as
738
+ // dismissed for ranking purposes (until next rescan).
739
+ el.querySelectorAll('.hwfit-hw-chip-x').forEach(btn => {
740
+ btn.addEventListener('click', (e) => {
741
+ e.stopPropagation();
742
+ const key = btn.dataset.hwChip;
743
+ if (!key) return;
744
+ // The manual-hardware chip needs special teardown: clear the
745
+ // saved manual state so the chip doesn't re-render on the next
746
+ // fetch from localStorage. Routes through clearManual() which
747
+ // also collapses the edit panel.
748
+ if (key === 'manual') {
749
+ _saveManualHwState(null);
750
+ btn.closest('.hwfit-hw-chip-row')?.remove();
751
+ document.getElementById('hwfit-manual-panel')?.classList.add('hidden');
752
+ _resetGpuToggleState();
753
+ _hwfitCache = null;
754
+ _hwfitFetch(true);
755
+ return;
756
+ }
757
+ _removedHwChips.add(key);
758
+ _dismissedHwChips.add(key);
759
+ btn.closest('.hwfit-hw-chip-row')?.remove();
760
+ _resetGpuToggleState(false);
761
+ _hwfitCache = null;
762
+ _hwfitFetch(true);
763
+ });
764
+ });
765
+ _wireManualHardwareControls(el);
766
+ }
767
+
768
+ function _wireManualHardwareControls(el) {
769
+ const btn = document.getElementById('hwfit-hw-manual-btn');
770
+ const panel = document.getElementById('hwfit-manual-panel');
771
+ if (!btn || !panel) return;
772
+ const clearManual = () => {
773
+ _saveManualHwState(null);
774
+ el.querySelector('.hwfit-hw-chip-manual')?.remove();
775
+ panel.classList.add('hidden');
776
+ _resetGpuToggleState();
777
+ _hwfitCache = null;
778
+ _hwfitFetch(true);
779
+ };
780
+ const manual = _manualHwState();
781
+ btn.textContent = 'EDIT';
782
+ if (manual) {
783
+ panel.querySelector('.hwfit-manual-mode').value = manual.mode || 'gpu';
784
+ panel.querySelector('.hwfit-manual-backend').value = manual.backend || 'cuda';
785
+ }
786
+ const syncMode = () => {
787
+ const isRam = panel.querySelector('.hwfit-manual-mode')?.value === 'ram';
788
+ panel.querySelector('.hwfit-manual-gpus')?.closest('label')?.style.setProperty('display', isRam ? 'none' : '');
789
+ panel.querySelector('.hwfit-manual-vram')?.closest('label')?.style.setProperty('display', isRam ? 'none' : '');
790
+ const backend = panel.querySelector('.hwfit-manual-backend');
791
+ if (backend) backend.style.display = isRam ? 'none' : '';
792
+ };
793
+ if (!btn._hwfitManualBound) {
794
+ btn._hwfitManualBound = true;
795
+ btn.addEventListener('click', () => {
796
+ panel.classList.toggle('hidden');
797
+ syncMode();
798
+ });
799
+ }
800
+ el.querySelector('.hwfit-hw-chip-toggle[data-hw-chip="manual"]')?.addEventListener('click', () => {
801
+ panel.classList.remove('hidden');
802
+ syncMode();
803
+ });
804
+ if (!panel._hwfitManualBound) {
805
+ panel._hwfitManualBound = true;
806
+ panel.querySelector('.hwfit-manual-mode')?.addEventListener('change', syncMode);
807
+ panel.querySelector('.hwfit-hw-manual-save')?.addEventListener('click', () => {
808
+ const mode = panel.querySelector('.hwfit-manual-mode')?.value || 'gpu';
809
+ const gpuCount = _manualNumber(panel.querySelector('.hwfit-manual-gpus')?.value, 1);
810
+ const vramGb = _manualNumber(panel.querySelector('.hwfit-manual-vram')?.value, 8);
811
+ const ramGb = _manualOptionalNumber(panel.querySelector('.hwfit-manual-ram')?.value);
812
+ const backend = panel.querySelector('.hwfit-manual-backend')?.value || 'cuda';
813
+ const manual = { mode, gpuCount, vramGb, ramGb, backend };
814
+ _saveManualHwState(manual);
815
+ _resetGpuToggleState();
816
+ _hwfitCache = null;
817
+ panel.classList.add('hidden');
818
+ _hwfitRenderHw(el, _manualDisplaySystem(window._hwfitSystemCache, manual));
819
+ _hwfitFetch(true);
820
+ });
821
+ panel.querySelector('.hwfit-hw-manual-clear')?.addEventListener('click', clearManual);
822
+ }
823
+ syncMode();
824
+ }
825
+
826
+ export const _fitColors = { perfect: 'var(--green, #50fa7b)', good: 'var(--yellow, #f1fa8c)', marginal: 'var(--orange, #ffb86c)', too_tight: 'var(--red, #ff5555)' };
827
+
828
+ function _requiresAcceleratorBackend(model) {
829
+ const q = String(model?.quant || model?.quantization || '').toUpperCase();
830
+ const text = `${model?.name || ''} ${model?.repo_id || ''} ${model?.path || ''}`.toLowerCase();
831
+ return /^AWQ|^GPTQ|^NVFP4/.test(q) || q === 'FP8' || /\b(awq|gptq|fp8|nvfp4)\b/i.test(text);
832
+ }
833
+
834
+ function _modeLabel(model) {
835
+ if (model?.is_image_gen) return 'image';
836
+ if (_requiresAcceleratorBackend(model)) return 'vLLM/SGLang';
837
+ const detected = _detectBackend(model);
838
+ if (detected?.label) return detected.label;
839
+ return String(model?.run_mode || '').replace('_', '+');
840
+ }
841
+
842
+ export const _hwfitColumns = [
843
+ { key: 'fit', label: 'Fit', cls: 'hwfit-fit' },
844
+ { key: null, label: 'Model', cls: 'hwfit-name' },
845
+ { key: 'params',label: 'Param', cls: 'hwfit-c-params' },
846
+ { key: null, label: 'Quant', cls: 'hwfit-c-quant' },
847
+ { key: 'vram', label: 'VRAM', cls: 'hwfit-c-vram' },
848
+ { key: 'context',label: 'Ctx', cls: 'hwfit-c-ctx' },
849
+ { key: 'speed', label: 'Speed', cls: 'hwfit-c-speed' },
850
+ { key: 'score', label: 'Score', cls: 'hwfit-c-score' },
851
+ { key: null, label: 'Mode', cls: 'hwfit-c-mode' },
852
+ ];
853
+
854
+ export function _hwfitRenderList(el, models) {
855
+ if (!el) return;
856
+ models = models || [];
857
+ if (!models.length) {
858
+ // Disambiguate WHY the list is empty so capable servers don't read as "too weak":
859
+ // active filters vs. a likely under-reported probe vs. genuinely low hardware.
860
+ const sys = _hwfitCache?.system;
861
+ const hasHw = sys && ((sys.gpu_vram_gb || 0) > 0 || (sys.total_ram_gb || 0) > 8);
862
+ const hasFilters = !!(document.getElementById('hwfit-search')?.value?.trim()
863
+ || document.getElementById('hwfit-usecase')?.value
864
+ || document.getElementById('hwfit-quant')?.value
865
+ || document.getElementById('hwfit-engine')?.value);
866
+ let msg;
867
+ if (hasFilters) msg = 'No models match these filters — try clearing the search, use-case, quant, or engine.';
868
+ else if (hasHw) msg = 'No models fit — the hardware probe may have under-reported. Try Rescan.';
869
+ else msg = 'No models fit your hardware';
870
+ el.innerHTML = `<div class="hwfit-loading">${msg}</div>`;
871
+ return;
872
+ }
873
+ const sortSel = document.getElementById('hwfit-sort');
874
+ const currentSort = sortSel?.value || 'score';
875
+ const isReversed = sortSel?.dataset.reverse === '1';
876
+ // Active budget for the Fit column label \u2014 make it obvious whether the
877
+ // ranking is against GPU or RAM so "tightest" can't be ambiguous on a
878
+ // mixed-resource box.
879
+ const tc = document.getElementById('hwfit-gpu-toggles');
880
+ const _budget = (tc && typeof tc._activeCount === 'number')
881
+ ? (tc._activeCount === 0 ? 'RAM' : (tc._activeCount === 1 ? 'GPU' : tc._activeCount + ' GPU'))
882
+ : null;
883
+ let html = '<div class="hwfit-row hwfit-header">';
884
+ for (const col of _hwfitColumns) {
885
+ const sortable = col.key ? ' hwfit-sortable' : '';
886
+ const active = col.key === currentSort ? ' hwfit-sort-active' : '';
887
+ let arrow = '';
888
+ if (col.key === currentSort) {
889
+ // \u25BC = highest first (default), \u25B2 = reversed (lowest first) \u2014 uniform
890
+ // across all columns now.
891
+ arrow = isReversed ? ' \u25B2' : ' \u25BC';
892
+ }
893
+ const dataAttr = col.key ? ` data-sort="${col.key}"` : '';
894
+ // Fit column gets a small dot to its left that toggles "show only models
895
+ // that fit" — replaces the old Fits On/Off button next to the toolbar.
896
+ let label = col.label;
897
+ if (col.cls === 'hwfit-fit') {
898
+ const _fitOnly = (() => { try { return localStorage.getItem('hwfit_fit_only_v1') === '1'; } catch { return false; } })();
899
+ label = `<span class="hwfit-fit-dot${_fitOnly ? ' active' : ''}" title="${_fitOnly ? 'Showing only models that fit. Click to also show too-tight rows.' : 'Click to show only models that fit your hardware.'}" data-fit-dot>●</span>${col.label}`;
900
+ // (Budget tag removed — the GPU/RAM/N-GPU suffix next to "Fit" was noise;
901
+ // the toggle row already shows which budget is active.)
902
+ }
903
+ html += `<span class="hwfit-col ${col.cls}${sortable}${active}"${dataAttr}>${label}${arrow}</span>`;
904
+ }
905
+ html += '</div>';
906
+ for (const m of models) {
907
+ const fitColor = _fitColors[m.fit_level] || 'var(--fg-muted)';
908
+ const score = m.score?.toFixed?.(1) ?? m.score ?? '0';
909
+ let tpsRaw = m.speed_tps ?? 0;
910
+ if (tpsRaw > 9999) tpsRaw = 9999;
911
+ const tps = tpsRaw > 0 ? (tpsRaw >= 100 ? Math.round(tpsRaw) : tpsRaw.toFixed(1)) : '?';
912
+ const pcount = m.parameter_count || '?';
913
+ const ctx = m.context ? (m.context >= 1024 ? (m.context / 1024).toFixed(0) + 'k' : m.context) : '?';
914
+ const fitLabel = (m.fit_level || '').replace('_', ' ');
915
+ const modeLabel = _modeLabel(m);
916
+ const vramLabel = m.required_gb ? m.required_gb.toFixed(1) + 'G' : '?';
917
+ const moeBadge = m.is_moe ? '<span class="hwfit-badge hwfit-moe">MoE</span>' : '';
918
+ const imgBadge = m.is_image_gen ? '<span class="hwfit-badge" style="background:color-mix(in srgb, var(--red) 20%, transparent);color:var(--red);font-size:8px;padding:1px 4px;border-radius:3px;margin-left:4px;">IMG</span>' : '';
919
+ const dlDot = (_cachedModelIds && (_cachedModelIds.has(m.name) || [..._cachedModelIds].some(id => id === m.name?.split('/').pop()))) ? '<span class="hwfit-dl-dot" title="Downloaded">\u25CF</span>' : '';
920
+ html += `<div class="hwfit-row" data-model="${esc(m.name)}">`;
921
+ html += `<span class="hwfit-col hwfit-fit" style="color:${fitColor}">${esc(fitLabel)}</span>`;
922
+ // Append quant to the title when it's not already in the repo name. The
923
+ // suffix strips quant-parts the name already contains — e.g. for
924
+ // QuantTrio/MiniMax-M2-AWQ + quant=AWQ-4bit we just show "(4bit)", not
925
+ // "(AWQ-4bit)". DeepSeek-V4-Flash + FP4-MoE-Mixed keeps the full tag
926
+ // (none of those parts are in the repo id).
927
+ const _short = m.name?.split('/').pop() || m.name || '';
928
+ const _quantTag = (m.quant || '').trim();
929
+ const _lowerShort = _short.toLowerCase();
930
+ let _quantSuffix = '';
931
+ if (_quantTag) {
932
+ const _parts = _quantTag.split(/[-_]/).filter(Boolean);
933
+ const _remaining = _parts.filter(p => !_lowerShort.includes(p.toLowerCase()));
934
+ if (_remaining.length && _remaining.length < _parts.length + 1) { // at least one part is new
935
+ let _display = _remaining.join('-');
936
+ if (_display.length > 9) _display = _display.slice(0, 9) + '…';
937
+ _quantSuffix = ` <span class="hwfit-name-quant" title="${esc(_quantTag)} — full storage format">(${esc(_display)})</span>`;
938
+ }
939
+ }
940
+ html += `<span class="hwfit-col hwfit-name">${modelLogo(m.name)}${esc(_short)}${_quantSuffix}${moeBadge}${imgBadge}${dlDot}</span>`;
941
+ html += `<span class="hwfit-col hwfit-c-params">${esc(pcount)}</span>`;
942
+ // Truncate the Quant cell to 9 chars + ellipsis so long tags like
943
+ // "FP4-MoE-Mixed" don't push neighboring columns. Full tag stays in title.
944
+ const _qRaw = m.quant || '?';
945
+ const _qShort = _qRaw.length > 9 ? _qRaw.slice(0, 9) + '…' : _qRaw;
946
+ html += `<span class="hwfit-col hwfit-c-quant" title="${esc(_qRaw)}">${esc(_qShort)}</span>`;
947
+ html += `<span class="hwfit-col hwfit-c-vram">${vramLabel}</span>`;
948
+ html += `<span class="hwfit-col hwfit-c-ctx">${m.is_image_gen ? '\u2014' : ctx}</span>`;
949
+ html += `<span class="hwfit-col hwfit-c-speed">${m.is_image_gen ? '\u2014' : tps + ' t/s'}</span>`;
950
+ html += `<span class="hwfit-col hwfit-c-score">${score}</span>`;
951
+ html += `<span class="hwfit-col hwfit-c-mode" title="${_requiresAcceleratorBackend(m) ? 'Requires vLLM or SGLang with a visible CUDA/ROCm accelerator. llama.cpp and Ollama need GGUF files.' : ''}">${esc(modeLabel)}</span>`;
952
+ html += `</div>`;
953
+ }
954
+ el.innerHTML = html;
955
+ // Click row → expand inline action panel
956
+ el.querySelectorAll('.hwfit-row:not(.hwfit-header)').forEach(row => {
957
+ row.addEventListener('click', () => {
958
+ const name = row.dataset.model;
959
+ if (!name) return;
960
+ // Find model data from cache
961
+ const modelData = (_hwfitCache?.models || []).find(m => m.name === name);
962
+ if (!modelData) return;
963
+ _expandModelRow(row, modelData);
964
+ });
965
+ });
966
+ // Clickable header columns → sort (click again to toggle direction)
967
+ el.querySelectorAll('.hwfit-header .hwfit-sortable').forEach(col => {
968
+ col.addEventListener('click', (e) => {
969
+ // The little dot inside the Fit header is its own toggle (fit-only
970
+ // filter), don't let it fall through to a sort click.
971
+ if (e.target.closest('[data-fit-dot]')) {
972
+ const on = !e.target.classList.contains('active');
973
+ try { localStorage.setItem('hwfit_fit_only_v1', on ? '1' : '0'); } catch {}
974
+ // Un-toggling the fit filter (off → showing too-tight rows again) is
975
+ // typically because the user wants to see the LARGE models they can't
976
+ // run yet — re-sort by VRAM descending so the biggest surface first.
977
+ if (!on) {
978
+ const sortSel = document.getElementById('hwfit-sort');
979
+ if (sortSel) {
980
+ sortSel.value = 'vram';
981
+ sortSel.dataset.reverse = '0'; // descending (biggest first)
982
+ }
983
+ }
984
+ _hwfitCache = null;
985
+ _hwfitFetch();
986
+ return;
987
+ }
988
+ const sortKey = col.dataset.sort;
989
+ if (!sortKey) return;
990
+ const sel = document.getElementById('hwfit-sort');
991
+ if (!sel) return;
992
+ // Toggle direction if clicking the same column
993
+ if (sel.value === sortKey) {
994
+ sel.dataset.reverse = sel.dataset.reverse === '1' ? '0' : '1';
995
+ } else {
996
+ sel.value = sortKey;
997
+ sel.dataset.reverse = '0';
998
+ }
999
+ _hwfitFetch();
1000
+ });
1001
+ });
1002
+ }
1003
+
1004
+ // Read the server currently selected in the scan dropdown and make it the
1005
+ // active host. Called right before a download/run so the action targets the
1006
+ // server the user sees selected — defends against the global remoteHost being
1007
+ // changed elsewhere (e.g. background serve-task handling) between selecting and
1008
+ // clicking, which was sending downloads to the wrong host.
1009
+ // Resolve the server the user currently has selected in the scan dropdown and
1010
+ // return its host string (''/local for local). Also mirrors it into _envState
1011
+ // for the command preview. The RETURN VALUE is the source of truth passed to
1012
+ // the download — never trust _envState.remoteHost downstream (multiple copies).
1013
+ function _syncHostFromScanDropdown() {
1014
+ const ss = document.getElementById('hwfit-server-select');
1015
+ if (!ss || ss.value == null) return _envState.remoteHost || '';
1016
+ let host = '';
1017
+ if (ss.value === 'local') {
1018
+ _envState.remoteHost = '';
1019
+ } else {
1020
+ const s = _serverByVal(ss.value);
1021
+ if (s) {
1022
+ host = s.host;
1023
+ _envState.remoteHost = s.host;
1024
+ _envState.env = s.env;
1025
+ _envState.envPath = s.envPath;
1026
+ _envState.platform = s.platform || '';
1027
+ }
1028
+ }
1029
+ try { _persistEnvState(); } catch {}
1030
+ return host;
1031
+ }
1032
+
1033
+ export function _expandModelRow(row, modelData) {
1034
+ const list = row.closest('.hwfit-list');
1035
+ if (!list) return;
1036
+
1037
+ const existingPanel = list.querySelector('.hwfit-action-panel');
1038
+ const wasActive = row.classList.contains('hwfit-row-active');
1039
+
1040
+ // Remove existing panel and active state
1041
+ if (existingPanel) existingPanel.remove();
1042
+ list.querySelectorAll('.hwfit-row-active').forEach(r => r.classList.remove('hwfit-row-active'));
1043
+
1044
+ // Toggle: if clicking same row, just close
1045
+ if (wasActive) return;
1046
+
1047
+ row.classList.add('hwfit-row-active');
1048
+ const { backend, label } = _detectBackend(modelData);
1049
+ const isVllm = backend === 'vllm';
1050
+ const isLlamaCpp = backend === 'llamacpp';
1051
+ const ctx = modelData.context || 8192;
1052
+
1053
+ const dlSource = _downloadSourceRepo(modelData, backend);
1054
+ const hfUrl = `https://huggingface.co/${dlSource.repo}`;
1055
+ let html = `<div class="hwfit-action-panel" data-model-name="${esc(modelData.name)}">`;
1056
+ html += `<div class="hwfit-panel-header">`;
1057
+ html += `<span class="hwfit-panel-model">${esc(modelData.name)}${dlSource.kind ? ` <span style="opacity:0.5;font-size:10px;">(${esc(dlSource.kind)} ${esc(modelData.quant || '')})</span>` : (modelData.quant_repo ? ` <span style="opacity:0.5;font-size:10px;">(${esc(modelData.quant)})</span>` : '')}</span>`;
1058
+ html += `<span class="hwfit-panel-badge">${esc(label)}</span>`;
1059
+ html += `<a href="${esc(hfUrl)}" target="_blank" rel="noopener" class="hwfit-panel-hf-link" title="View download source on HuggingFace">HF \u2197</a>`;
1060
+ html += `</div>`;
1061
+ html += `<div class="hwfit-panel-actions">`;
1062
+ html += `<button class="cookbook-btn hwfit-dl-btn">Download</button>`;
1063
+ if (!modelData.is_image_gen) {
1064
+ html += `<button class="cookbook-btn cookbook-run-btn hwfit-quickrun-btn" title="Download + launch with smart defaults">Run</button>`;
1065
+ html += `<button class="cookbook-btn hwfit-serve-expand-btn" title="Configure & serve">Configure</button>`;
1066
+ }
1067
+ html += `</div>`;
1068
+ if (modelData.is_image_gen) {
1069
+ html += `<div style="font-size:10px;opacity:0.5;margin-top:4px;">${esc((modelData.capabilities || []).join(' \u00B7 ') || '')}${modelData.description ? ' \u2014 ' + esc(modelData.description) : ''}</div>`;
1070
+ } else if (_requiresAcceleratorBackend(modelData)) {
1071
+ // Only show the "needs CUDA/ROCm" note when the host doesn't already have
1072
+ // one. With a visible CUDA/ROCm accelerator the note is noise — the user
1073
+ // can already serve the model and reading the warning on every row makes
1074
+ // the panel feel like everything's broken.
1075
+ const _sys = _hwfitCache?.system || {};
1076
+ const _backend = (_sys.backend || '').toLowerCase();
1077
+ const _hasGpuAccel = !!_sys.has_gpu && (_backend === 'cuda' || _backend === 'rocm');
1078
+ if (!_hasGpuAccel) {
1079
+ html += `<div class="hwfit-panel-note">This is a safetensors GPU-serving format. Use vLLM/SGLang with a visible CUDA/ROCm accelerator, or pick a GGUF download for llama.cpp/Ollama.</div>`;
1080
+ }
1081
+ }
1082
+ html += `</div>`;
1083
+
1084
+ row.insertAdjacentHTML('afterend', html);
1085
+ const panel = row.nextElementSibling;
1086
+
1087
+ // Wire download button
1088
+ const dlBtn = panel.querySelector('.hwfit-dl-btn');
1089
+ if (dlBtn) {
1090
+ dlBtn.addEventListener('click', () => {
1091
+ const host = _syncHostFromScanDropdown(); // host the user picked, passed explicitly
1092
+ if (backend === 'ollama') {
1093
+ _runPanelCmd(panel, _buildDownloadCmd(modelData, backend), { timeout: 0 });
1094
+ } else {
1095
+ _runModelDownload(panel, modelData, backend, host);
1096
+ }
1097
+ });
1098
+ }
1099
+
1100
+ // Wire quick-run button — download + launch with smart defaults
1101
+ const quickRunBtn = panel.querySelector('.hwfit-quickrun-btn');
1102
+ if (quickRunBtn) {
1103
+ quickRunBtn.addEventListener('click', async () => {
1104
+ const _qrHost = _syncHostFromScanDropdown();
1105
+
1106
+ // Don't serve a model that isn't downloaded yet. vLLM/SGLang would
1107
+ // background-pull at launch, so the serve task shows up as "running" in
1108
+ // the Running tab while nothing is actually served (and llama.cpp just
1109
+ // errors "No GGUF found"). The Configure button and the Serve tab already
1110
+ // gate on the cached-model list — mirror that here. When the model isn't
1111
+ // present, honor the button's "Download" half by kicking off the download
1112
+ // instead, then the user can Run again to serve once it finishes.
1113
+ const _short = modelData.name.split('/').pop();
1114
+ const _downloaded = _cachedModelIds && (
1115
+ _cachedModelIds.has(modelData.name)
1116
+ || [..._cachedModelIds].some(id => id === modelData.name || id.endsWith('/' + _short))
1117
+ );
1118
+ if (_cachedModelIds && !_downloaded) {
1119
+ uiModule.showToast('Model not downloaded yet — starting download. Run again to serve once it finishes.');
1120
+ if (backend === 'ollama') {
1121
+ _runPanelCmd(panel, _buildDownloadCmd(modelData, backend), { timeout: 0 });
1122
+ } else {
1123
+ _runModelDownload(panel, modelData, backend, _qrHost);
1124
+ }
1125
+ return;
1126
+ }
1127
+
1128
+ quickRunBtn.disabled = true;
1129
+ quickRunBtn.textContent = 'Starting...';
1130
+
1131
+ // Smart defaults based on hardware and model
1132
+ const system = _hwfitCache?.system || {};
1133
+ // Prefer the active homogeneous pool (the route sets active_group when a GPU
1134
+ // pool is selected). Its per-GPU VRAM + device indices are what we serve on —
1135
+ // vLLM can only tensor-parallel across identical GPUs, so we pin to one pool.
1136
+ const grp = system.active_group || null;
1137
+ const poolCount = (grp && grp.use_count) || system.gpu_count || 1;
1138
+ const gpuMem = (grp && grp.vram_each) || (system.gpu_vram_gb / (system.gpu_count || 1)) || 20;
1139
+ const modelVram = modelData.required_gb || 10;
1140
+
1141
+ // TP must be a power of two within the pool (plus the exact pool size) —
1142
+ // pick the smallest that fits the model in VRAM, else the whole pool.
1143
+ const _tpOpts = [1, 2, 4, 8, 16].filter(n => n <= poolCount);
1144
+ if (poolCount > 0 && !_tpOpts.includes(poolCount)) _tpOpts.push(poolCount);
1145
+ let tp = _tpOpts[_tpOpts.length - 1] || 1;
1146
+ for (const n of _tpOpts) { if (n * gpuMem >= modelVram) { tp = n; break; } }
1147
+
1148
+ // Pin to exactly this pool's first `tp` GPUs so vLLM can't reach across into
1149
+ // a mismatched pool. Respect a manual GPU pin (_envState.gpus) if the user set one.
1150
+ let cudaDevices = '';
1151
+ if (grp && Array.isArray(grp.indices)) cudaDevices = grp.indices.slice(0, tp).join(',');
1152
+ // Context: scale based on available VRAM headroom
1153
+ const headroom = (tp * gpuMem) - modelVram;
1154
+ let maxCtx = modelData.context_length || 8192;
1155
+ if (headroom < 4) maxCtx = Math.min(maxCtx, 4096);
1156
+ else if (headroom < 8) maxCtx = Math.min(maxCtx, 8192);
1157
+ else if (headroom < 16) maxCtx = Math.min(maxCtx, 16384);
1158
+ // GPU mem utilization
1159
+ const gpuUtil = modelVram / (tp * gpuMem) > 0.8 ? '0.95' : '0.90';
1160
+ // Tool parser
1161
+ const parser = _detectToolParser(modelData.name);
1162
+
1163
+ const host = _envState.remoteHost || '';
1164
+ const hostIp = host.includes('@') ? host.split('@').pop() : host;
1165
+ const port = '8000';
1166
+ const detected = _detectBackend(modelData);
1167
+ const runBackend = detected.backend || 'vllm';
1168
+
1169
+ // Build serve command
1170
+ let cmd = '';
1171
+ if (runBackend === 'sglang') {
1172
+ cmd = `python3 -m sglang.launch_server --model-path ${modelData.name} --host 0.0.0.0 --port ${port}`;
1173
+ if (tp > 1) cmd += ` --tp ${tp}`;
1174
+ cmd += ` --context-length ${maxCtx}`;
1175
+ cmd += ` --mem-fraction-static ${gpuUtil}`;
1176
+ cmd += ' --trust-remote-code';
1177
+ } else if (runBackend === 'llamacpp') {
1178
+ const dir = `"$HOME/.cache/huggingface/hub/models--${modelData.name.replace(/\//g, '--')}/snapshots"`;
1179
+ const ggufPath = `$({ find ${dir} -name '*-00001-of-*.gguf' 2>/dev/null | sort; find ${dir} -name '*.gguf' 2>/dev/null | sort; } | head -1)`;
1180
+ cmd = `MODEL_FILE=${ggufPath} && { [ -n "$MODEL_FILE" ] && [ -f "$MODEL_FILE" ]; } || { echo "ERROR: No GGUF found on this host. Download a GGUF quant or switch backend."; exit 1; } && llama-server --model "$MODEL_FILE" --host 0.0.0.0 --port 8080 -ngl 99 -c ${maxCtx} || python3 -m llama_cpp.server --model "$MODEL_FILE" --host 0.0.0.0 --port 8080 --n_gpu_layers 99 --n_ctx ${maxCtx}`;
1181
+ } else {
1182
+ cmd = `vllm serve ${modelData.name} --host 0.0.0.0 --port ${port}`;
1183
+ cmd += ` --tensor-parallel-size ${tp}`;
1184
+ cmd += ` --max-model-len ${maxCtx}`;
1185
+ cmd += ` --gpu-memory-utilization ${gpuUtil}`;
1186
+ cmd += ' --dtype auto';
1187
+ cmd += ' --enforce-eager';
1188
+ cmd += ' --trust-remote-code';
1189
+ cmd += ` --enable-auto-tool-choice --tool-call-parser ${parser}`;
1190
+ }
1191
+
1192
+ // Build env prefix
1193
+ let envPrefix = '';
1194
+ if (_envState.env === 'venv' && _envState.envPath) {
1195
+ const p = _envState.envPath;
1196
+ envPrefix = 'source ' + _shellQuote(p.endsWith('/bin/activate') ? p : p + '/bin/activate');
1197
+ } else if (_envState.env === 'conda' && _envState.envPath) {
1198
+ envPrefix = 'eval "$(conda shell.bash hook)" && conda activate ' + _shellQuote(_envState.envPath);
1199
+ }
1200
+
1201
+ // Launch via serve API. Field names must match the backend ServeRequest
1202
+ // schema (repo_id + cmd) — sending `command`/`model` failed Pydantic
1203
+ // validation (422), which is why Run silently did nothing.
1204
+ const _srv = (_envState.servers || []).find(s => s.host === host);
1205
+ const payload = {
1206
+ repo_id: modelData.name,
1207
+ cmd: cmd,
1208
+ remote_host: host || undefined,
1209
+ ssh_port: (_srv && _srv.port) || undefined,
1210
+ env_prefix: envPrefix || undefined,
1211
+ hf_token: _envState.hfToken || undefined,
1212
+ gpus: _envState.gpus || cudaDevices || undefined,
1213
+ platform: _envState.platform || undefined,
1214
+ };
1215
+
1216
+ try {
1217
+ const res = await fetch('/api/model/serve', {
1218
+ method: 'POST', credentials: 'same-origin',
1219
+ headers: { 'Content-Type': 'application/json' },
1220
+ body: JSON.stringify(payload),
1221
+ });
1222
+ const data = await res.json();
1223
+ if (data.ok) {
1224
+ const shortName = modelData.name.split('/').pop();
1225
+ _addTask(data.session_id, shortName, 'serve', { _cmd: cmd, model: modelData.name, backend: runBackend, remote_host: host });
1226
+ _renderRunningTab();
1227
+ uiModule.showToast(`Launching ${shortName}...`);
1228
+ // Switch to Running tab
1229
+ const runTab = document.querySelector('.cookbook-tab[data-backend="Running"]');
1230
+ if (runTab) runTab.click();
1231
+ } else {
1232
+ uiModule.showError('Launch failed: ' + (data.error || ''));
1233
+ }
1234
+ } catch (e) {
1235
+ uiModule.showError('Launch failed: ' + e.message);
1236
+ }
1237
+ quickRunBtn.disabled = false;
1238
+ quickRunBtn.textContent = 'Run';
1239
+ });
1240
+ }
1241
+
1242
+ // Wire configure button — open the model's Serve panel.
1243
+ const configBtn = panel.querySelector('.hwfit-serve-expand-btn');
1244
+ if (configBtn) {
1245
+ configBtn.addEventListener('click', async () => {
1246
+ const repo = modelData.name;
1247
+ const short = repo?.split('/').pop();
1248
+ // Use the same "downloaded" source as the dl-dot (_cachedModelIds), NOT a
1249
+ // DOM lookup for .hwfit-cached-item — those only exist on the Serve tab, so
1250
+ // from the What-Fits tab the old check always failed and falsely said
1251
+ // "download first" even for models that ARE downloaded.
1252
+ const downloaded = _cachedModelIds && (
1253
+ _cachedModelIds.has(repo)
1254
+ || [..._cachedModelIds].some(id => id === repo || id.endsWith('/' + short))
1255
+ );
1256
+ if (_cachedModelIds && !downloaded) {
1257
+ uiModule.showToast('Download the model first, then configure from Serve tab');
1258
+ return;
1259
+ }
1260
+ // Downloaded (or cache state unknown) — open the Serve panel, which switches
1261
+ // to the Serve tab, fetches the cached list, and expands this model's card.
1262
+ try {
1263
+ const { openServePanelForRepo } = await import('./cookbookServe.js');
1264
+ await openServePanelForRepo(repo);
1265
+ } catch (e) {
1266
+ uiModule.showToast('Could not open Serve: ' + (e && e.message ? e.message : e));
1267
+ }
1268
+ });
1269
+ }
1270
+
1271
+ }
1272
+
1273
+ export function _hwfitInit() {
1274
+ const uc = document.getElementById('hwfit-usecase');
1275
+ const sort = document.getElementById('hwfit-sort');
1276
+ const qpref = document.getElementById('hwfit-quant');
1277
+ const ctx = document.getElementById('hwfit-context');
1278
+ const ctxLabel = document.getElementById('hwfit-context-label');
1279
+ const search = document.getElementById('hwfit-search');
1280
+ const remote = document.getElementById('hwfit-host');
1281
+ _syncCtxControl();
1282
+ if (uc) uc.addEventListener('change', () => _hwfitFetch());
1283
+ if (sort) sort.addEventListener('change', () => _hwfitFetch());
1284
+ if (qpref) qpref.addEventListener('change', () => _hwfitFetch());
1285
+ // Engine filter is a pure client-side view filter over the already-fetched
1286
+ // list, so just re-render from cache instead of re-probing hardware.
1287
+ const engine = document.getElementById('hwfit-engine');
1288
+ if (engine) engine.addEventListener('change', () => {
1289
+ const list = document.getElementById('hwfit-list');
1290
+ if (list && _hwfitCache && Array.isArray(_hwfitCache.models)) {
1291
+ _hwfitRenderList(list, _applyEngineFilter(_hwfitCache.models));
1292
+ } else {
1293
+ _hwfitFetch();
1294
+ }
1295
+ });
1296
+ if (ctx && !ctx.dataset.bound) {
1297
+ ctx.dataset.bound = '1';
1298
+ ctx.addEventListener('input', () => {
1299
+ if (ctxLabel) ctxLabel.textContent = _ctxLabel(_ctxValue());
1300
+ });
1301
+ ctx.addEventListener('change', () => {
1302
+ const targetCtx = _ctxValue();
1303
+ try { localStorage.setItem(_CTX_KEY, String(targetCtx)); } catch {}
1304
+ // Ctx drag affects sort mode: a specific ctx target (anything < Max)
1305
+ // implies "what runs at this context length" — sort by VRAM ascending
1306
+ // so the cheapest-fitting models surface first. Dragging back to Max
1307
+ // releases the constraint → go back to the default score ranking.
1308
+ const sortSel = document.getElementById('hwfit-sort');
1309
+ if (sortSel) {
1310
+ if (targetCtx) {
1311
+ sortSel.value = 'vram';
1312
+ sortSel.dataset.reverse = '1'; // ascending = smallest VRAM first
1313
+ } else {
1314
+ sortSel.value = 'score';
1315
+ sortSel.dataset.reverse = '';
1316
+ }
1317
+ }
1318
+ _hwfitCache = null;
1319
+ _hwfitFetch();
1320
+ });
1321
+ }
1322
+ // Rescan — force a fresh hardware probe (bypasses the per-host cache).
1323
+ const rescan = document.getElementById('hwfit-rescan');
1324
+ if (rescan && !rescan.dataset.bound) {
1325
+ rescan.dataset.bound = '1';
1326
+ rescan.addEventListener('click', async () => {
1327
+ if (rescan.dataset.scanning) return; // ignore re-clicks mid-scan
1328
+ rescan.dataset.scanning = '1';
1329
+ const orig = rescan.innerHTML;
1330
+ rescan.disabled = true;
1331
+ rescan.style.opacity = '0.85';
1332
+ // Swap the ↻ glyph for a live whirlpool so the click feels responsive
1333
+ // during the (often slow) SSH hardware probe.
1334
+ const wp = spinnerModule.createWhirlpool(12);
1335
+ wp.element.style.marginRight = '4px';
1336
+ wp.element.style.position = 'relative';
1337
+ wp.element.style.top = '-2px'; // sit a touch higher, aligned with the label
1338
+ rescan.innerHTML = '';
1339
+ rescan.appendChild(wp.element);
1340
+ rescan.appendChild(document.createTextNode('RESCAN'));
1341
+ // Reset toggle state (no flicker — buttons stay until the fresh scan swaps them).
1342
+ _resetGpuToggleState();
1343
+ try {
1344
+ await _hwfitFetch(true);
1345
+ } finally {
1346
+ try { wp.destroy(); } catch {}
1347
+ rescan.innerHTML = orig;
1348
+ rescan.disabled = false;
1349
+ rescan.style.opacity = '';
1350
+ delete rescan.dataset.scanning;
1351
+ }
1352
+ });
1353
+ }
1354
+ if (search) search.addEventListener('input', () => {
1355
+ clearTimeout(_hwfitDebounce);
1356
+ _hwfitDebounce = setTimeout(() => _hwfitFetch(), 400);
1357
+ });
1358
+ // HF Token
1359
+ const hfToken = document.getElementById('hwfit-hftoken');
1360
+ if (hfToken) {
1361
+ hfToken.addEventListener('change', () => { _envState.hfToken = hfToken.value.trim(); _persistEnvState(); });
1362
+ hfToken.addEventListener('input', () => { _envState.hfToken = hfToken.value.trim(); });
1363
+ }
1364
+
1365
+ // Rebuild all server select dropdowns with current servers
1366
+ function _rebuildServerSelect() {
1367
+ const selectors = [
1368
+ document.getElementById('hwfit-server-select'),
1369
+ document.getElementById('hwfit-dl-server'),
1370
+ ];
1371
+ for (const sel of selectors) {
1372
+ if (!sel) continue;
1373
+ const currentVal = sel.value;
1374
+ let html = `<option value="local">Local</option>`;
1375
+ _envState.servers.forEach((s, i) => {
1376
+ if (!s.host) return;
1377
+ const label = s.name || s.host || `Server ${i + 1}`;
1378
+ html += `<option value="${i}">${uiModule.esc(label)}</option>`;
1379
+ });
1380
+ sel.innerHTML = html;
1381
+ sel.value = currentVal;
1382
+ }
1383
+ }
1384
+
1385
+ // Servers — sync changes, add, remove
1386
+ function _syncServers() {
1387
+ const entries = document.querySelectorAll('.cookbook-server-entry');
1388
+ _envState.servers = [];
1389
+ entries.forEach(entry => {
1390
+ const row = entry.querySelector('.cookbook-server-row');
1391
+ if (!row) return;
1392
+ const nameEl = row.querySelector('.cookbook-srv-name');
1393
+ const hostEl = row.querySelector('.cookbook-srv-host');
1394
+ const name = nameEl?.value.trim() || '';
1395
+ const host = (hostEl?.disabled || hostEl?.readOnly) ? '' : (hostEl?.value.trim() || '');
1396
+ const port = row.querySelector('.cookbook-srv-port')?.value.trim() || '';
1397
+ const env = row.querySelector('.cookbook-srv-env')?.value || 'none';
1398
+ const envPath = row.querySelector('.cookbook-srv-path')?.value.trim() || '';
1399
+ // Collect model directories from tags. Read the authoritative data-dir
1400
+ // attribute, not textContent \u2014 the tag now also holds a download-target
1401
+ // icon, and textContent would fold the icon/\u2716 glyph into the path.
1402
+ const dirTags = entry.querySelectorAll('.cookbook-modeldir-tag');
1403
+ const modelDirs = [];
1404
+ dirTags.forEach(tag => {
1405
+ const d = (tag.dataset.dir || '').replaceAll('\u2715', '').replaceAll('\u2716', '').trim();
1406
+ if (d) modelDirs.push(d);
1407
+ });
1408
+ if (!modelDirs.length) modelDirs.push('~/.cache/huggingface/hub');
1409
+ // Which dir (if any) is flagged as the download target. '' = HF cache.
1410
+ const dlEl = entry.querySelector('.cookbook-modeldir-dl.active');
1411
+ const downloadDir = dlEl ? (dlEl.dataset.dlDir || '') : '';
1412
+ const platform = entry.dataset.platform || '';
1413
+ _envState.servers.push({ name, host: host || '', port, env, envPath, modelDirs, modelDir: modelDirs.filter(d => d !== '~/.cache/huggingface/hub')[0] || modelDirs[0], downloadDir, platform });
1414
+ });
1415
+ // Do NOT auto-change the selected host here. _syncServers can run while the
1416
+ // servers DOM is mid-render — host fields that are disabled/readonly read as
1417
+ // empty (see above), which made the rebuilt list temporarily miss the
1418
+ // selected server. The old code then "fell back" to the first remote server
1419
+ // and persisted it, silently flipping the active host even though the
1420
+ // dropdown still showed zero. The user's selection must only change via
1421
+ // an explicit dropdown pick. Here we just refresh env/path if we can match
1422
+ // the current host; otherwise leave remoteHost untouched.
1423
+ const sel = _envState.servers.find(s => s.host === _envState.remoteHost);
1424
+ if (sel) { _envState.env = sel.env; _envState.envPath = sel.envPath; }
1425
+ _persistEnvState();
1426
+ }
1427
+
1428
+ async function _testServerConnection(entry) {
1429
+ const host = entry.querySelector('.cookbook-srv-host')?.value?.trim();
1430
+ const port = entry.querySelector('.cookbook-srv-port')?.value?.trim() || '';
1431
+ const dot = entry.querySelector('.cookbook-srv-status');
1432
+ const msg = entry.querySelector('.cookbook-srv-test-msg');
1433
+ const setMsg = (text, color = '') => {
1434
+ if (!msg) return;
1435
+ msg.textContent = text || '';
1436
+ msg.title = text || '';
1437
+ msg.style.color = color || '';
1438
+ msg.style.opacity = text ? '0.75' : '0.55';
1439
+ };
1440
+ if (!dot) return;
1441
+ if (!host) {
1442
+ dot.className = 'cookbook-srv-status';
1443
+ dot.title = 'Enter user@host to test';
1444
+ setMsg('');
1445
+ return;
1446
+ }
1447
+ dot.className = 'cookbook-srv-status testing';
1448
+ dot.title = 'Testing SSH…';
1449
+ setMsg('Testing SSH...');
1450
+ const pf = port && port !== '22' ? `-p ${port} ` : '';
1451
+ const cmd = `ssh -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new ${pf}${host} "echo ok"`;
1452
+ const t0 = Date.now();
1453
+ try {
1454
+ const res = await fetch('/api/shell/exec', {
1455
+ method: 'POST', credentials: 'same-origin',
1456
+ headers: { 'Content-Type': 'application/json' },
1457
+ body: JSON.stringify({ command: cmd, timeout: 8 }),
1458
+ });
1459
+ const data = await res.json();
1460
+ const ms = Date.now() - t0;
1461
+ const out = (data.stdout || '').trim();
1462
+ if (data.exit_code === 0 && out.startsWith('ok')) {
1463
+ dot.className = 'cookbook-srv-status ok';
1464
+ dot.title = `Reachable · ${ms} ms · use Dependencies to check tmux/HF setup`;
1465
+ setMsg(`Connected · ${ms} ms`, 'var(--green,#50fa7b)');
1466
+ } else {
1467
+ dot.className = 'cookbook-srv-status fail';
1468
+ const err = (data.stderr || data.stdout || `exit ${data.exit_code}`).toString().trim().slice(0, 240);
1469
+ dot.title = `SSH failed: ${err}`;
1470
+ setMsg(`Failed · ${err}`, 'var(--red,#e06c75)');
1471
+ }
1472
+ } catch (e) {
1473
+ dot.className = 'cookbook-srv-status fail';
1474
+ dot.title = `Test failed: ${e.message || e}`;
1475
+ setMsg(`Failed · ${e.message || e}`, 'var(--red,#e06c75)');
1476
+ }
1477
+ }
1478
+
1479
+ function _singleQuote(value) {
1480
+ return `'${String(value || '').replace(/'/g, `'\"'\"'`)}'`;
1481
+ }
1482
+
1483
+ function _serverKeyCommand(host, port, publicKey) {
1484
+ const pf = port && port !== '22' ? `-p ${port} ` : '';
1485
+ const remote = [
1486
+ `KEY=${_singleQuote(publicKey)}`,
1487
+ 'mkdir -p ~/.ssh',
1488
+ 'chmod 700 ~/.ssh',
1489
+ 'touch ~/.ssh/authorized_keys',
1490
+ '(grep -qxF "$KEY" ~/.ssh/authorized_keys || printf "%s\\n" "$KEY" >> ~/.ssh/authorized_keys)',
1491
+ 'chmod 600 ~/.ssh/authorized_keys',
1492
+ ].join(' && ');
1493
+ return `ssh -o StrictHostKeyChecking=accept-new ${pf}${host} ${_singleQuote(remote)}`;
1494
+ }
1495
+
1496
+ async function _fetchCookbookSshKey(generate = false) {
1497
+ const res = await fetch('/api/cookbook/ssh-key', {
1498
+ method: generate ? 'POST' : 'GET',
1499
+ credentials: 'same-origin',
1500
+ });
1501
+ const data = await res.json();
1502
+ if (generate && !data.ok) throw new Error(data.error || 'Failed to generate SSH key');
1503
+ return (data.public_key || '').trim();
1504
+ }
1505
+
1506
+ async function _populateServerKeyPanel(entry, generate = false) {
1507
+ const panel = entry.querySelector('.cookbook-server-key-panel');
1508
+ const cmdBox = entry.querySelector('.cookbook-server-key-command');
1509
+ const copyBtn = entry.querySelector('.cookbook-server-key-copy');
1510
+ const genBtn = entry.querySelector('.cookbook-server-key-gen');
1511
+ if (!panel || !cmdBox) return;
1512
+ const host = entry.querySelector('.cookbook-srv-host')?.value?.trim() || '';
1513
+ const port = entry.querySelector('.cookbook-srv-port')?.value?.trim() || '';
1514
+ if (!host || !host.includes('@')) {
1515
+ cmdBox.value = 'Enter the server as user@host first.';
1516
+ if (copyBtn) copyBtn.disabled = true;
1517
+ return;
1518
+ }
1519
+ if (!/^[A-Za-z0-9._~-]+@[A-Za-z0-9._:-]+$/.test(host) || (port && !/^\d{1,5}$/.test(port))) {
1520
+ cmdBox.value = 'Use a plain SSH target like user@host and an optional numeric port.';
1521
+ if (copyBtn) copyBtn.disabled = true;
1522
+ return;
1523
+ }
1524
+ if (genBtn) {
1525
+ genBtn.disabled = true;
1526
+ genBtn.textContent = generate ? 'Generating...' : 'Loading...';
1527
+ }
1528
+ try {
1529
+ let publicKey = await _fetchCookbookSshKey(generate);
1530
+ if (!publicKey && !generate) publicKey = await _fetchCookbookSshKey(true);
1531
+ cmdBox.value = _serverKeyCommand(host, port, publicKey);
1532
+ if (copyBtn) copyBtn.disabled = false;
1533
+ if (genBtn) genBtn.textContent = 'Key ready';
1534
+ } catch (e) {
1535
+ cmdBox.value = e.message || String(e);
1536
+ if (copyBtn) copyBtn.disabled = true;
1537
+ if (genBtn) genBtn.textContent = 'Generate key';
1538
+ } finally {
1539
+ if (genBtn) genBtn.disabled = false;
1540
+ }
1541
+ }
1542
+
1543
+ function _wireServerEntry(entry) {
1544
+ // Idempotency guard: _hwfitInit() can run more than once per panel open,
1545
+ // and re-wiring would stack duplicate listeners on every control (e.g. the
1546
+ // model-dir "+" button would add two tags per click, change handlers fire
1547
+ // twice). Bind each entry exactly once.
1548
+ if (entry.dataset.wired) return;
1549
+ entry.dataset.wired = '1';
1550
+ // Inject the status dot once if missing — into the card header next to the
1551
+ // server name (was previously the first child of the input row).
1552
+ const row = entry.querySelector('.cookbook-server-row');
1553
+ const titleEl = entry.querySelector('.cookbook-server-title');
1554
+ if (!entry.querySelector('.cookbook-srv-status')) {
1555
+ const dot = document.createElement('span');
1556
+ dot.className = 'cookbook-srv-status';
1557
+ dot.title = 'Click to test SSH';
1558
+ dot.addEventListener('click', (e) => { e.stopPropagation(); _testServerConnection(entry); });
1559
+ if (titleEl) titleEl.insertBefore(dot, titleEl.firstChild);
1560
+ else if (row) row.insertBefore(dot, row.firstChild);
1561
+ // The local server (readonly host) is always reachable — show it green
1562
+ // without an SSH test.
1563
+ const _hostEl = entry.querySelector('.cookbook-srv-host');
1564
+ if (_hostEl && (_hostEl.readOnly || _hostEl.disabled)) {
1565
+ dot.className = 'cookbook-srv-status ok';
1566
+ dot.title = 'Local (this machine)';
1567
+ }
1568
+ }
1569
+ const checkBtn = entry.querySelector('.cookbook-server-check-btn');
1570
+ if (checkBtn && !checkBtn.dataset.bound) {
1571
+ checkBtn.dataset.bound = '1';
1572
+ checkBtn.addEventListener('click', (e) => {
1573
+ e.stopPropagation();
1574
+ _testServerConnection(entry);
1575
+ });
1576
+ }
1577
+ // Default-server toggle: exclusive checkmark in the entry title. The chosen
1578
+ // server is what Cookbook lands on (all dropdowns) on the next open.
1579
+ const _defBtn = entry.querySelector('.cookbook-srv-default');
1580
+ if (_defBtn && !_defBtn.dataset.bound) {
1581
+ _defBtn.dataset.bound = '1';
1582
+ _defBtn.addEventListener('click', (e) => {
1583
+ e.stopPropagation();
1584
+ const key = _defBtn.dataset.srvKey || '';
1585
+ // Toggle off if it's already the default; otherwise make it the default.
1586
+ _envState.defaultServer = (_envState.defaultServer === key) ? '' : key;
1587
+ _persistEnvState();
1588
+ document.querySelectorAll('.cookbook-srv-default').forEach(b => {
1589
+ const on = !!_envState.defaultServer && b.dataset.srvKey === _envState.defaultServer;
1590
+ b.classList.toggle('active', on);
1591
+ // Keep the "default" label after the icon (don't overwrite it).
1592
+ b.innerHTML = (on ? _MODELDIR_CHECK_ON : _MODELDIR_CHECK_OFF) + '<span class="cookbook-srv-default-label">default</span>';
1593
+ b.title = on ? 'Default server — Cookbook opens here' : 'Make this the default server';
1594
+ });
1595
+ // Apply immediately so the dropdowns reflect it without reopening
1596
+ // (inline — _applyServerSelection lives in cookbook.js and isn't imported here).
1597
+ const _dk = _envState.defaultServer;
1598
+ if (_dk) {
1599
+ if (_dk === 'local') { _envState.remoteHost = ''; _envState.env = 'none'; _envState.envPath = ''; _envState.platform = ''; }
1600
+ else { const _s = (_envState.servers || []).find(x => x.host === _dk); if (_s) { _envState.remoteHost = _s.host; _envState.env = _s.env || 'none'; _envState.envPath = _s.envPath || ''; _envState.platform = _s.platform || ''; } }
1601
+ _persistEnvState();
1602
+ document.querySelectorAll('#hwfit-server-select, #hwfit-dl-server, #hwfit-cache-server, #hwfit-deps-server').forEach(sel => {
1603
+ if (sel && sel.tagName === 'SELECT') sel.value = _envState.remoteHost || 'local';
1604
+ });
1605
+ }
1606
+ uiModule.showToast(_envState.defaultServer
1607
+ ? 'Default server: ' + (_envState.defaultServer === 'local' ? 'Local' : _envState.defaultServer)
1608
+ : 'Default server cleared');
1609
+ });
1610
+ }
1611
+ const keyBtn = entry.querySelector('.cookbook-server-key-btn');
1612
+ if (keyBtn && !keyBtn.dataset.bound) {
1613
+ keyBtn.dataset.bound = '1';
1614
+ keyBtn.addEventListener('click', async () => {
1615
+ const panel = entry.querySelector('.cookbook-server-key-panel');
1616
+ if (!panel) return;
1617
+ const willOpen = panel.classList.contains('hidden');
1618
+ panel.classList.toggle('hidden', !willOpen);
1619
+ panel.style.display = willOpen ? 'flex' : '';
1620
+ if (willOpen) await _populateServerKeyPanel(entry, false);
1621
+ });
1622
+ }
1623
+ const keyGenBtn = entry.querySelector('.cookbook-server-key-gen');
1624
+ if (keyGenBtn && !keyGenBtn.dataset.bound) {
1625
+ keyGenBtn.dataset.bound = '1';
1626
+ keyGenBtn.addEventListener('click', () => _populateServerKeyPanel(entry, true));
1627
+ }
1628
+ const keyCopyBtn = entry.querySelector('.cookbook-server-key-copy');
1629
+ if (keyCopyBtn && !keyCopyBtn.dataset.bound) {
1630
+ keyCopyBtn.dataset.bound = '1';
1631
+ keyCopyBtn.addEventListener('click', async () => {
1632
+ const cmd = entry.querySelector('.cookbook-server-key-command')?.value?.trim() || '';
1633
+ if (!cmd || cmd.startsWith('Enter ')) return;
1634
+ await _copyText(cmd);
1635
+ uiModule.showToast('SSH setup command copied');
1636
+ });
1637
+ }
1638
+ entry.querySelectorAll('input, select').forEach(el => {
1639
+ el.addEventListener('change', () => {
1640
+ const selectedBefore = _envState.remoteHost || '';
1641
+ const entryHost = entry.querySelector('.cookbook-srv-host')?.value?.trim() || '';
1642
+ _syncServers();
1643
+ _rebuildServerSelect();
1644
+ if (selectedBefore && selectedBefore === entryHost) {
1645
+ _hwfitCache = null;
1646
+ _hwfitFetch();
1647
+ }
1648
+ if (!entry.querySelector('.cookbook-server-key-panel')?.classList.contains('hidden')) {
1649
+ _populateServerKeyPanel(entry, false);
1650
+ }
1651
+ });
1652
+ });
1653
+ // Auto-test when host or port blur
1654
+ entry.querySelectorAll('.cookbook-srv-host, .cookbook-srv-port').forEach(el => {
1655
+ el.addEventListener('blur', () => _testServerConnection(entry));
1656
+ });
1657
+ // Initial test for pre-filled rows (existing servers on tab load)
1658
+ if (entry.querySelector('.cookbook-srv-host')?.value?.trim() && !entry.dataset.tested) {
1659
+ entry.dataset.tested = '1';
1660
+ _testServerConnection(entry);
1661
+ }
1662
+ // Cancel button on a brand-new server entry: discard it (no confirm — it's
1663
+ // unsaved) and re-sync so the dropped blank server doesn't linger.
1664
+ const cancelBtn = entry.querySelector('.cookbook-server-cancel-btn');
1665
+ if (cancelBtn && !cancelBtn.dataset.bound) {
1666
+ cancelBtn.dataset.bound = '1';
1667
+ cancelBtn.addEventListener('click', () => {
1668
+ entry.remove();
1669
+ _syncServers();
1670
+ _rebuildServerSelect();
1671
+ _hwfitCache = null;
1672
+ _hwfitFetch();
1673
+ });
1674
+ }
1675
+ // Save button on a brand-new server entry: persist + confirm with a check.
1676
+ const saveBtn = entry.querySelector('.cookbook-server-save-btn');
1677
+ if (saveBtn && !saveBtn.dataset.bound) {
1678
+ saveBtn.dataset.bound = '1';
1679
+ saveBtn.addEventListener('click', () => {
1680
+ _syncServers();
1681
+ _rebuildServerSelect();
1682
+ saveBtn.classList.add('saved');
1683
+ saveBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#50fa7b" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;flex-shrink:0;"><polyline points="20 6 9 17 4 12"/></svg>Saved';
1684
+ });
1685
+ }
1686
+ const rmBtn = entry.querySelector('.cookbook-server-rm');
1687
+ if (rmBtn) rmBtn.addEventListener('click', async () => {
1688
+ const name = entry.querySelector('.cookbook-srv-name')?.value?.trim()
1689
+ || entry.querySelector('.cookbook-srv-host')?.value?.trim()
1690
+ || 'this server';
1691
+ let ok = true;
1692
+ if (uiModule && uiModule.styledConfirm) {
1693
+ ok = await uiModule.styledConfirm(`Remove "${name}"?`, { confirmText: 'Remove', danger: true });
1694
+ } else {
1695
+ ok = confirm(`Remove "${name}"?`);
1696
+ }
1697
+ if (!ok) return;
1698
+ entry.remove();
1699
+ _syncServers();
1700
+ _rebuildServerSelect();
1701
+ _hwfitCache = null;
1702
+ _hwfitFetch();
1703
+ });
1704
+ // Setup is owned by cookbook.js's delegated handler (Settings behavior:
1705
+ // select server + open the Dependencies tab). Don't bind the inline-install
1706
+ // handler here too, or one click would do two conflicting things.
1707
+ const setupBtn = null;
1708
+ if (setupBtn) {
1709
+ setupBtn.addEventListener('click', async () => {
1710
+ const host = entry.querySelector('.cookbook-srv-host')?.value?.trim();
1711
+ const port = entry.querySelector('.cookbook-srv-port')?.value?.trim() || '';
1712
+ if (!host) return;
1713
+ setupBtn.disabled = true;
1714
+ const origText = setupBtn.textContent;
1715
+ setupBtn.textContent = 'Installing...';
1716
+ try {
1717
+ const res = await fetch('/api/cookbook/setup', {
1718
+ method: 'POST', credentials: 'same-origin',
1719
+ headers: { 'Content-Type': 'application/json' },
1720
+ body: JSON.stringify({ host, ssh_port: port || undefined }),
1721
+ });
1722
+ const data = await res.json();
1723
+ if (data.ok) {
1724
+ setupBtn.textContent = '\u2713 Done';
1725
+ setupBtn.style.color = '#50fa7b';
1726
+ uiModule.showToast(`Setup complete (${data.platform})`);
1727
+ // Store detected platform on the server entry
1728
+ if (data.platform) {
1729
+ entry.dataset.platform = data.platform;
1730
+ _syncServers();
1731
+ // Show platform badge
1732
+ const existingBadge = entry.querySelector('.cookbook-platform-badge');
1733
+ if (existingBadge) existingBadge.remove();
1734
+ const badge = document.createElement('span');
1735
+ badge.className = 'cookbook-platform-badge';
1736
+ badge.style.cssText = 'font-size:8px;padding:1px 5px;border-radius:3px;border:1px solid ' + (data.platform === 'windows' ? 'var(--cyan,#56b6c2)' : 'var(--green,#98c379)') + ';color:' + (data.platform === 'windows' ? 'var(--cyan,#56b6c2)' : 'var(--green,#98c379)') + ';opacity:0.7;white-space:nowrap;flex-shrink:0;';
1737
+ badge.textContent = data.platform;
1738
+ setupBtn.parentNode.insertBefore(badge, setupBtn);
1739
+ }
1740
+ // Auto-set Termux model dir
1741
+ if (data.platform === 'termux') {
1742
+ const container = entry.querySelector('.cookbook-modeldirs');
1743
+ if (container) {
1744
+ const existing = [...container.querySelectorAll('.cookbook-modeldir-tag')].map(t => t.textContent.replace('\u2716', '').replace('\u2715', '').trim());
1745
+ const termuxDir = '/data/data/com.termux/files/home/models';
1746
+ if (!existing.includes(termuxDir)) {
1747
+ const tag = document.createElement('span');
1748
+ tag.className = 'cookbook-modeldir-tag';
1749
+ tag.dataset.dirIdx = existing.length;
1750
+ tag.innerHTML = `${uiModule.esc(termuxDir)} <span class="cookbook-modeldir-rm" title="Remove">\u2715</span>`;
1751
+ tag.querySelector('.cookbook-modeldir-rm').addEventListener('click', () => { tag.remove(); _syncServers(); });
1752
+ const addBtn = container.querySelector('.cookbook-modeldir-add');
1753
+ if (addBtn) container.insertBefore(tag, addBtn);
1754
+ else container.appendChild(tag);
1755
+ _syncServers();
1756
+ }
1757
+ }
1758
+ }
1759
+ } else {
1760
+ setupBtn.textContent = 'Failed';
1761
+ setupBtn.style.color = 'var(--red)';
1762
+ uiModule.showError(data.error || data.output || 'Setup failed');
1763
+ }
1764
+ } catch (e) {
1765
+ setupBtn.textContent = 'Error';
1766
+ setupBtn.style.color = 'var(--red)';
1767
+ uiModule.showError(e.message);
1768
+ }
1769
+ setTimeout(() => { setupBtn.disabled = false; setupBtn.textContent = origText; setupBtn.style.color = ''; }, 3000);
1770
+ });
1771
+ }
1772
+ // Model dir add/remove
1773
+ const addDirBtn = entry.querySelector('.cookbook-modeldir-add');
1774
+ if (addDirBtn) addDirBtn.addEventListener('click', () => {
1775
+ const raw = prompt('Model directory path:', '/data/models');
1776
+ if (!raw) return;
1777
+ const dir = raw.replaceAll('\u2715', '').replaceAll('\u2716', '').trim();
1778
+ if (!dir) return;
1779
+ // Don't add duplicates
1780
+ const existing = [...entry.querySelectorAll('.cookbook-modeldir-tag')].some(t => (t.dataset.dir || t.textContent.trim()) === dir);
1781
+ if (existing) return;
1782
+ const container = entry.querySelector('.cookbook-modeldirs');
1783
+ const tag = document.createElement('span');
1784
+ tag.className = 'cookbook-modeldir-tag';
1785
+ tag.dataset.dirIdx = container.querySelectorAll('.cookbook-modeldir-tag').length;
1786
+ tag.dataset.dir = dir;
1787
+ tag.innerHTML = `<span class="cookbook-modeldir-dl" title="Send downloads here" data-dl-dir="${uiModule.esc(dir)}">${_MODELDIR_CHECK_OFF}</span> ${uiModule.esc(dir)} <span class="cookbook-modeldir-rm" title="Remove">\u2716</span>`;
1788
+ tag.querySelector('.cookbook-modeldir-rm').addEventListener('click', () => { tag.remove(); _syncServers(); });
1789
+ _wireModelDirTarget(entry, tag.querySelector('.cookbook-modeldir-dl'));
1790
+ container.insertBefore(tag, addDirBtn);
1791
+ _syncServers();
1792
+ });
1793
+ entry.querySelectorAll('.cookbook-modeldir-rm').forEach(rm => {
1794
+ rm.addEventListener('click', () => { rm.closest('.cookbook-modeldir-tag').remove(); _syncServers(); });
1795
+ });
1796
+ // Download-target toggles: clicking one makes that dir the sole target for
1797
+ // this server (or the default HF cache if it's the default dir).
1798
+ entry.querySelectorAll('.cookbook-modeldir-dl').forEach(dl => _wireModelDirTarget(entry, dl));
1799
+ }
1800
+
1801
+ // Mark a model-dir tag as this server's download target (exclusive), then
1802
+ // persist. Clicking ANYWHERE on the tag (not just the check) selects it \u2014
1803
+ // except the remove \u2716, which has its own handler.
1804
+ function _wireModelDirTarget(entry, dlEl) {
1805
+ if (!dlEl) return;
1806
+ const tag = dlEl.closest('.cookbook-modeldir-tag');
1807
+ if (!tag || tag.dataset.dlBound) return;
1808
+ tag.dataset.dlBound = '1';
1809
+ tag.style.cursor = 'pointer';
1810
+ tag.addEventListener('click', (e) => {
1811
+ if (e.target.closest('.cookbook-modeldir-rm')) return; // remove handled elsewhere
1812
+ e.stopPropagation();
1813
+ entry.querySelectorAll('.cookbook-modeldir-dl').forEach(d => {
1814
+ d.classList.remove('active');
1815
+ d.innerHTML = _MODELDIR_CHECK_OFF; // uncheck the others
1816
+ d.closest('.cookbook-modeldir-tag')?.classList.remove('cookbook-modeldir-target');
1817
+ d.title = 'Send downloads here';
1818
+ });
1819
+ dlEl.classList.add('active');
1820
+ dlEl.innerHTML = _MODELDIR_CHECK_ON; // check the chosen one
1821
+ tag.classList.add('cookbook-modeldir-target');
1822
+ dlEl.title = 'Downloads go here';
1823
+ _syncServers();
1824
+ uiModule.showToast((dlEl.dataset.dlDir ? 'Downloads \u2192 ' + dlEl.dataset.dlDir : 'Downloads \u2192 default HF cache'));
1825
+ });
1826
+ }
1827
+
1828
+ document.querySelectorAll('.cookbook-server-entry').forEach(_wireServerEntry);
1829
+
1830
+ const addBtn = document.getElementById('cookbook-server-add');
1831
+ if (addBtn && !addBtn.dataset.bound) {
1832
+ addBtn.dataset.bound = '1';
1833
+ addBtn.addEventListener('click', () => {
1834
+ const list = document.getElementById('cookbook-servers-list');
1835
+ if (!list) return;
1836
+ const idx = list.children.length;
1837
+ // Build the new entry with the SAME template as existing servers (Model
1838
+ // Directory header, default checkmark, platform icon) \u2014 isNew swaps the
1839
+ // delete button for a Save button. forceRemote keeps it editable.
1840
+ const blank = { host: '', name: '', port: '', env: 'none', envPath: '', platform: '', modelDirs: ['~/.cache/huggingface/hub'] };
1841
+ const wrap = document.createElement('div');
1842
+ wrap.innerHTML = _serverEntryHtml(blank, idx, _envState.defaultServer || '', true, true);
1843
+ const entry = wrap.firstElementChild;
1844
+ list.appendChild(entry);
1845
+ _wireServerEntry(entry);
1846
+ _syncServers();
1847
+ // Also refresh the server select dropdown
1848
+ _rebuildServerSelect();
1849
+ entry.querySelector('.cookbook-srv-host')?.focus();
1850
+ });
1851
+ }
1852
+
1853
+ // Server selector dropdown
1854
+ const serverSelect = document.getElementById('hwfit-server-select');
1855
+ if (serverSelect && !serverSelect.dataset.bound) {
1856
+ serverSelect.dataset.bound = '1';
1857
+ serverSelect.addEventListener('change', () => {
1858
+ const val = serverSelect.value;
1859
+ if (val === 'local') {
1860
+ _envState.remoteHost = '';
1861
+ _envState.env = 'none';
1862
+ _envState.envPath = '';
1863
+ } else {
1864
+ const s = _serverByVal(val);
1865
+ if (s) {
1866
+ _envState.remoteHost = s.host;
1867
+ _envState.env = s.env;
1868
+ _envState.envPath = s.envPath;
1869
+ }
1870
+ }
1871
+ _persistEnvState();
1872
+ // Keep the other server dropdowns (Download / Cache / Deps) in sync. The
1873
+ // download-input button reads #hwfit-dl-server *directly*, so without this
1874
+ // it kept its old value and downloads went to the wrong host even
1875
+ // though the scan here correctly switched to the selected server.
1876
+ // Option values are host strings now ('local' for the local box).
1877
+ document.querySelectorAll('#hwfit-dl-server, #hwfit-cache-server, #hwfit-deps-server').forEach(sel => {
1878
+ if (!sel || sel.tagName !== 'SELECT') return;
1879
+ sel.value = _envState.remoteHost || 'local';
1880
+ });
1881
+ _hwfitCache = null;
1882
+ // Reset GPU-toggle state (no flicker) so the new server's hardware re-renders.
1883
+ _resetGpuToggleState();
1884
+ _hwfitFetch();
1885
+ });
1886
+ }
1887
+
1888
+ }