agent-orcha 0.0.5 → 0.0.8

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 (404) hide show
  1. package/README.md +250 -1275
  2. package/dist/lib/agents/agent-executor.d.ts +4 -2
  3. package/dist/lib/agents/agent-executor.d.ts.map +1 -1
  4. package/dist/lib/agents/agent-executor.js +85 -53
  5. package/dist/lib/agents/agent-executor.js.map +1 -1
  6. package/dist/lib/agents/agent-loader.d.ts +3 -0
  7. package/dist/lib/agents/agent-loader.d.ts.map +1 -1
  8. package/dist/lib/agents/agent-loader.js +10 -1
  9. package/dist/lib/agents/agent-loader.js.map +1 -1
  10. package/dist/lib/agents/react-loop.d.ts.map +1 -1
  11. package/dist/lib/agents/react-loop.js +207 -142
  12. package/dist/lib/agents/react-loop.js.map +1 -1
  13. package/dist/lib/agents/types.d.ts +181 -18
  14. package/dist/lib/agents/types.d.ts.map +1 -1
  15. package/dist/lib/agents/types.js +18 -2
  16. package/dist/lib/agents/types.js.map +1 -1
  17. package/dist/lib/functions/function-loader.d.ts +2 -0
  18. package/dist/lib/functions/function-loader.d.ts.map +1 -1
  19. package/dist/lib/functions/function-loader.js +10 -0
  20. package/dist/lib/functions/function-loader.js.map +1 -1
  21. package/dist/lib/functions/simple-function-wrapper.js +3 -3
  22. package/dist/lib/functions/simple-function-wrapper.js.map +1 -1
  23. package/dist/lib/integrations/email.d.ts +38 -0
  24. package/dist/lib/integrations/email.d.ts.map +1 -0
  25. package/dist/lib/integrations/email.js +249 -0
  26. package/dist/lib/integrations/email.js.map +1 -0
  27. package/dist/lib/integrations/integration-manager.d.ts +5 -0
  28. package/dist/lib/integrations/integration-manager.d.ts.map +1 -1
  29. package/dist/lib/integrations/integration-manager.js +53 -3
  30. package/dist/lib/integrations/integration-manager.js.map +1 -1
  31. package/dist/lib/integrations/types.d.ts +187 -4
  32. package/dist/lib/integrations/types.d.ts.map +1 -1
  33. package/dist/lib/integrations/types.js +24 -1
  34. package/dist/lib/integrations/types.js.map +1 -1
  35. package/dist/lib/knowledge/knowledge-store.d.ts +7 -1
  36. package/dist/lib/knowledge/knowledge-store.d.ts.map +1 -1
  37. package/dist/lib/knowledge/knowledge-store.js +96 -8
  38. package/dist/lib/knowledge/knowledge-store.js.map +1 -1
  39. package/dist/lib/knowledge/loaders/file-loaders.d.ts +8 -3
  40. package/dist/lib/knowledge/loaders/file-loaders.d.ts.map +1 -1
  41. package/dist/lib/knowledge/loaders/file-loaders.js +96 -75
  42. package/dist/lib/knowledge/loaders/file-loaders.js.map +1 -1
  43. package/dist/lib/knowledge/loaders/web-loader.d.ts +12 -3
  44. package/dist/lib/knowledge/loaders/web-loader.d.ts.map +1 -1
  45. package/dist/lib/knowledge/loaders/web-loader.js +56 -22
  46. package/dist/lib/knowledge/loaders/web-loader.js.map +1 -1
  47. package/dist/lib/knowledge/sqlite-store.d.ts.map +1 -1
  48. package/dist/lib/knowledge/sqlite-store.js +19 -10
  49. package/dist/lib/knowledge/sqlite-store.js.map +1 -1
  50. package/dist/lib/knowledge/types.d.ts +69 -33
  51. package/dist/lib/knowledge/types.d.ts.map +1 -1
  52. package/dist/lib/knowledge/types.js +18 -3
  53. package/dist/lib/knowledge/types.js.map +1 -1
  54. package/dist/lib/llm/index.d.ts +1 -1
  55. package/dist/lib/llm/index.d.ts.map +1 -1
  56. package/dist/lib/llm/index.js +1 -1
  57. package/dist/lib/llm/index.js.map +1 -1
  58. package/dist/lib/llm/llm-call-logger.d.ts +3 -1
  59. package/dist/lib/llm/llm-call-logger.d.ts.map +1 -1
  60. package/dist/lib/llm/llm-call-logger.js +31 -26
  61. package/dist/lib/llm/llm-call-logger.js.map +1 -1
  62. package/dist/lib/llm/llm-config.d.ts +59 -8
  63. package/dist/lib/llm/llm-config.d.ts.map +1 -1
  64. package/dist/lib/llm/llm-config.js +163 -17
  65. package/dist/lib/llm/llm-config.js.map +1 -1
  66. package/dist/lib/llm/llm-factory.d.ts +1 -2
  67. package/dist/lib/llm/llm-factory.d.ts.map +1 -1
  68. package/dist/lib/llm/llm-factory.js +44 -8
  69. package/dist/lib/llm/llm-factory.js.map +1 -1
  70. package/dist/lib/llm/providers/anthropic-chat-model.d.ts +5 -1
  71. package/dist/lib/llm/providers/anthropic-chat-model.d.ts.map +1 -1
  72. package/dist/lib/llm/providers/anthropic-chat-model.js +118 -42
  73. package/dist/lib/llm/providers/anthropic-chat-model.js.map +1 -1
  74. package/dist/lib/llm/providers/gemini-chat-model.d.ts +3 -2
  75. package/dist/lib/llm/providers/gemini-chat-model.d.ts.map +1 -1
  76. package/dist/lib/llm/providers/gemini-chat-model.js +83 -24
  77. package/dist/lib/llm/providers/gemini-chat-model.js.map +1 -1
  78. package/dist/lib/llm/providers/openai-chat-model.d.ts +20 -1
  79. package/dist/lib/llm/providers/openai-chat-model.d.ts.map +1 -1
  80. package/dist/lib/llm/providers/openai-chat-model.js +265 -32
  81. package/dist/lib/llm/providers/openai-chat-model.js.map +1 -1
  82. package/dist/lib/llm/providers/openai-embeddings.d.ts.map +1 -1
  83. package/dist/lib/llm/providers/openai-embeddings.js +41 -10
  84. package/dist/lib/llm/providers/openai-embeddings.js.map +1 -1
  85. package/dist/lib/local-llm/binary-manager.d.ts +66 -0
  86. package/dist/lib/local-llm/binary-manager.d.ts.map +1 -0
  87. package/dist/lib/local-llm/binary-manager.js +441 -0
  88. package/dist/lib/local-llm/binary-manager.js.map +1 -0
  89. package/dist/lib/local-llm/engine-interface.d.ts +47 -0
  90. package/dist/lib/local-llm/engine-interface.d.ts.map +1 -0
  91. package/dist/lib/local-llm/engine-interface.js +2 -0
  92. package/dist/lib/local-llm/engine-interface.js.map +1 -0
  93. package/dist/lib/local-llm/engine-registry.d.ts +20 -0
  94. package/dist/lib/local-llm/engine-registry.d.ts.map +1 -0
  95. package/dist/lib/local-llm/engine-registry.js +56 -0
  96. package/dist/lib/local-llm/engine-registry.js.map +1 -0
  97. package/dist/lib/local-llm/engines/llama-cpp-engine.d.ts +31 -0
  98. package/dist/lib/local-llm/engines/llama-cpp-engine.d.ts.map +1 -0
  99. package/dist/lib/local-llm/engines/llama-cpp-engine.js +164 -0
  100. package/dist/lib/local-llm/engines/llama-cpp-engine.js.map +1 -0
  101. package/dist/lib/local-llm/engines/mlx-serve-engine.d.ts +31 -0
  102. package/dist/lib/local-llm/engines/mlx-serve-engine.d.ts.map +1 -0
  103. package/dist/lib/local-llm/engines/mlx-serve-engine.js +161 -0
  104. package/dist/lib/local-llm/engines/mlx-serve-engine.js.map +1 -0
  105. package/dist/lib/local-llm/gguf-reader.d.ts +20 -0
  106. package/dist/lib/local-llm/gguf-reader.d.ts.map +1 -0
  107. package/dist/lib/local-llm/gguf-reader.js +190 -0
  108. package/dist/lib/local-llm/gguf-reader.js.map +1 -0
  109. package/dist/lib/local-llm/index.d.ts +9 -0
  110. package/dist/lib/local-llm/index.d.ts.map +1 -0
  111. package/dist/lib/local-llm/index.js +6 -0
  112. package/dist/lib/local-llm/index.js.map +1 -0
  113. package/dist/lib/local-llm/llama-server-process.d.ts +42 -0
  114. package/dist/lib/local-llm/llama-server-process.d.ts.map +1 -0
  115. package/dist/lib/local-llm/llama-server-process.js +237 -0
  116. package/dist/lib/local-llm/llama-server-process.js.map +1 -0
  117. package/dist/lib/local-llm/mlx-binary-manager.d.ts +33 -0
  118. package/dist/lib/local-llm/mlx-binary-manager.d.ts.map +1 -0
  119. package/dist/lib/local-llm/mlx-binary-manager.js +211 -0
  120. package/dist/lib/local-llm/mlx-binary-manager.js.map +1 -0
  121. package/dist/lib/local-llm/mlx-server-process.d.ts +26 -0
  122. package/dist/lib/local-llm/mlx-server-process.d.ts.map +1 -0
  123. package/dist/lib/local-llm/mlx-server-process.js +210 -0
  124. package/dist/lib/local-llm/mlx-server-process.js.map +1 -0
  125. package/dist/lib/local-llm/model-manager.d.ts +33 -0
  126. package/dist/lib/local-llm/model-manager.d.ts.map +1 -0
  127. package/dist/lib/local-llm/model-manager.js +591 -0
  128. package/dist/lib/local-llm/model-manager.js.map +1 -0
  129. package/dist/lib/local-llm/types.d.ts +51 -0
  130. package/dist/lib/local-llm/types.d.ts.map +1 -0
  131. package/dist/lib/local-llm/types.js +2 -0
  132. package/dist/lib/local-llm/types.js.map +1 -0
  133. package/dist/lib/logger.d.ts +2 -0
  134. package/dist/lib/logger.d.ts.map +1 -1
  135. package/dist/lib/logger.js +68 -6
  136. package/dist/lib/logger.js.map +1 -1
  137. package/dist/lib/mcp/mcp-client.d.ts.map +1 -1
  138. package/dist/lib/mcp/mcp-client.js +5 -3
  139. package/dist/lib/mcp/mcp-client.js.map +1 -1
  140. package/dist/lib/mcp/types.d.ts +0 -9
  141. package/dist/lib/mcp/types.d.ts.map +1 -1
  142. package/dist/lib/mcp/types.js +1 -2
  143. package/dist/lib/mcp/types.js.map +1 -1
  144. package/dist/lib/memory/memory-manager.d.ts +1 -0
  145. package/dist/lib/memory/memory-manager.d.ts.map +1 -1
  146. package/dist/lib/memory/memory-manager.js +9 -0
  147. package/dist/lib/memory/memory-manager.js.map +1 -1
  148. package/dist/lib/orchestrator.d.ts +11 -8
  149. package/dist/lib/orchestrator.d.ts.map +1 -1
  150. package/dist/lib/orchestrator.js +246 -5
  151. package/dist/lib/orchestrator.js.map +1 -1
  152. package/dist/lib/sandbox/cdp-client.d.ts +15 -0
  153. package/dist/lib/sandbox/cdp-client.d.ts.map +1 -0
  154. package/dist/lib/sandbox/cdp-client.js +139 -0
  155. package/dist/lib/sandbox/cdp-client.js.map +1 -0
  156. package/dist/lib/sandbox/html-to-markdown.d.ts +9 -1
  157. package/dist/lib/sandbox/html-to-markdown.d.ts.map +1 -1
  158. package/dist/lib/sandbox/html-to-markdown.js +67 -10
  159. package/dist/lib/sandbox/html-to-markdown.js.map +1 -1
  160. package/dist/lib/sandbox/index.d.ts +6 -0
  161. package/dist/lib/sandbox/index.d.ts.map +1 -1
  162. package/dist/lib/sandbox/index.js +5 -0
  163. package/dist/lib/sandbox/index.js.map +1 -1
  164. package/dist/lib/sandbox/page-readiness.d.ts +37 -0
  165. package/dist/lib/sandbox/page-readiness.d.ts.map +1 -0
  166. package/dist/lib/sandbox/page-readiness.js +268 -0
  167. package/dist/lib/sandbox/page-readiness.js.map +1 -0
  168. package/dist/lib/sandbox/sandbox-browser.d.ts +4 -0
  169. package/dist/lib/sandbox/sandbox-browser.d.ts.map +1 -0
  170. package/dist/lib/sandbox/sandbox-browser.js +316 -0
  171. package/dist/lib/sandbox/sandbox-browser.js.map +1 -0
  172. package/dist/lib/sandbox/sandbox-container.d.ts +39 -0
  173. package/dist/lib/sandbox/sandbox-container.d.ts.map +1 -0
  174. package/dist/lib/sandbox/sandbox-container.js +176 -0
  175. package/dist/lib/sandbox/sandbox-container.js.map +1 -0
  176. package/dist/lib/sandbox/sandbox-file.d.ts +4 -0
  177. package/dist/lib/sandbox/sandbox-file.d.ts.map +1 -0
  178. package/dist/lib/sandbox/sandbox-file.js +169 -0
  179. package/dist/lib/sandbox/sandbox-file.js.map +1 -0
  180. package/dist/lib/sandbox/sandbox-shell.d.ts +5 -0
  181. package/dist/lib/sandbox/sandbox-shell.d.ts.map +1 -0
  182. package/dist/lib/sandbox/sandbox-shell.js +111 -0
  183. package/dist/lib/sandbox/sandbox-shell.js.map +1 -0
  184. package/dist/lib/sandbox/sandbox-web.d.ts.map +1 -1
  185. package/dist/lib/sandbox/sandbox-web.js +64 -24
  186. package/dist/lib/sandbox/sandbox-web.js.map +1 -1
  187. package/dist/lib/sandbox/types.d.ts +9 -0
  188. package/dist/lib/sandbox/types.d.ts.map +1 -1
  189. package/dist/lib/sandbox/types.js +1 -0
  190. package/dist/lib/sandbox/types.js.map +1 -1
  191. package/dist/lib/sandbox/vision-browser.d.ts +4 -0
  192. package/dist/lib/sandbox/vision-browser.d.ts.map +1 -0
  193. package/dist/lib/sandbox/vision-browser.js +298 -0
  194. package/dist/lib/sandbox/vision-browser.js.map +1 -0
  195. package/dist/lib/sea/app-window.d.ts +7 -0
  196. package/dist/lib/sea/app-window.d.ts.map +1 -0
  197. package/dist/lib/sea/app-window.js +95 -0
  198. package/dist/lib/sea/app-window.js.map +1 -0
  199. package/dist/lib/sea/bootstrap.d.ts +18 -0
  200. package/dist/lib/sea/bootstrap.d.ts.map +1 -0
  201. package/dist/lib/sea/bootstrap.js +103 -0
  202. package/dist/lib/sea/bootstrap.js.map +1 -0
  203. package/dist/lib/sea/sqlite-vec-shim.d.ts +3 -0
  204. package/dist/lib/sea/sqlite-vec-shim.d.ts.map +1 -0
  205. package/dist/lib/sea/sqlite-vec-shim.js +10 -0
  206. package/dist/lib/sea/sqlite-vec-shim.js.map +1 -0
  207. package/dist/lib/skills/skill-loader.d.ts +2 -0
  208. package/dist/lib/skills/skill-loader.d.ts.map +1 -1
  209. package/dist/lib/skills/skill-loader.js +12 -1
  210. package/dist/lib/skills/skill-loader.js.map +1 -1
  211. package/dist/lib/tasks/task-manager.d.ts +3 -1
  212. package/dist/lib/tasks/task-manager.d.ts.map +1 -1
  213. package/dist/lib/tasks/task-manager.js +11 -0
  214. package/dist/lib/tasks/task-manager.js.map +1 -1
  215. package/dist/lib/tasks/task-store.d.ts +1 -1
  216. package/dist/lib/tasks/task-store.d.ts.map +1 -1
  217. package/dist/lib/tasks/task-store.js.map +1 -1
  218. package/dist/lib/tasks/types.d.ts +18 -0
  219. package/dist/lib/tasks/types.d.ts.map +1 -1
  220. package/dist/lib/tools/built-in/integration-tools.d.ts +4 -0
  221. package/dist/lib/tools/built-in/integration-tools.d.ts.map +1 -0
  222. package/dist/lib/tools/built-in/integration-tools.js +47 -0
  223. package/dist/lib/tools/built-in/integration-tools.js.map +1 -0
  224. package/dist/lib/tools/built-in/knowledge-entity-lookup.tool.d.ts +1 -2
  225. package/dist/lib/tools/built-in/knowledge-entity-lookup.tool.d.ts.map +1 -1
  226. package/dist/lib/tools/built-in/knowledge-entity-lookup.tool.js +17 -17
  227. package/dist/lib/tools/built-in/knowledge-entity-lookup.tool.js.map +1 -1
  228. package/dist/lib/tools/built-in/knowledge-graph-schema.tool.d.ts.map +1 -1
  229. package/dist/lib/tools/built-in/knowledge-graph-schema.tool.js +2 -4
  230. package/dist/lib/tools/built-in/knowledge-graph-schema.tool.js.map +1 -1
  231. package/dist/lib/tools/built-in/knowledge-search.tool.js +4 -4
  232. package/dist/lib/tools/built-in/knowledge-search.tool.js.map +1 -1
  233. package/dist/lib/tools/built-in/knowledge-sql.tool.d.ts.map +1 -1
  234. package/dist/lib/tools/built-in/knowledge-sql.tool.js +74 -40
  235. package/dist/lib/tools/built-in/knowledge-sql.tool.js.map +1 -1
  236. package/dist/lib/tools/built-in/knowledge-tools-factory.js +2 -2
  237. package/dist/lib/tools/built-in/knowledge-tools-factory.js.map +1 -1
  238. package/dist/lib/tools/built-in/knowledge-traverse.tool.d.ts +1 -2
  239. package/dist/lib/tools/built-in/knowledge-traverse.tool.d.ts.map +1 -1
  240. package/dist/lib/tools/built-in/knowledge-traverse.tool.js +5 -11
  241. package/dist/lib/tools/built-in/knowledge-traverse.tool.js.map +1 -1
  242. package/dist/lib/tools/built-in/query-validators.d.ts.map +1 -1
  243. package/dist/lib/tools/built-in/query-validators.js +4 -0
  244. package/dist/lib/tools/built-in/query-validators.js.map +1 -1
  245. package/dist/lib/tools/workspace/workspace-tools.d.ts +1 -0
  246. package/dist/lib/tools/workspace/workspace-tools.d.ts.map +1 -1
  247. package/dist/lib/tools/workspace/workspace-tools.js +44 -4
  248. package/dist/lib/tools/workspace/workspace-tools.js.map +1 -1
  249. package/dist/lib/triggers/cron-trigger.d.ts +1 -1
  250. package/dist/lib/triggers/cron-trigger.d.ts.map +1 -1
  251. package/dist/lib/triggers/cron-trigger.js.map +1 -1
  252. package/dist/lib/triggers/trigger-manager.d.ts +1 -0
  253. package/dist/lib/triggers/trigger-manager.d.ts.map +1 -1
  254. package/dist/lib/triggers/trigger-manager.js +26 -0
  255. package/dist/lib/triggers/trigger-manager.js.map +1 -1
  256. package/dist/lib/triggers/webhook-trigger.d.ts +1 -1
  257. package/dist/lib/triggers/webhook-trigger.d.ts.map +1 -1
  258. package/dist/lib/triggers/webhook-trigger.js.map +1 -1
  259. package/dist/lib/types/llm-types.d.ts +22 -4
  260. package/dist/lib/types/llm-types.d.ts.map +1 -1
  261. package/dist/lib/types/llm-types.js +50 -0
  262. package/dist/lib/types/llm-types.js.map +1 -1
  263. package/dist/lib/types/tool-factory.d.ts +2 -2
  264. package/dist/lib/types/tool-factory.d.ts.map +1 -1
  265. package/dist/lib/types/tool-factory.js +9 -2
  266. package/dist/lib/types/tool-factory.js.map +1 -1
  267. package/dist/lib/utils/document-extract.d.ts +10 -0
  268. package/dist/lib/utils/document-extract.d.ts.map +1 -0
  269. package/dist/lib/utils/document-extract.js +149 -0
  270. package/dist/lib/utils/document-extract.js.map +1 -0
  271. package/dist/lib/utils/env-substitution.d.ts +6 -0
  272. package/dist/lib/utils/env-substitution.d.ts.map +1 -0
  273. package/dist/lib/utils/env-substitution.js +15 -0
  274. package/dist/lib/utils/env-substitution.js.map +1 -0
  275. package/dist/lib/workflows/react-workflow-executor.d.ts.map +1 -1
  276. package/dist/lib/workflows/react-workflow-executor.js +23 -17
  277. package/dist/lib/workflows/react-workflow-executor.js.map +1 -1
  278. package/dist/lib/workflows/types.d.ts +81 -55
  279. package/dist/lib/workflows/types.d.ts.map +1 -1
  280. package/dist/lib/workflows/types.js +10 -0
  281. package/dist/lib/workflows/types.js.map +1 -1
  282. package/dist/lib/workflows/workflow-loader.d.ts +3 -0
  283. package/dist/lib/workflows/workflow-loader.d.ts.map +1 -1
  284. package/dist/lib/workflows/workflow-loader.js +10 -1
  285. package/dist/lib/workflows/workflow-loader.js.map +1 -1
  286. package/dist/public/assets/logo.png +0 -0
  287. package/dist/public/chat.html +39 -0
  288. package/dist/public/index.html +6 -176
  289. package/dist/public/src/components/AgentComposer.js +807 -0
  290. package/dist/public/src/components/AgentsView.js +1812 -508
  291. package/dist/public/src/components/AppRoot.js +125 -38
  292. package/dist/public/src/components/GraphView.js +382 -300
  293. package/dist/public/src/components/IdeView.js +277 -86
  294. package/dist/public/src/components/KnowledgeView.js +94 -130
  295. package/dist/public/src/components/LlmView.js +15 -19
  296. package/dist/public/src/components/LocalLlmView.js +2440 -0
  297. package/dist/public/src/components/LogViewer.js +155 -0
  298. package/dist/public/src/components/McpView.js +41 -49
  299. package/dist/public/src/components/MonitorView.js +174 -83
  300. package/dist/public/src/components/NavBar.js +16 -26
  301. package/dist/public/src/components/StandaloneChat.js +875 -0
  302. package/dist/public/src/services/ApiService.js +203 -4
  303. package/dist/public/src/services/SessionStore.js +86 -0
  304. package/dist/public/src/services/StreamManager.js +183 -0
  305. package/dist/public/src/store.js +1 -3
  306. package/dist/public/src/utils/card.js +21 -0
  307. package/dist/public/src/utils/markdown.js +7 -0
  308. package/dist/public/styles.css +2777 -0
  309. package/dist/src/cli/commands/init.d.ts.map +1 -1
  310. package/dist/src/cli/commands/init.js +7 -1
  311. package/dist/src/cli/commands/init.js.map +1 -1
  312. package/dist/src/cli/commands/start.d.ts.map +1 -1
  313. package/dist/src/cli/commands/start.js +28 -5
  314. package/dist/src/cli/commands/start.js.map +1 -1
  315. package/dist/src/cli/index.js +19 -5
  316. package/dist/src/cli/index.js.map +1 -1
  317. package/dist/src/index.js +7 -1
  318. package/dist/src/index.js.map +1 -1
  319. package/dist/src/middleware/auth.d.ts.map +1 -1
  320. package/dist/src/middleware/auth.js +28 -6
  321. package/dist/src/middleware/auth.js.map +1 -1
  322. package/dist/src/middleware/rate-limit.d.ts +8 -0
  323. package/dist/src/middleware/rate-limit.d.ts.map +1 -0
  324. package/dist/src/middleware/rate-limit.js +21 -0
  325. package/dist/src/middleware/rate-limit.js.map +1 -0
  326. package/dist/src/routes/agents.route.d.ts.map +1 -1
  327. package/dist/src/routes/agents.route.js +138 -10
  328. package/dist/src/routes/agents.route.js.map +1 -1
  329. package/dist/src/routes/chat.route.d.ts +3 -0
  330. package/dist/src/routes/chat.route.d.ts.map +1 -0
  331. package/dist/src/routes/chat.route.js +156 -0
  332. package/dist/src/routes/chat.route.js.map +1 -0
  333. package/dist/src/routes/files.route.d.ts.map +1 -1
  334. package/dist/src/routes/files.route.js +37 -2
  335. package/dist/src/routes/files.route.js.map +1 -1
  336. package/dist/src/routes/llm.route.d.ts.map +1 -1
  337. package/dist/src/routes/llm.route.js +263 -8
  338. package/dist/src/routes/llm.route.js.map +1 -1
  339. package/dist/src/routes/local-llm.route.d.ts +3 -0
  340. package/dist/src/routes/local-llm.route.d.ts.map +1 -0
  341. package/dist/src/routes/local-llm.route.js +688 -0
  342. package/dist/src/routes/local-llm.route.js.map +1 -0
  343. package/dist/src/routes/logs.route.d.ts +3 -0
  344. package/dist/src/routes/logs.route.d.ts.map +1 -0
  345. package/dist/src/routes/logs.route.js +24 -0
  346. package/dist/src/routes/logs.route.js.map +1 -0
  347. package/dist/src/routes/tasks.route.d.ts.map +1 -1
  348. package/dist/src/routes/tasks.route.js +15 -1
  349. package/dist/src/routes/tasks.route.js.map +1 -1
  350. package/dist/src/routes/vnc.route.d.ts +12 -0
  351. package/dist/src/routes/vnc.route.d.ts.map +1 -0
  352. package/dist/src/routes/vnc.route.js +74 -0
  353. package/dist/src/routes/vnc.route.js.map +1 -0
  354. package/dist/src/routes/workflows.route.d.ts.map +1 -1
  355. package/dist/src/routes/workflows.route.js +24 -0
  356. package/dist/src/routes/workflows.route.js.map +1 -1
  357. package/dist/src/server.d.ts.map +1 -1
  358. package/dist/src/server.js +29 -3
  359. package/dist/src/server.js.map +1 -1
  360. package/dist/templates/Demo.md +152 -0
  361. package/dist/templates/README.md +12 -3
  362. package/dist/templates/agents/actor.agent.yaml +34 -0
  363. package/dist/templates/agents/architect.agent.yaml +20 -13
  364. package/dist/templates/agents/chatbot.agent.yaml +23 -27
  365. package/dist/templates/agents/corporate.agent.yaml +64 -0
  366. package/dist/templates/agents/functions.agent.yaml +29 -0
  367. package/dist/templates/agents/investment-analyst.agent.yaml +79 -0
  368. package/dist/templates/agents/music-librarian.agent.yaml +46 -0
  369. package/dist/templates/agents/network-security.agent.yaml +81 -0
  370. package/dist/templates/agents/transport-security.agent.yaml +69 -0
  371. package/dist/templates/agents/web-engineer.agent.yaml +98 -0
  372. package/dist/templates/agents/web-pilot.agent.yaml +57 -0
  373. package/dist/templates/knowledge/music-store/LICENSE.md +11 -0
  374. package/dist/templates/knowledge/music-store/musicstore.sqlite +0 -0
  375. package/dist/templates/knowledge/music-store/tables.png +0 -0
  376. package/dist/templates/knowledge/music-store.knowledge.yaml +138 -0
  377. package/dist/templates/knowledge/org-chart/personnel.csv +21 -21
  378. package/dist/templates/knowledge/org-chart.knowledge.yaml +4 -0
  379. package/dist/templates/knowledge/patient-records.knowledge.yaml +20 -0
  380. package/dist/templates/knowledge/pdf-patients/PDF_Deid_Deidentification_0.pdf +0 -0
  381. package/dist/templates/knowledge/pdf-patients/PDF_Deid_Deidentification_1.pdf +0 -0
  382. package/dist/templates/knowledge/pdf-patients/PDF_Deid_Deidentification_10.pdf +0 -0
  383. package/dist/templates/knowledge/pdf-patients/PDF_Deid_Deidentification_11.pdf +0 -0
  384. package/dist/templates/knowledge/pet-store.knowledge.yaml +3 -0
  385. package/dist/templates/knowledge/security-incidents/incidents.json +55935 -0
  386. package/dist/templates/knowledge/security-incidents.knowledge.yaml +46 -0
  387. package/dist/templates/knowledge/{example.knowledge.yaml → transcripts.knowledge.yaml} +9 -5
  388. package/dist/templates/knowledge/transport-ot/systems.csv +117 -0
  389. package/dist/templates/knowledge/transport-ot.knowledge.yaml +55 -0
  390. package/dist/templates/knowledge/web-docs.knowledge.yaml +1 -1
  391. package/dist/templates/llm.json +62 -22
  392. package/dist/templates/mcp.json +7 -4
  393. package/dist/templates/skills/orcha-builder/SKILL.md +148 -215
  394. package/dist/templates/skills/pii-guard/SKILL.md +22 -0
  395. package/dist/templates/skills/sandbox/SKILL.md +25 -48
  396. package/dist/templates/skills/web-pilot/SKILL.md +51 -0
  397. package/dist/templates/workflows/example.workflow.yaml +27 -35
  398. package/dist/templates/workflows/react-example.workflow.yaml +14 -19
  399. package/dist/templates/workflows/team-chat.workflow.yaml +47 -0
  400. package/package.json +17 -4
  401. package/dist/public/src/components/SkillsView.js +0 -137
  402. package/dist/public/src/components/WorkflowsView.js +0 -416
  403. package/dist/templates/agents/knowledge-broker.agent.yaml +0 -39
  404. package/dist/templates/agents/sandbox.agent.yaml +0 -56
@@ -0,0 +1,2440 @@
1
+
2
+ import { Component } from '../utils/Component.js';
3
+ import { api } from '../services/ApiService.js';
4
+
5
+ function formatBytes(bytes) {
6
+ if (!bytes) return '0 B';
7
+ if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`;
8
+ if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`;
9
+ if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
10
+ return `${bytes} B`;
11
+ }
12
+
13
+ function escapeHtml(text) {
14
+ if (!text) return '';
15
+ return String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
16
+ }
17
+
18
+ function timeAgo(dateStr) {
19
+ const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
20
+ if (seconds < 60) return 'just now';
21
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
22
+ if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
23
+ return `${Math.floor(seconds / 86400)}d ago`;
24
+ }
25
+
26
+ // Model family patterns for capability detection.
27
+ // HuggingFace tags are unreliable — most GGUF repos don't tag capabilities.
28
+ // We combine explicit tags, pipeline_tag, and model name heuristics.
29
+ const TOOL_TAGS = ['tool-calling', 'function-calling', 'tool_use', 'tool-use'];
30
+ const TOOL_NAME_PATTERNS = [
31
+ /qwen[23]/i, /qwen3\.5/i, /llama.?3\.[1-9]/i, /mistral/i, /phi.?[4-9]/i,
32
+ /functionary/i, /hermes/i, /command.?r/i, /glm/i, /gemma.?[2-9]/i,
33
+ /nemotron/i, /granite/i,
34
+ ];
35
+ const VISION_PIPELINE_TAGS = ['image-text-to-text', 'image-to-text'];
36
+ const VISION_NAME_PATTERNS = [
37
+ /vision/i, /llava/i, /pixtral/i, /qwen3\.5/i,
38
+ ];
39
+ const REASONING_NAME_PATTERNS = [
40
+ /deepseek.?r1/i, /qwq/i, /o[134]-/i, /reasoning/i,
41
+ /think/i, /r1.?distill/i, /qwen3/i,
42
+ ];
43
+ // Qwen3+ has built-in thinking mode (enable_thinking), including Qwen3.5
44
+ const REASONING_TAG_PATTERNS = [/qwen3/i];
45
+
46
+ const PROVIDERS = ['local', 'openai', 'anthropic', 'gemini'];
47
+
48
+ // Brand SVG icons (viewBox 0 0 24 24, uses currentColor)
49
+ const BRAND_SVGS = {
50
+ openai: `<svg viewBox="0 0 24 24" fill="currentColor" class="llm-brand-icon"><path d="M22.28 9.82a5.98 5.98 0 0 0-.52-4.91 6.05 6.05 0 0 0-6.51-2.9A6.07 6.07 0 0 0 4.98 4.18a5.98 5.98 0 0 0-4 2.9 6.05 6.05 0 0 0 .74 7.1 5.98 5.98 0 0 0 .51 4.91 6.05 6.05 0 0 0 6.52 2.9A5.98 5.98 0 0 0 13.26 24a6.06 6.06 0 0 0 5.77-4.21 5.99 5.99 0 0 0 4-2.9 6.06 6.06 0 0 0-.75-7.07zm-9.02 12.61a4.48 4.48 0 0 1-2.88-1.04l.14-.08 4.78-2.76a.8.8 0 0 0 .39-.68v-6.74l2.02 1.17a.07.07 0 0 1 .04.05v5.58a4.5 4.5 0 0 1-4.49 4.5zM3.6 18.3a4.47 4.47 0 0 1-.54-3.01l.14.08 4.78 2.76a.77.77 0 0 0 .78 0l5.84-3.37v2.33a.08.08 0 0 1-.03.06l-4.84 2.79a4.5 4.5 0 0 1-6.14-1.65zM2.34 7.9a4.49 4.49 0 0 1 2.37-1.97V11.6a.77.77 0 0 0 .39.68l5.81 3.35-2.02 1.17a.08.08 0 0 1-.07 0L4.02 14.01A4.5 4.5 0 0 1 2.34 7.87zm16.6 3.86L13.1 8.36l2.02-1.16a.08.08 0 0 1 .07 0l4.83 2.79a4.49 4.49 0 0 1-.68 8.1V12.42a.79.79 0 0 0-.41-.68zm2.01-3.02l-.14-.09-4.77-2.78a.78.78 0 0 0-.79 0L9.41 9.23V6.9a.07.07 0 0 1 .03-.06l4.83-2.79a4.5 4.5 0 0 1 6.68 4.66zM8.31 12.86l-2.02-1.16a.08.08 0 0 1-.04-.06V6.07a4.5 4.5 0 0 1 7.38-3.45l-.14.08-4.78 2.76a.8.8 0 0 0-.39.68zm1.1-2.36l2.6-1.5 2.6 1.5v3l-2.6 1.5-2.6-1.5z"/></svg>`,
51
+ anthropic: `<svg viewBox="0 0 24 24" fill="currentColor" class="llm-brand-icon"><path d="M13.83 3.52h3.6L24 20.48h-3.6l-6.57-16.96zm-7.26 0h3.6l6.57 16.96h-3.6L6.57 3.52z"/></svg>`,
52
+ gemini: `<svg viewBox="0 0 24 24" fill="currentColor" class="llm-brand-icon"><path d="M12 0C12 6.63 6.63 12 0 12c6.63 0 12 5.37 12 12 0-6.63 5.37-12 12-12C17.37 12 12 6.63 12 0z"/></svg>`,
53
+ };
54
+
55
+ function providerIcon(provider) {
56
+ if (BRAND_SVGS[provider]) return BRAND_SVGS[provider];
57
+ return `<i class="fas fa-server"></i>`;
58
+ }
59
+
60
+ const PROVIDER_META = {
61
+ local: { label: 'Local', color: 'amber' },
62
+ openai: { label: 'OpenAI', color: 'green' },
63
+ anthropic: { label: 'Anthropic', color: 'purple' },
64
+ gemini: { label: 'Google', color: 'blue' },
65
+ };
66
+ const POPULAR_MODELS = {
67
+ openai: ['gpt-5.4', 'gpt-5.2', 'gpt-5.1', 'gpt-5', 'gpt-5-mini', 'o4-mini', 'o3', 'o3-mini', 'gpt-4.1', 'gpt-4.1-mini', 'gpt-4o', 'gpt-4o-mini'],
68
+ anthropic: ['claude-sonnet-4-6', 'claude-opus-4-6', 'claude-haiku-4-5-20251001', 'claude-sonnet-4-5', 'claude-opus-4-5'],
69
+ gemini: ['gemini-3.1-pro-preview', 'gemini-3-flash-preview', 'gemini-3.1-flash-lite-preview', 'gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.5-flash-lite'],
70
+ };
71
+ const POPULAR_EMBEDDINGS = {
72
+ openai: ['text-embedding-3-small', 'text-embedding-3-large'],
73
+ gemini: ['gemini-embedding-001', 'text-embedding-004'],
74
+ };
75
+ const PROVIDER_ENV_NAMES = {
76
+ openai: 'OPENAI_API_KEY',
77
+ anthropic: 'ANTHROPIC_API_KEY',
78
+ gemini: 'GOOGLE_API_KEY',
79
+ };
80
+
81
+ const RECOMMENDED_MODELS_GGUF = [
82
+ { repo: 'unsloth/Qwen3.5-9B-GGUF', file: 'Qwen3.5-9B-Q4_K_M.gguf', label: 'Qwen3.5-9B-Q4_K_M', desc: 'Chat model with tool calling, vision, and reasoning. Great all-rounder for local use.', size: '~5.3 GB', icon: 'fa-comments', color: 'amber', type: 'gguf' },
83
+ { repo: 'nomic-ai/nomic-embed-text-v1.5-GGUF', file: 'nomic-embed-text-v1.5.Q4_K_M.gguf', label: 'nomic-embed-text-v1.5-Q4_K_M', desc: 'Embedding model for knowledge stores. Required for local RAG pipelines.', size: '~80 MB', icon: 'fa-vector-square', color: 'blue', type: 'gguf' },
84
+ ];
85
+
86
+ const RECOMMENDED_MODELS_MLX = [
87
+ { repo: 'mlx-community/Qwen3.5-9B-4bit', file: '__mlx_repo__', label: 'Qwen3.5-9B-4bit (MLX)', desc: 'MLX-optimized for Apple Silicon. Recommended for Mac.', size: '~5 GB', icon: 'fa-apple', color: 'amber', type: 'mlx' },
88
+ { repo: 'mlx-community/all-MiniLM-L6-v2-4bit', file: '__mlx_repo__', label: 'all-MiniLM-L6-v2-4bit (MLX)', desc: 'Fast, lightweight embedding model (22M params, 384 dims). Recommended for local RAG.', size: '~15 MB', icon: 'fa-vector-square', color: 'blue', type: 'mlx' },
89
+ ];
90
+
91
+ function isAppleSilicon(status) {
92
+ return status?.platform === 'darwin' && status?.arch === 'arm64';
93
+ }
94
+
95
+ function getRecommendedModels(status) {
96
+ return isAppleSilicon(status) ? RECOMMENDED_MODELS_MLX : RECOMMENDED_MODELS_GGUF;
97
+ }
98
+
99
+ function detectCapabilities(result) {
100
+ const { tags, pipelineTag, modelName, repoId } = result;
101
+ const name = `${repoId} ${modelName}`;
102
+ const lowerTags = (tags || []).map(t => t.toLowerCase());
103
+
104
+ const vision = VISION_PIPELINE_TAGS.includes(pipelineTag)
105
+ || lowerTags.some(t => VISION_PIPELINE_TAGS.includes(t))
106
+ || VISION_NAME_PATTERNS.some(p => p.test(name));
107
+
108
+ const tools = lowerTags.some(t => TOOL_TAGS.includes(t))
109
+ || TOOL_NAME_PATTERNS.some(p => p.test(name));
110
+
111
+ const reasoning = REASONING_NAME_PATTERNS.some(p => p.test(name))
112
+ || REASONING_TAG_PATTERNS.some(p => lowerTags.some(t => p.test(t)));
113
+
114
+ return { vision, tools, reasoning };
115
+ }
116
+
117
+ function detectCapabilitiesFromFile(model) {
118
+ const name = `${model.repo || ''} ${model.fileName}`;
119
+ return {
120
+ tools: TOOL_NAME_PATTERNS.some(p => p.test(name)),
121
+ vision: VISION_NAME_PATTERNS.some(p => p.test(name)),
122
+ reasoning: REASONING_NAME_PATTERNS.some(p => p.test(name))
123
+ || REASONING_TAG_PATTERNS.some(p => p.test(name)),
124
+ };
125
+ }
126
+
127
+ function capabilityBadges(caps) {
128
+ const badges = [];
129
+ if (caps.tools) badges.push('<span class="cap-badge cap-badge-tools" title="Tool calling"><i class="fas fa-wrench mr-1"></i>tools</span>');
130
+ if (caps.vision) badges.push('<span class="cap-badge cap-badge-vision" title="Vision"><i class="fas fa-eye mr-1"></i>vision</span>');
131
+ if (caps.reasoning) badges.push('<span class="cap-badge cap-badge-think" title="Reasoning"><i class="fas fa-brain mr-1"></i>think</span>');
132
+ return badges.join(' ');
133
+ }
134
+
135
+ const ENGINE_LABELS = {
136
+ 'llama-cpp': 'llama-cpp',
137
+ 'mlx-serve': 'mlx-serve',
138
+ 'ollama': 'Ollama',
139
+ 'lmstudio': 'LM Studio',
140
+ };
141
+ const ENGINE_ICONS = {
142
+ 'llama-cpp': '<i class="fas fa-server"></i>',
143
+ 'mlx-serve': '<i class="fab fa-apple"></i>',
144
+ 'ollama': '<i class="fas fa-cube"></i>',
145
+ 'lmstudio': '<i class="fas fa-flask"></i>',
146
+ };
147
+ const MANAGED_ENGINES = ['llama-cpp', 'mlx-serve'];
148
+ const EXTERNAL_ENGINES = ['ollama', 'lmstudio'];
149
+
150
+ export class LocalLlmView extends Component {
151
+ constructor() {
152
+ super();
153
+ this.status = null;
154
+ this.models = [];
155
+ this.searchResults = [];
156
+ this.activeDownloads = new Map(); // EventSource connections (UI only)
157
+ this.downloadPollTimer = null;
158
+ this.systemRamBytes = 0;
159
+ this.updateInfo = null;
160
+ this.mlxUpdateInfo = null;
161
+ this.activeProvider = 'local';
162
+ this.llmConfig = null;
163
+ this._browseFormat = 'gguf';
164
+ this._selectedEngine = null; // auto-detect from status
165
+ this._engines = null; // cached engine probe result
166
+ this._engineUrls = {}; // custom base URLs for external engines
167
+ }
168
+
169
+ /**
170
+ * Resolve a config section's default pointer to the actual config object.
171
+ * In the new pointer format, `default` is a string key pointing to another entry.
172
+ * @param {'models'|'embeddings'} section
173
+ * @returns {object|null} The resolved config object, or null
174
+ */
175
+ _resolveDefault(section) {
176
+ const sectionData = this.llmConfig?.[section];
177
+ if (!sectionData) return null;
178
+ let val = sectionData['default'];
179
+ // Dereference string pointer (one level)
180
+ if (typeof val === 'string') {
181
+ val = sectionData[val];
182
+ }
183
+ return (val && typeof val === 'object') ? val : null;
184
+ }
185
+
186
+ /**
187
+ * Get the resolved key name that 'default' points to.
188
+ * @param {'models'|'embeddings'} section
189
+ * @returns {string|null}
190
+ */
191
+ _resolveDefaultKey(section) {
192
+ const sectionData = this.llmConfig?.[section];
193
+ if (!sectionData) return null;
194
+ const val = sectionData['default'];
195
+ if (typeof val === 'string') return val;
196
+ return 'default';
197
+ }
198
+
199
+ async connectedCallback() {
200
+ super.connectedCallback();
201
+ await this.loadLlmConfig();
202
+ await this.refresh();
203
+ this.pollActiveDownloads();
204
+ this.loadInterruptedDownloads();
205
+ this.loadEngines();
206
+ }
207
+
208
+ disconnectedCallback() {
209
+ // Only close SSE connections — server downloads continue
210
+ for (const es of this.activeDownloads.values()) {
211
+ es.close();
212
+ }
213
+ this.activeDownloads.clear();
214
+ if (this.downloadPollTimer) {
215
+ clearInterval(this.downloadPollTimer);
216
+ this.downloadPollTimer = null;
217
+ }
218
+ }
219
+
220
+ async refresh() {
221
+ await Promise.all([this.loadStatus(), this.loadModels()]);
222
+ }
223
+
224
+ startDownloadPolling() {
225
+ if (this.downloadPollTimer) return; // already polling
226
+ this.downloadPollTimer = setInterval(async () => {
227
+ try {
228
+ const current = await api.getActiveDownloads();
229
+ if (current.length === 0) {
230
+ clearInterval(this.downloadPollTimer);
231
+ this.downloadPollTimer = null;
232
+ this.renderActiveDownloads([]);
233
+ this.loadModels();
234
+ } else {
235
+ this.renderActiveDownloads(current);
236
+ }
237
+ } catch { /* ignore */ }
238
+ }, 1000);
239
+ }
240
+
241
+ async pollActiveDownloads() {
242
+ try {
243
+ const downloads = await api.getActiveDownloads();
244
+ if (downloads.length > 0) {
245
+ this.renderActiveDownloads(downloads);
246
+ this.startDownloadPolling();
247
+ }
248
+ } catch { /* ignore */ }
249
+ }
250
+
251
+ async loadInterruptedDownloads() {
252
+ const container = this.querySelector('#interruptedDownloads');
253
+ if (!container) return;
254
+
255
+ try {
256
+ const interrupted = await api.getInterruptedDownloads();
257
+ if (!interrupted.length) {
258
+ container.innerHTML = '';
259
+ return;
260
+ }
261
+
262
+ container.innerHTML = interrupted.map(d => `
263
+ <div class="llm-alert llm-alert-warning" data-interrupted="${escapeHtml(d.fileName)}">
264
+ <i class="fas fa-pause-circle text-amber text-sm"></i>
265
+ <div class="min-w-0 flex-1">
266
+ <div class="text-sm text-primary truncate">${escapeHtml(d.fileName)}</div>
267
+ <div class="text-xs text-muted">${d.repo ? escapeHtml(d.repo) + ' · ' : ''}${formatBytes(d.downloadedBytes)} downloaded</div>
268
+ </div>
269
+ <div class="flex items-center gap-2 flex-shrink-0">
270
+ <button class="resume-btn btn btn-amber btn-sm" data-repo="${escapeHtml(d.repo || '')}" data-file="${escapeHtml(d.fileName)}">
271
+ <i class="fas fa-play mr-1"></i>Resume
272
+ </button>
273
+ <button class="discard-btn btn btn-danger btn-sm" data-file="${escapeHtml(d.fileName)}">
274
+ <i class="fas fa-trash-alt mr-1"></i>Discard
275
+ </button>
276
+ </div>
277
+ </div>
278
+ `).join('');
279
+
280
+ container.querySelectorAll('.resume-btn').forEach(btn => {
281
+ btn.addEventListener('click', () => {
282
+ const repo = btn.dataset.repo;
283
+ const fileName = btn.dataset.file;
284
+ if (!repo) return;
285
+ const row = btn.closest('[data-interrupted]');
286
+ if (row) row.remove();
287
+ this.downloadModel(repo, fileName, null);
288
+ this.startDownloadPolling();
289
+ });
290
+ });
291
+
292
+ container.querySelectorAll('.discard-btn').forEach(btn => {
293
+ btn.addEventListener('click', async () => {
294
+ const fileName = btn.dataset.file;
295
+ try {
296
+ await api.deleteInterruptedDownload(fileName);
297
+ const row = btn.closest('[data-interrupted]');
298
+ if (row) row.remove();
299
+ } catch (e) {
300
+ console.error('Failed to discard download:', e);
301
+ }
302
+ });
303
+ });
304
+ } catch {
305
+ // ignore
306
+ }
307
+ }
308
+
309
+ renderActiveDownloads(downloads) {
310
+ const container = this.querySelector('#activeDownloads');
311
+ if (!container) return;
312
+
313
+ if (!downloads.length) {
314
+ container.innerHTML = '';
315
+ return;
316
+ }
317
+
318
+ container.innerHTML = downloads.map(d => `
319
+ <div class="llm-alert llm-alert-amber">
320
+ <i class="fas fa-spinner fa-spin text-amber text-sm"></i>
321
+ <div class="min-w-0 flex-1">
322
+ <div class="text-sm text-primary truncate">${escapeHtml(d.fileName)}</div>
323
+ <div class="text-xs text-muted truncate">${escapeHtml(d.repo)}</div>
324
+ </div>
325
+ <div class="flex items-center gap-2 flex-shrink-0">
326
+ <div class="llm-download-bar">
327
+ <div class="llm-download-fill llm-download-fill-amber" style="width: ${d.progress.percent}%"></div>
328
+ </div>
329
+ <span class="text-xs text-muted font-mono text-right">${d.progress.percent}%</span>
330
+ </div>
331
+ </div>
332
+ `).join('');
333
+ }
334
+
335
+ async loadStatus() {
336
+ try {
337
+ this.status = await api.getLocalLlmStatus();
338
+ this.systemRamBytes = this.status.systemRamBytes || 0;
339
+ // Re-render engine tabs if available (status may update engine availability)
340
+ if (this._engines) this.renderEngineTabs();
341
+ this.renderStatus();
342
+ } catch (e) {
343
+ console.error('Failed to load local LLM status:', e);
344
+ }
345
+ }
346
+
347
+ async loadModels() {
348
+ try {
349
+ this.models = await api.getLocalLlmModels();
350
+ this.renderModels();
351
+ this.renderRecommendations();
352
+ // Re-render status since it depends on models for capability detection
353
+ if (this.status) this.renderStatus();
354
+ } catch (e) {
355
+ console.error('Failed to load local models:', e);
356
+ }
357
+ }
358
+
359
+ async loadEngines() {
360
+ try {
361
+ const [engines, urls] = await Promise.all([api.getEngines(), api.getEngineUrls()]);
362
+ this._engines = engines;
363
+ this._engineUrls = urls || {};
364
+ // Auto-detect initial engine from current default config
365
+ if (!this._selectedEngine) {
366
+ const configEngine = this.status?.defaultEngine || this._resolveDefault('models')?.engine;
367
+ if (configEngine) {
368
+ this._selectedEngine = configEngine;
369
+ } else {
370
+ this._selectedEngine = 'llama-cpp';
371
+ }
372
+ }
373
+ this.renderEngineTabs();
374
+ this.renderEngineContent();
375
+ } catch (e) {
376
+ console.error('Failed to load engines:', e);
377
+ }
378
+ }
379
+
380
+ renderEngineTabs() {
381
+ const container = this.querySelector('#engineTabs');
382
+ if (!container || !this._engines) return;
383
+
384
+ const isMacHost = this.status?.platform === 'darwin';
385
+ const configDefaultEngine = this._resolveDefault('models')?.engine || this.status?.defaultEngine || null;
386
+ const engines = ['llama-cpp', 'mlx-serve', 'ollama', 'lmstudio']
387
+ .filter(eng => eng !== 'mlx-serve' || isMacHost);
388
+ container.innerHTML = engines.map(eng => {
389
+ const available = this._engines[eng]?.available;
390
+ const isActive = this._selectedEngine === eng;
391
+ const isExternal = EXTERNAL_ENGINES.includes(eng);
392
+ const isDefault = eng === configDefaultEngine;
393
+ const statusDot = isExternal
394
+ ? `<span class="engine-status ${available ? 'connected' : 'disconnected'}"></span>`
395
+ : '';
396
+ const defaultBadge = isDefault
397
+ ? '<span class="badge badge-green text-2xs">default</span>'
398
+ : '';
399
+ const experimentalBadge = eng === 'mlx-serve'
400
+ ? '<span class="badge badge-amber text-2xs">experimental</span>'
401
+ : '';
402
+ return `
403
+ <button class="llm-engine-tab ${isActive ? 'active' : ''} ${!available ? 'unavailable' : ''}"
404
+ data-engine="${eng}" ${!available ? 'title="Not detected / Not running"' : ''}>
405
+ ${ENGINE_ICONS[eng]}
406
+ <span>${ENGINE_LABELS[eng]}</span>
407
+ ${experimentalBadge}
408
+ ${defaultBadge}
409
+ ${statusDot}
410
+ </button>`;
411
+ }).join('');
412
+
413
+ container.querySelectorAll('.llm-engine-tab').forEach(btn => {
414
+ btn.addEventListener('click', () => {
415
+ const eng = btn.dataset.engine;
416
+ // Allow selecting unavailable engines (they'll show "not detected")
417
+ this._selectedEngine = eng;
418
+ this.renderEngineTabs();
419
+ this.renderEngineContent();
420
+ });
421
+ });
422
+ }
423
+
424
+ renderEngineContent() {
425
+ const eng = this._selectedEngine;
426
+ if (!eng) return;
427
+
428
+ const isExternal = EXTERNAL_ENGINES.includes(eng);
429
+ const modelsSection = this.querySelector('#managedModelsSection');
430
+ const hfSection = this.querySelector('#hfSection');
431
+ const recSection = this.querySelector('#recommendedSection');
432
+ const extSection = this.querySelector('#externalModelsSection');
433
+
434
+ // Status bar is always visible — renderStatus handles all engines
435
+ this.renderStatus();
436
+
437
+ if (isExternal) {
438
+ if (modelsSection) modelsSection.classList.add('hidden');
439
+ if (hfSection) hfSection.classList.add('hidden');
440
+ if (recSection) recSection.classList.add('hidden');
441
+ if (extSection) extSection.classList.remove('hidden');
442
+ this.renderExternalModels();
443
+ } else {
444
+ if (modelsSection) modelsSection.classList.remove('hidden');
445
+ if (hfSection) hfSection.classList.remove('hidden');
446
+ if (extSection) extSection.classList.add('hidden');
447
+ this.renderModels();
448
+ this.renderRecommendations();
449
+ // Lock HF format to match engine and reset results
450
+ const formatSelect = this.querySelector('#hfFormatSelect');
451
+ if (formatSelect) {
452
+ const newFormat = eng === 'mlx-serve' ? 'mlx' : 'gguf';
453
+ if (this._browseFormat !== newFormat) {
454
+ formatSelect.value = newFormat;
455
+ this._browseFormat = newFormat;
456
+ this.searchResults = [];
457
+ const hfResults = this.querySelector('#hfResults');
458
+ if (hfResults) {
459
+ hfResults.innerHTML = `
460
+ <div class="text-muted text-center py-8 text-sm">
461
+ <i class="fas fa-cube text-2xl mb-3 block text-muted"></i>
462
+ Search HuggingFace to find and download ${newFormat.toUpperCase()} models
463
+ </div>`;
464
+ }
465
+ }
466
+ formatSelect.disabled = true;
467
+ }
468
+ }
469
+ }
470
+
471
+ renderExternalModels() {
472
+ const container = this.querySelector('#externalModelsSection');
473
+ if (!container) return;
474
+
475
+ const eng = this._selectedEngine;
476
+ const engineData = this._engines?.[eng];
477
+ const available = engineData?.available;
478
+ const models = engineData?.models || [];
479
+ const label = ENGINE_LABELS[eng];
480
+
481
+ if (!available) {
482
+ container.innerHTML = '';
483
+ return;
484
+ }
485
+
486
+ const currentDefault = this._resolveDefault('models');
487
+ const currentEmbDefault = this._resolveDefault('embeddings');
488
+ const isActiveChat = currentDefault?.engine === eng;
489
+ const isActiveEmb = currentEmbDefault?.engine === eng;
490
+ const activeChatModel = isActiveChat ? currentDefault?.model : null;
491
+ const activeEmbModel = isActiveEmb ? currentEmbDefault?.model : null;
492
+
493
+ let html = '';
494
+ if (!models.length) {
495
+ html = `<div class="text-muted text-center py-8 text-sm">No models loaded in ${label}.</div>`;
496
+ } else {
497
+ const totalRam = this.systemRamBytes;
498
+ html = `
499
+ <h3 class="section-title mb-3">Available Models</h3>
500
+ <div class="llm-model-grid">
501
+ ${models.map(m => {
502
+ const name = m.name;
503
+ const isChatActive = activeChatModel === name;
504
+ const isEmbActive = activeEmbModel === name;
505
+ const isLoaded = !!m.loaded;
506
+
507
+ const caps = this._detectExternalCaps(m, eng);
508
+ const badges = capabilityBadges(caps);
509
+ const looksLikeEmbed = caps.embedding;
510
+
511
+ const sizeStr = m.size ? formatBytes(m.size) : '';
512
+ const tooLarge = !this._isEngineRemote(eng) && totalRam && m.size && m.size > totalRam;
513
+
514
+ const metaParts = [];
515
+ if (m.parameterSize) metaParts.push(m.parameterSize);
516
+ if (m.quantization) metaParts.push(m.quantization);
517
+ if (m.family) metaParts.push(m.family);
518
+ if (m.arch) metaParts.push(m.arch);
519
+ if (m.maxContextLength) metaParts.push(`${(m.maxContextLength / 1024).toFixed(0)}K ctx`);
520
+ const metaStr = metaParts.join(' · ');
521
+
522
+ // Card highlights as active based on config; button reflects loaded state
523
+ const cardCls = isChatActive ? 'llm-model-card active-chat' : isEmbActive ? 'llm-model-card active-emb' : 'llm-model-card';
524
+
525
+ // Determine the action button/badge
526
+ let actionHtml;
527
+ if (looksLikeEmbed) {
528
+ if (isEmbActive && isLoaded) {
529
+ actionHtml = '<span class="badge badge-blue">Embedding</span>';
530
+ } else if (isEmbActive && !isLoaded) {
531
+ actionHtml = `<button class="ext-activate-emb btn btn-blue btn-sm" data-model="${escapeHtml(name)}">
532
+ <i class="fas fa-redo mr-1"></i>Reload
533
+ </button>`;
534
+ } else {
535
+ actionHtml = `<button class="ext-activate-emb btn btn-blue btn-sm" data-model="${escapeHtml(name)}">
536
+ <i class="fas fa-vector-square mr-1"></i>Embed
537
+ </button>`;
538
+ }
539
+ } else {
540
+ if (isChatActive && isLoaded) {
541
+ actionHtml = '<span class="badge badge-amber">Active</span>';
542
+ } else if (isChatActive && !isLoaded) {
543
+ actionHtml = `<button class="ext-activate-chat btn btn-amber btn-sm" data-model="${escapeHtml(name)}">
544
+ <i class="fas fa-redo mr-1"></i>Reload
545
+ </button>`;
546
+ } else {
547
+ actionHtml = `<button class="ext-activate-chat btn btn-amber btn-sm" data-model="${escapeHtml(name)}">
548
+ <i class="fas fa-play mr-1"></i>Activate
549
+ </button>`;
550
+ }
551
+ }
552
+
553
+ return `
554
+ <div class="${cardCls}">
555
+ <div class="flex items-start justify-between mb-3">
556
+ <div class="min-w-0 flex-1">
557
+ <div class="flex items-center gap-2 mb-1">
558
+ ${isChatActive ? `<i class="fas fa-circle ${isLoaded ? 'text-amber' : 'text-muted'} text-2xs" ${!isLoaded ? 'title="Not loaded on server"' : ''}></i>` : ''}
559
+ ${isEmbActive ? `<i class="fas fa-circle ${isLoaded ? 'text-blue' : 'text-muted'} text-2xs" ${!isLoaded ? 'title="Not loaded on server"' : ''}></i>` : ''}
560
+ <span class="font-medium text-primary text-sm truncate">${escapeHtml(name)}</span>
561
+ ${m.format ? `<span class="badge badge-${m.format === 'mlx' ? 'green' : 'amber'} text-2xs">${escapeHtml(m.format.toUpperCase())}</span>` : ''}
562
+ </div>
563
+ ${metaStr ? `<div class="text-xs text-muted">${escapeHtml(metaStr)}</div>` : ''}
564
+ ${badges ? `<div class="flex items-center gap-1 mt-1">${badges}</div>` : ''}
565
+ </div>
566
+ </div>
567
+ <div class="flex items-center justify-between">
568
+ <div class="flex items-center gap-3 text-xs text-muted">
569
+ ${sizeStr ? `<span><i class="fas fa-hard-drive mr-1"></i>${sizeStr}</span>` : ''}
570
+ ${tooLarge ? `<span class="text-red"><i class="fas fa-memory mr-1"></i>won't fit</span>` : ''}
571
+ </div>
572
+ <div class="flex items-center gap-2">
573
+ ${actionHtml}
574
+ </div>
575
+ </div>
576
+ </div>`;
577
+ }).join('')}
578
+ </div>`;
579
+ }
580
+
581
+ container.innerHTML = html;
582
+
583
+ container.querySelectorAll('.ext-activate-chat').forEach(btn => {
584
+ btn.addEventListener('click', async () => {
585
+ btn.disabled = true;
586
+ btn.innerHTML = '<span class="spinner-sm"></span> Activating...';
587
+ try {
588
+ await api.activateEngine(eng, btn.dataset.model, 'chat');
589
+ await this.loadLlmConfig();
590
+ await this.loadEngines();
591
+ } catch (e) {
592
+ console.error('Failed to activate external model:', e);
593
+ btn.disabled = false;
594
+ btn.innerHTML = '<i class="fas fa-play mr-1"></i>Activate';
595
+ }
596
+ });
597
+ });
598
+
599
+ container.querySelectorAll('.ext-activate-emb').forEach(btn => {
600
+ btn.addEventListener('click', async () => {
601
+ btn.disabled = true;
602
+ btn.innerHTML = '<span class="spinner-sm"></span> Activating...';
603
+ try {
604
+ await api.activateEngine(eng, btn.dataset.model, 'embedding');
605
+ await this.loadLlmConfig();
606
+ await this.loadEngines();
607
+ } catch (e) {
608
+ console.error('Failed to activate external embedding:', e);
609
+ btn.disabled = false;
610
+ btn.innerHTML = '<i class="fas fa-vector-square mr-1"></i>Embed';
611
+ }
612
+ });
613
+ });
614
+ }
615
+
616
+ _isEngineRemote(eng) {
617
+ const defaultUrls = { ollama: 'http://localhost:11434', lmstudio: 'http://localhost:1234' };
618
+ const url = this._engineUrls?.[eng] || defaultUrls[eng] || '';
619
+ try {
620
+ const host = new URL(url).hostname;
621
+ return host !== 'localhost' && host !== '127.0.0.1' && host !== '::1';
622
+ } catch { return false; }
623
+ }
624
+
625
+ _detectExternalCaps(model, engine) {
626
+ const name = model.name || '';
627
+ const caps = model.capabilities || [];
628
+ const type = model.type || ''; // LM Studio: vlm, llm, embeddings
629
+ // When API provides capability data, trust it over name heuristics
630
+ const hasApiCaps = caps.length > 0 || !!type;
631
+
632
+ // Embedding detection: API capabilities, LM Studio type, or name heuristics
633
+ // Always use name heuristics for embedding — LM Studio doesn't flag embedding models
634
+ const embedding = caps.includes('embedding') || caps.includes('embeddings')
635
+ || type === 'embedding' || type === 'embeddings'
636
+ || /embed|MiniLM|bge-|e5-|gte-|nomic/i.test(name);
637
+
638
+ // Tools: API capability, fall back to name heuristics only without API data
639
+ const tools = caps.includes('tools') || caps.includes('tool_use')
640
+ || (!hasApiCaps && TOOL_NAME_PATTERNS.some(p => p.test(name)));
641
+
642
+ // Vision: API capability, LM Studio type, or name heuristics as fallback
643
+ const vision = caps.includes('vision')
644
+ || type === 'vlm'
645
+ || (!hasApiCaps && VISION_NAME_PATTERNS.some(p => p.test(name)));
646
+
647
+ // Reasoning: API capability or name heuristics as fallback
648
+ const reasoning = caps.includes('thinking') || caps.includes('reasoning')
649
+ || (!hasApiCaps && REASONING_NAME_PATTERNS.some(p => p.test(name)));
650
+
651
+ return { tools, vision, reasoning, embedding };
652
+ }
653
+
654
+ _renderExternalStatus(container) {
655
+ const eng = this._selectedEngine;
656
+ const engineData = this._engines?.[eng];
657
+ const available = engineData?.available;
658
+ const running = engineData?.running || [];
659
+ const label = ENGINE_LABELS[eng];
660
+ const totalRam = this.systemRamBytes;
661
+ const freeRam = this.status?.freeRamBytes || 0;
662
+
663
+ const currentDefault = this._resolveDefault('models');
664
+ const currentEmbDefault = this._resolveDefault('embeddings');
665
+ const activeChatModel = currentDefault?.engine === eng ? currentDefault?.model : null;
666
+ const activeEmbModel = currentEmbDefault?.engine === eng ? currentEmbDefault?.model : null;
667
+ const currentCtx = currentDefault?.contextSize || 8192;
668
+ const currentMaxTokens = currentDefault?.maxTokens || 4096;
669
+
670
+ // Cross-reference config with server loaded state
671
+ const models = engineData?.models || [];
672
+ const activeModelData = activeChatModel ? models.find(m => m.name === activeChatModel) : null;
673
+ const isChatLoaded = !!activeModelData?.loaded;
674
+ const maxCtxFromApi = activeModelData?.maxContextLength || null; // LM Studio provides this
675
+
676
+ // Detect if engine is on a remote host (non-localhost base URL)
677
+ const defaultUrls = { ollama: 'http://localhost:11434', lmstudio: 'http://localhost:1234' };
678
+ const effectiveUrl = this._engineUrls?.[eng] || defaultUrls[eng] || '';
679
+ const isRemote = this._isEngineRemote(eng);
680
+
681
+ // Calculate total VRAM used by running models (Ollama provides this)
682
+ const totalVram = running.reduce((sum, r) => sum + (r.sizeVram || 0), 0);
683
+
684
+ let html = `<div class="llm-server-panel">`;
685
+
686
+ // --- Header ---
687
+ html += `
688
+ <div class="llm-server-header">
689
+ <div class="flex items-center gap-2">
690
+ ${ENGINE_ICONS[eng]}
691
+ <span class="text-sm font-semibold text-primary">${label}</span>
692
+ <span class="${available ? 'llm-pulse llm-pulse-green' : 'llm-pulse-off'}"></span>
693
+ <span class="text-xs ${available ? 'text-green' : 'text-red'}">${available ? 'Connected' : 'Not detected / Not running'}</span>
694
+ </div>
695
+ <button id="refreshEnginesBtn" class="btn-ghost text-xs flex-shrink-0">
696
+ <i class="fas fa-sync-alt mr-1"></i>Refresh
697
+ </button>
698
+ </div>`;
699
+
700
+ // --- Base URL override ---
701
+ const currentUrl = this._engineUrls?.[eng] || '';
702
+ html += `
703
+ <div class="llm-server-section">
704
+ <div class="llm-section-content flex items-center gap-2">
705
+ <label class="text-xs text-muted flex-shrink-0" for="engineUrlInput">Base URL</label>
706
+ <input type="text" id="engineUrlInput" class="input input-sm flex-1 font-mono text-xs"
707
+ value="${escapeHtml(currentUrl)}" placeholder="${defaultUrls[eng] || ''}" />
708
+ <button id="engineUrlSaveBtn" class="btn btn-accent btn-sm hidden">
709
+ <i class="fas fa-save mr-1"></i>Save
710
+ </button>
711
+ <button id="engineUrlResetBtn" class="btn-ghost text-xs ${currentUrl === defaultUrls[eng] || !currentUrl ? 'hidden' : ''}" title="Reset to default">
712
+ <i class="fas fa-undo"></i>
713
+ </button>
714
+ </div>
715
+ </div>`;
716
+
717
+ if (!available) {
718
+ html += `
719
+ <div class="llm-section-content flex items-center gap-3 py-2">
720
+ <span class="text-sm text-muted">Make sure ${label} is running${isRemote ? ` at ${escapeHtml(effectiveUrl)}` : ' on your machine'}</span>
721
+ </div>`;
722
+ } else {
723
+ // --- Server details (RAM + models loaded) ---
724
+ // Only show local system RAM when the engine is running on localhost
725
+ if (!isRemote) {
726
+ const ramUsedPct = totalRam ? Math.round(((totalRam - freeRam) / totalRam) * 100) : 0;
727
+ const ramBarCls = ramUsedPct > 80 ? 'llm-mem-red' : ramUsedPct > 60 ? 'llm-mem-amber' : 'llm-mem-green';
728
+ html += `
729
+ <div class="llm-server-details">
730
+ <span title="System RAM"><i class="fas fa-memory mr-1 llm-icon-dim"></i>${formatBytes(totalRam - freeRam)} / ${formatBytes(totalRam)} RAM</span>
731
+ ${totalVram ? `<span title="VRAM used by loaded models"><i class="fas fa-bolt mr-1 llm-icon-dim"></i>${formatBytes(totalVram)} VRAM</span>` : ''}
732
+ <span title="Models loaded"><i class="fas fa-cube mr-1 llm-icon-dim"></i>${running.length} loaded</span>
733
+ </div>`;
734
+
735
+ if (totalRam) {
736
+ html += `
737
+ <div class="llm-mem-bar" title="${ramUsedPct}% RAM used">
738
+ <div class="llm-mem-fill ${ramBarCls}" style="width: ${ramUsedPct}%"></div>
739
+ </div>`;
740
+ }
741
+
742
+ // --- GPU VRAM bar (NVIDIA discrete GPUs) ---
743
+ const gpuVram = this.status?.gpuVram;
744
+ if (gpuVram) {
745
+ const vramPct = Math.round((gpuVram.usedBytes / gpuVram.totalBytes) * 100);
746
+ const vramBarCls = vramPct > 80 ? 'llm-mem-red' : vramPct > 60 ? 'llm-mem-amber' : 'llm-mem-green';
747
+ html += `
748
+ <div class="llm-server-details">
749
+ <span title="GPU VRAM"><i class="fas fa-microchip mr-1 llm-icon-dim"></i>${formatBytes(gpuVram.usedBytes)} / ${formatBytes(gpuVram.totalBytes)} VRAM</span>
750
+ </div>
751
+ <div class="llm-mem-bar" title="${vramPct}% VRAM used">
752
+ <div class="llm-mem-fill ${vramBarCls}" style="width: ${vramPct}%"></div>
753
+ </div>`;
754
+ }
755
+ } else {
756
+ // Remote engine: only show model count and VRAM from API data
757
+ html += `
758
+ <div class="llm-server-details">
759
+ ${totalVram ? `<span title="VRAM used by loaded models"><i class="fas fa-bolt mr-1 llm-icon-dim"></i>${formatBytes(totalVram)} VRAM</span>` : ''}
760
+ <span title="Models loaded"><i class="fas fa-cube mr-1 llm-icon-dim"></i>${running.length} loaded</span>
761
+ </div>`;
762
+ }
763
+
764
+ // --- Running models with unload ---
765
+ if (running.length) {
766
+ html += running.map(r => {
767
+ const vramStr = r.sizeVram ? formatBytes(r.sizeVram) : '';
768
+ const ctxStr = r.contextLength ? `${(r.contextLength / 1024).toFixed(0)}K ctx` : '';
769
+ const sizeStr = r.size ? formatBytes(r.size) : '';
770
+ const meta = [sizeStr, vramStr, ctxStr].filter(Boolean).join(' · ');
771
+ return `
772
+ <div class="llm-server-section">
773
+ <div class="llm-section-content flex items-center justify-between">
774
+ <div class="flex items-center gap-3">
775
+ <span class="llm-pulse llm-pulse-green"></span>
776
+ <span class="badge badge-amber font-mono">${escapeHtml(r.name)}</span>
777
+ ${meta ? `<span class="text-xs text-muted">${meta}</span>` : ''}
778
+ </div>
779
+ <button class="unload-btn btn btn-danger btn-sm" data-model="${escapeHtml(r.name)}"
780
+ ${r.instanceId ? `data-instance-id="${escapeHtml(r.instanceId)}"` : ''}>
781
+ <i class="fas fa-eject mr-1"></i>Unload
782
+ </button>
783
+ </div>
784
+ </div>`;
785
+ }).join('');
786
+ }
787
+
788
+ // --- Chat model config (sliders) ---
789
+ html += `<div class="llm-server-section">`;
790
+ if (activeChatModel) {
791
+ html += `
792
+ <div class="llm-section-content space-y-2">
793
+ <div class="flex items-center gap-3">
794
+ <i class="fas fa-comments text-amber text-xs"></i>
795
+ <span class="text-sm text-primary">Chat Config</span>
796
+ <span class="text-xs text-muted font-mono">${escapeHtml(activeChatModel)}</span>
797
+ ${!isChatLoaded ? '<span class="text-2xs text-red">not loaded</span>' : ''}
798
+ </div>
799
+ <div class="llm-sliders-section">
800
+ <div class="llm-slider-row">
801
+ <label class="llm-slider-label">
802
+ <span>Context Size</span>
803
+ <span id="extCtxValue" class="font-mono">${currentCtx >= 1024 ? `${(currentCtx / 1024).toFixed(0)}K` : currentCtx}</span>
804
+ </label>
805
+ <input type="range" id="extCtxSlider" class="llm-range"
806
+ data-orig="${currentCtx}"
807
+ min="2048" max="${maxCtxFromApi || 131072}" step="1024" value="${Math.min(currentCtx, maxCtxFromApi || 131072)}" />
808
+ <div class="llm-slider-meta">
809
+ <span>2K</span>
810
+ <span class="text-2xs text-muted">${eng === 'ollama' ? 'Sent as num_ctx per request' : 'Reloads model in LM Studio'}</span>
811
+ <span>${maxCtxFromApi ? `${(maxCtxFromApi / 1024).toFixed(0)}K` : '128K'}</span>
812
+ </div>
813
+ </div>
814
+ <div class="llm-slider-row">
815
+ <label class="llm-slider-label">
816
+ <span>Max Tokens</span>
817
+ <span id="extMaxTokValue" class="font-mono">${currentMaxTokens.toLocaleString()}</span>
818
+ </label>
819
+ <input type="range" id="extMaxTokSlider" class="llm-range"
820
+ data-orig="${currentMaxTokens}"
821
+ min="256" max="16384" step="256" value="${currentMaxTokens}" />
822
+ <div class="llm-slider-meta">
823
+ <span>256</span>
824
+ <span>16K</span>
825
+ </div>
826
+ </div>
827
+ <button id="extApplyBtn" class="btn btn-accent btn-sm hidden">
828
+ <i class="fas fa-save mr-1"></i>Apply
829
+ </button>
830
+ </div>
831
+ </div>`;
832
+ } else {
833
+ html += `
834
+ <div class="llm-section-content flex items-center gap-3">
835
+ <span class="llm-pulse-off"></span>
836
+ <span class="text-sm text-secondary">Chat Model</span>
837
+ <span class="text-xs text-muted">Activate a model below</span>
838
+ </div>`;
839
+ }
840
+ html += `</div>`;
841
+
842
+ // --- Embedding model ---
843
+ if (activeEmbModel) {
844
+ html += `
845
+ <div class="llm-server-section">
846
+ <div class="llm-section-content flex items-center gap-3">
847
+ <span class="llm-pulse llm-pulse-blue"></span>
848
+ <span class="text-sm text-primary">Embedding Model</span>
849
+ <span class="badge badge-blue font-mono">${escapeHtml(activeEmbModel)}</span>
850
+ </div>
851
+ </div>`;
852
+ }
853
+ }
854
+
855
+ html += `</div>`;
856
+ container.innerHTML = html;
857
+
858
+ // --- Wire events ---
859
+ container.querySelector('#refreshEnginesBtn')?.addEventListener('click', () => this.loadEngines());
860
+
861
+ // Base URL input
862
+ const urlInput = container.querySelector('#engineUrlInput');
863
+ const urlSaveBtn = container.querySelector('#engineUrlSaveBtn');
864
+ const urlResetBtn = container.querySelector('#engineUrlResetBtn');
865
+ if (urlInput) {
866
+ const origUrl = urlInput.value;
867
+ urlInput.addEventListener('input', () => {
868
+ const changed = urlInput.value.replace(/\/+$/, '') !== origUrl;
869
+ urlSaveBtn?.classList.toggle('hidden', !changed);
870
+ });
871
+ urlInput.addEventListener('keydown', (e) => {
872
+ if (e.key === 'Enter') urlSaveBtn?.click();
873
+ });
874
+ }
875
+ urlSaveBtn?.addEventListener('click', async () => {
876
+ const newUrl = urlInput.value.trim();
877
+ if (!newUrl) return;
878
+ urlSaveBtn.disabled = true;
879
+ urlSaveBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i>Saving...';
880
+ try {
881
+ await api.setEngineUrl(eng, newUrl);
882
+ await this.loadEngines();
883
+ } catch (e) {
884
+ console.error('Failed to set engine URL:', e);
885
+ urlSaveBtn.disabled = false;
886
+ urlSaveBtn.innerHTML = '<i class="fas fa-save mr-1"></i>Save';
887
+ }
888
+ });
889
+ urlResetBtn?.addEventListener('click', async () => {
890
+ const defaults = { ollama: 'http://localhost:11434', lmstudio: 'http://localhost:1234' };
891
+ urlResetBtn.disabled = true;
892
+ try {
893
+ await api.setEngineUrl(eng, defaults[eng]);
894
+ await this.loadEngines();
895
+ } catch (e) {
896
+ console.error('Failed to reset engine URL:', e);
897
+ urlResetBtn.disabled = false;
898
+ }
899
+ });
900
+
901
+ // Unload buttons
902
+ container.querySelectorAll('.unload-btn').forEach(btn => {
903
+ btn.addEventListener('click', async () => {
904
+ btn.disabled = true;
905
+ btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i>Unloading...';
906
+ try {
907
+ await api.unloadEngineModel(eng, btn.dataset.model, btn.dataset.instanceId);
908
+ await this.loadEngines();
909
+ } catch (e) {
910
+ console.error('Failed to unload model:', e);
911
+ btn.disabled = false;
912
+ btn.innerHTML = '<i class="fas fa-eject mr-1"></i>Unload';
913
+ }
914
+ });
915
+ });
916
+
917
+ // Slider wiring
918
+ const extCtxSlider = container.querySelector('#extCtxSlider');
919
+ const extMaxTokSlider = container.querySelector('#extMaxTokSlider');
920
+ const extApplyBtn = container.querySelector('#extApplyBtn');
921
+
922
+ const updateApplyVisibility = () => {
923
+ const ctxChanged = extCtxSlider && extCtxSlider.value !== extCtxSlider.dataset.orig;
924
+ const tokChanged = extMaxTokSlider && extMaxTokSlider.value !== extMaxTokSlider.dataset.orig;
925
+ extApplyBtn?.classList.toggle('hidden', !ctxChanged && !tokChanged);
926
+ };
927
+
928
+ if (extCtxSlider) {
929
+ const ctxMax = parseInt(extCtxSlider.max);
930
+ const updateFill = () => {
931
+ const pos = ((parseInt(extCtxSlider.value) - 2048) / (ctxMax - 2048)) * 100;
932
+ extCtxSlider.style.setProperty('--range-color', 'var(--green)');
933
+ extCtxSlider.style.setProperty('--range-fill', `${pos}%`);
934
+ };
935
+ updateFill();
936
+ extCtxSlider.addEventListener('input', () => {
937
+ const val = parseInt(extCtxSlider.value);
938
+ container.querySelector('#extCtxValue').textContent = val >= 1024 ? `${(val / 1024).toFixed(0)}K` : val;
939
+ updateFill();
940
+ updateApplyVisibility();
941
+ });
942
+ }
943
+ if (extMaxTokSlider) {
944
+ const updateFill = () => {
945
+ const pos = ((parseInt(extMaxTokSlider.value) - 256) / (16384 - 256)) * 100;
946
+ extMaxTokSlider.style.setProperty('--range-color', 'var(--green)');
947
+ extMaxTokSlider.style.setProperty('--range-fill', `${pos}%`);
948
+ };
949
+ updateFill();
950
+ extMaxTokSlider.addEventListener('input', () => {
951
+ container.querySelector('#extMaxTokValue').textContent = parseInt(extMaxTokSlider.value).toLocaleString();
952
+ updateFill();
953
+ updateApplyVisibility();
954
+ });
955
+ }
956
+
957
+ extApplyBtn?.addEventListener('click', async () => {
958
+ extApplyBtn.disabled = true;
959
+ extApplyBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i>Applying...';
960
+ try {
961
+ const newCtx = extCtxSlider ? parseInt(extCtxSlider.value) : currentCtx;
962
+ const newMaxTokens = extMaxTokSlider ? parseInt(extMaxTokSlider.value) : currentMaxTokens;
963
+
964
+ await api.setEngineContext(newCtx);
965
+
966
+ const resolvedKey = this._resolveDefaultKey('models');
967
+ const existing = this._resolveDefault('models') || {};
968
+ await api.saveLlmModel(resolvedKey, {
969
+ ...existing,
970
+ provider: existing.provider || 'local',
971
+ maxTokens: newMaxTokens,
972
+ });
973
+
974
+ await this.loadLlmConfig();
975
+ if (extCtxSlider) extCtxSlider.dataset.orig = extCtxSlider.value;
976
+ if (extMaxTokSlider) extMaxTokSlider.dataset.orig = extMaxTokSlider.value;
977
+ extApplyBtn.classList.add('hidden');
978
+ } catch (e) {
979
+ console.error('Failed to apply settings:', e);
980
+ } finally {
981
+ extApplyBtn.disabled = false;
982
+ extApplyBtn.innerHTML = '<i class="fas fa-save mr-1"></i>Apply';
983
+ }
984
+ });
985
+ }
986
+
987
+ renderStatus() {
988
+ const container = this.querySelector('#statusBar');
989
+ if (!container) return;
990
+
991
+ // External engines get their own status panel
992
+ if (EXTERNAL_ENGINES.includes(this._selectedEngine)) {
993
+ this._renderExternalStatus(container);
994
+ return;
995
+ }
996
+
997
+ if (!this.status) return;
998
+
999
+ const isMlx = this._selectedEngine === 'mlx-serve';
1000
+ // Get per-engine status from the engines map
1001
+ const engineStatus = this.status.engines?.[this._selectedEngine];
1002
+ const chatStatus = engineStatus?.chat || {};
1003
+ const embeddingStatus = engineStatus?.embedding || {};
1004
+
1005
+ const running = chatStatus.running || false;
1006
+ const activeModel = running ? chatStatus.activeModel : null;
1007
+ // Show embedding if this engine has an active embedding server
1008
+ const embedding = embeddingStatus.running ? embeddingStatus : null;
1009
+ const version = isMlx ? this.status.mlxVersion : this.status.llamaVersion;
1010
+ const versionLabel = isMlx
1011
+ ? (version ? `v${escapeHtml(version)}` : '')
1012
+ : (version ? `b${escapeHtml(version.match(/^(\d+)/)?.[1] || version)}` : '');
1013
+ const binarySource = isMlx ? this.status.mlxBinarySource : this.status.binarySource;
1014
+ const gpuName = this.status.gpu?.name;
1015
+ const gpuAccel = this.status.gpu?.accel || 'none';
1016
+ const updateInfo = isMlx ? this.mlxUpdateInfo : this.updateInfo;
1017
+ const hasUpdate = updateInfo?.available === true;
1018
+ const daysLabel = hasUpdate && updateInfo.daysNewer != null
1019
+ ? (updateInfo.daysNewer === 0 ? 'today' : `${updateInfo.daysNewer}d ago`)
1020
+ : '';
1021
+
1022
+ const accelLabels = { 'none': 'CPU', 'metal': 'Metal', 'vulkan': 'Vulkan', 'cuda-12.4': 'CUDA 12.4', 'cuda-13.1': 'CUDA 13.1' };
1023
+ const runtimeLabel = gpuName
1024
+ ? `${gpuName} (${accelLabels[gpuAccel] || gpuAccel})`
1025
+ : (accelLabels[gpuAccel] || gpuAccel);
1026
+
1027
+ // --- Server header ---
1028
+ let html = `<div class="llm-server-panel">`;
1029
+
1030
+ // Server info header
1031
+ const configDefaultEngine = this._resolveDefault('models')?.engine || this.status.defaultEngine || null;
1032
+ const isDefaultEngine = this._selectedEngine === configDefaultEngine;
1033
+
1034
+ html += `
1035
+ <div class="llm-server-header">
1036
+ <div class="flex items-center gap-2">
1037
+ <i class="fas fa-server text-amber text-xs"></i>
1038
+ <span class="text-sm font-semibold text-primary">${isMlx ? 'mlx-serve' : 'llama-server'}</span>
1039
+ ${versionLabel ? `<span class="text-xs text-muted font-mono">${versionLabel}</span>` : ''}
1040
+ <span class="${running ? 'llm-pulse llm-pulse-green' : 'llm-pulse-off'}"></span>
1041
+ <span class="text-xs ${running ? 'text-green' : 'text-muted'}">${running ? 'Running' : 'Stopped'}</span>
1042
+ ${isDefaultEngine ? '<span class="badge badge-green text-2xs">default</span>' : ''}
1043
+ </div>
1044
+ ${binarySource === 'managed' ? (
1045
+ hasUpdate ? `
1046
+ <button id="updateBinaryBtn" class="btn btn-indigo btn-sm flex-shrink-0" title="Latest: ${escapeHtml(updateInfo.latestTag || '')}">
1047
+ <i class="fas fa-arrows-rotate mr-1"></i>Update${daysLabel ? ` <span class="text-muted">(${daysLabel})</span>` : ''}
1048
+ </button>`
1049
+ : !updateInfo ? `
1050
+ <button id="checkUpdateBtn" class="btn-ghost text-xs flex-shrink-0">
1051
+ <i class="fas fa-arrows-rotate mr-1"></i>Check for updates
1052
+ </button>` : ''
1053
+ ) : ''}
1054
+ </div>`;
1055
+
1056
+ // Server details row
1057
+ html += `
1058
+ <div class="llm-server-details">
1059
+ <span title="Runtime"><i class="fas fa-bolt mr-1 llm-icon-dim"></i>${escapeHtml(runtimeLabel)}</span>
1060
+ <span title="Server type"><i class="fas fa-comments mr-1 llm-icon-dim"></i>Chat Completions</span>
1061
+ ${running && chatStatus.port ? `
1062
+ <span class="flex items-center gap-1" title="OpenAI-compatible API endpoint">
1063
+ <i class="fas fa-link mr-1 llm-icon-dim"></i>
1064
+ <code class="font-mono text-secondary">http://127.0.0.1:${chatStatus.port}/v1</code>
1065
+ <button class="copy-url-btn text-muted transition-colors" data-url="http://127.0.0.1:${chatStatus.port}/v1" title="Copy URL">
1066
+ <i class="fas fa-copy text-2xs"></i>
1067
+ </button>
1068
+ </span>` : `<span><i class="fas fa-link mr-1 llm-icon-dim"></i><span class="text-muted">Not running</span></span>`}
1069
+ </div>`;
1070
+
1071
+ // --- System RAM bar ---
1072
+ const totalRamManaged = this.status.systemRamBytes;
1073
+ const freeRamManaged = this.status.freeRamBytes;
1074
+ if (totalRamManaged) {
1075
+ const ramUsedPct = Math.round(((totalRamManaged - freeRamManaged) / totalRamManaged) * 100);
1076
+ const ramBarCls = ramUsedPct > 80 ? 'llm-mem-red' : ramUsedPct > 60 ? 'llm-mem-amber' : 'llm-mem-green';
1077
+ html += `
1078
+ <div class="llm-server-details">
1079
+ <span title="System RAM"><i class="fas fa-memory mr-1 llm-icon-dim"></i>${formatBytes(totalRamManaged - freeRamManaged)} / ${formatBytes(totalRamManaged)} RAM</span>
1080
+ </div>
1081
+ <div class="llm-mem-bar" title="${ramUsedPct}% RAM used">
1082
+ <div class="llm-mem-fill ${ramBarCls}" style="width: ${ramUsedPct}%"></div>
1083
+ </div>`;
1084
+ }
1085
+
1086
+ // --- GPU VRAM bar (NVIDIA discrete GPUs) ---
1087
+ const gpuVram = this.status.gpuVram;
1088
+ if (gpuVram) {
1089
+ const vramPct = Math.round((gpuVram.usedBytes / gpuVram.totalBytes) * 100);
1090
+ const vramBarCls = vramPct > 80 ? 'llm-mem-red' : vramPct > 60 ? 'llm-mem-amber' : 'llm-mem-green';
1091
+ html += `
1092
+ <div class="llm-server-details">
1093
+ <span title="GPU VRAM"><i class="fas fa-microchip mr-1 llm-icon-dim"></i>${formatBytes(gpuVram.usedBytes)} / ${formatBytes(gpuVram.totalBytes)} VRAM</span>
1094
+ </div>
1095
+ <div class="llm-mem-bar" title="${vramPct}% VRAM used">
1096
+ <div class="llm-mem-fill ${vramBarCls}" style="width: ${vramPct}%"></div>
1097
+ </div>`;
1098
+ }
1099
+
1100
+ // --- Chat model (child) ---
1101
+ html += `<div class="llm-server-section">`;
1102
+ if (running) {
1103
+ const modelName = activeModel ? activeModel.split('/').pop() : 'Unknown';
1104
+ const mem = chatStatus.memoryEstimate;
1105
+ const totalRam = this.status.systemRamBytes;
1106
+ const ctxSize = chatStatus.contextSize;
1107
+ const memPct = mem && totalRam ? Math.round((mem.totalBytes / totalRam) * 100) : null;
1108
+ const memBarCls = memPct > 80 ? 'llm-mem-red' : memPct > 60 ? 'llm-mem-amber' : 'llm-mem-green';
1109
+ const kvPerToken = mem && ctxSize ? mem.kvCacheBytes / ctxSize : 0;
1110
+ const resolvedDefault = this._resolveDefault('models');
1111
+ const currentMaxTokens = resolvedDefault?.maxTokens || 4096;
1112
+ const currentReasoningBudget = resolvedDefault?.reasoningBudget || 0;
1113
+ const activeModelObj = this.models.find(m => m.filePath === activeModel);
1114
+ const modelCaps = activeModelObj ? detectCapabilitiesFromFile(activeModelObj) : { reasoning: false };
1115
+ const thinkingEnabled = currentReasoningBudget > 0;
1116
+
1117
+ html += `
1118
+ <div class="llm-section-content space-y-2">
1119
+ <div class="flex items-center justify-between">
1120
+ <div class="flex items-center gap-3">
1121
+ <span class="llm-pulse llm-pulse-green"></span>
1122
+ <span class="text-sm text-primary">Chat Model</span>
1123
+ <span class="badge badge-amber font-mono">${escapeHtml(modelName)}</span>
1124
+ ${ctxSize ? `<span class="text-xs text-muted">${(ctxSize / 1024).toFixed(0)}K ctx</span>` : ''}
1125
+ </div>
1126
+ <button id="stopBtn" class="btn btn-danger btn-sm">
1127
+ <i class="fas fa-stop mr-1"></i>Stop
1128
+ </button>
1129
+ </div>
1130
+ ${mem ? `
1131
+ <div class="flex items-center gap-3">
1132
+ <div class="llm-mem-bar">
1133
+ <div class="llm-mem-fill ${memBarCls}" style="width: ${memPct}%"></div>
1134
+ </div>
1135
+ <div class="flex items-center gap-3 text-xs text-muted flex-shrink-0">
1136
+ <span title="Model weights"><i class="fas fa-cube mr-1"></i>${formatBytes(mem.modelBytes)}</span>
1137
+ <span title="KV cache"><i class="fas fa-memory mr-1"></i>${formatBytes(mem.kvCacheBytes)}</span>
1138
+ <span title="Total estimated / System RAM">${formatBytes(mem.totalBytes)} / ${formatBytes(totalRam)}</span>
1139
+ </div>
1140
+ </div>` : ''}
1141
+ <div class="llm-sliders-section">
1142
+ <div class="llm-slider-row">
1143
+ <label class="llm-slider-label">
1144
+ <span>Context Size</span>
1145
+ <span id="ctxValue" class="font-mono">${ctxSize >= 1024 ? `${(ctxSize / 1024).toFixed(0)}K` : ctxSize}</span>
1146
+ </label>
1147
+ <input type="range" id="ctxSlider" class="llm-range"
1148
+ data-kv-per-token="${kvPerToken}" data-model-bytes="${mem?.modelBytes || 0}" data-total-ram="${totalRam || 0}"
1149
+ data-orig="${ctxSize || 8192}"
1150
+ min="2048" max="131072" step="1024" value="${ctxSize || 8192}" />
1151
+ <div class="llm-slider-meta">
1152
+ <span>2K</span>
1153
+ <span id="ctxMemEstimate" class="font-mono">${formatBytes(mem?.kvCacheBytes || 0)} KV + ${formatBytes(mem?.modelBytes || 0)} model = ${formatBytes(mem?.totalBytes || 0)} / ${formatBytes(totalRam)}</span>
1154
+ <span>128K</span>
1155
+ </div>
1156
+ </div>
1157
+ <div class="llm-slider-row">
1158
+ <label class="llm-slider-label">
1159
+ <span>Max Tokens</span>
1160
+ <span id="maxTokValue" class="font-mono">${currentMaxTokens.toLocaleString()}</span>
1161
+ </label>
1162
+ <input type="range" id="maxTokSlider" class="llm-range"
1163
+ data-orig="${currentMaxTokens}"
1164
+ min="256" max="16384" step="256" value="${currentMaxTokens}" />
1165
+ <div class="llm-slider-meta">
1166
+ <span>256</span>
1167
+ <span>16K</span>
1168
+ </div>
1169
+ </div>
1170
+ ${modelCaps.reasoning ? `
1171
+ <div class="llm-thinking-row">
1172
+ <div class="flex items-center justify-between">
1173
+ <label class="llm-slider-label flex items-center gap-2">
1174
+ <span><i class="fas fa-brain text-purple mr-1"></i>Thinking</span>
1175
+ <span id="thinkingValue" class="font-mono text-xs ${thinkingEnabled ? (currentReasoningBudget > 256 ? 'text-red' : 'text-purple') : 'text-muted'}">${thinkingEnabled ? currentReasoningBudget.toLocaleString() + ' tokens' : 'Off'}</span>
1176
+ </label>
1177
+ <label class="llm-toggle">
1178
+ <input type="checkbox" id="thinkingToggle" data-orig="${currentReasoningBudget}" ${thinkingEnabled ? 'checked' : ''} />
1179
+ <span class="llm-toggle-slider"></span>
1180
+ </label>
1181
+ </div>
1182
+ <div id="thinkingSliderWrap" class="${thinkingEnabled ? '' : 'hidden'}">
1183
+ <input type="range" id="thinkingSlider" class="llm-range"
1184
+ data-orig="${currentReasoningBudget || 128}"
1185
+ min="128" max="1024" step="128" value="${currentReasoningBudget || 128}" />
1186
+ <div class="llm-slider-meta">
1187
+ <span>128</span>
1188
+ <span class="text-2xs text-muted">Requires server restart</span>
1189
+ <span>1K</span>
1190
+ </div>
1191
+ </div>
1192
+ </div>` : ''}
1193
+ <div id="sliderWarning" class="llm-slider-warning hidden"></div>
1194
+ <button id="applySettingsBtn" class="btn btn-accent btn-sm hidden">
1195
+ <i class="fas fa-save mr-1"></i>Apply
1196
+ </button>
1197
+ </div>
1198
+ </div>`;
1199
+ } else {
1200
+ const lastModel = this.status.lastActiveModel;
1201
+ const lastModelName = lastModel ? this.models.find(m => m.id === lastModel) : null;
1202
+ html += `
1203
+ <div class="llm-section-content flex items-center justify-between">
1204
+ <div class="flex items-center gap-3">
1205
+ <span class="llm-pulse-off"></span>
1206
+ <span class="text-sm text-secondary">Chat Model</span>
1207
+ ${lastModelName
1208
+ ? `<span class="text-xs text-muted">${escapeHtml(lastModelName.fileName)}</span>`
1209
+ : '<span class="text-xs text-muted">Activate a model to start</span>'}
1210
+ </div>
1211
+ ${lastModelName
1212
+ ? `<button id="startLastBtn" class="btn btn-amber btn-sm" data-id="${escapeHtml(lastModel)}">
1213
+ <i class="fas fa-play mr-1"></i>Start
1214
+ </button>`
1215
+ : ''}
1216
+ </div>`;
1217
+ }
1218
+ html += `</div>`;
1219
+
1220
+ // --- Embedding model (child) ---
1221
+ if (embedding?.running) {
1222
+ const embName = embedding.activeModel ? embedding.activeModel.split('/').pop() : 'Unknown';
1223
+ html += `
1224
+ <div class="llm-server-section">
1225
+ <div class="llm-section-content flex items-center justify-between">
1226
+ <div class="flex items-center gap-3">
1227
+ <span class="llm-pulse llm-pulse-blue"></span>
1228
+ <span class="text-sm text-primary">Embedding Model</span>
1229
+ <span class="badge badge-blue font-mono">${escapeHtml(embName)}</span>
1230
+ </div>
1231
+ <button id="stopEmbBtn" class="btn btn-danger btn-sm">
1232
+ <i class="fas fa-stop mr-1"></i>Stop
1233
+ </button>
1234
+ </div>
1235
+ </div>`;
1236
+ }
1237
+
1238
+ html += `</div>`; // close outer container
1239
+
1240
+ container.innerHTML = html;
1241
+ this.querySelector('#stopBtn')?.addEventListener('click', () => this.stopServer());
1242
+ this.querySelector('#stopEmbBtn')?.addEventListener('click', () => this.stopEmbedding());
1243
+ this.querySelector('#startLastBtn')?.addEventListener('click', (e) => this.activateModel(e.currentTarget.dataset.id));
1244
+ this.querySelector('.copy-url-btn')?.addEventListener('click', (e) => {
1245
+ navigator.clipboard.writeText(e.currentTarget.dataset.url);
1246
+ const icon = e.currentTarget.querySelector('i');
1247
+ icon.className = 'fas fa-check text-2xs text-green';
1248
+ setTimeout(() => { icon.className = 'fas fa-copy text-2xs'; }, 1500);
1249
+ });
1250
+ this.querySelector('#updateBinaryBtn')?.addEventListener('click', () => this.updateBinary());
1251
+ this.querySelector('#checkUpdateBtn')?.addEventListener('click', () => this.checkForUpdate());
1252
+
1253
+ const ctxSlider = this.querySelector('#ctxSlider');
1254
+ const maxTokSlider = this.querySelector('#maxTokSlider');
1255
+ const applyBtn = this.querySelector('#applySettingsBtn');
1256
+ const warningEl = this.querySelector('#sliderWarning');
1257
+
1258
+ const updateSliderTrack = (slider, pct) => {
1259
+ const color = pct > 80 ? 'var(--red-400)' : pct > 60 ? 'var(--amber-400)' : 'var(--green)';
1260
+ const pos = ((parseInt(slider.value) - slider.min) / (slider.max - slider.min)) * 100;
1261
+ slider.style.setProperty('--range-color', color);
1262
+ slider.style.setProperty('--range-fill', `${pos}%`);
1263
+ };
1264
+
1265
+ const thinkingToggle = this.querySelector('#thinkingToggle');
1266
+ const thinkingSlider = this.querySelector('#thinkingSlider');
1267
+ const thinkingSliderWrap = this.querySelector('#thinkingSliderWrap');
1268
+
1269
+ const getThinkingBudget = () => {
1270
+ if (!thinkingToggle) return 0;
1271
+ return thinkingToggle.checked ? parseInt(thinkingSlider?.value || '128') : 0;
1272
+ };
1273
+
1274
+ const updateApplyVisibility = () => {
1275
+ const ctxChanged = ctxSlider && ctxSlider.value !== ctxSlider.dataset.orig;
1276
+ const tokChanged = maxTokSlider && maxTokSlider.value !== maxTokSlider.dataset.orig;
1277
+ const thinkingChanged = thinkingToggle && String(getThinkingBudget()) !== thinkingToggle.dataset.orig;
1278
+ const changed = ctxChanged || tokChanged || thinkingChanged;
1279
+ applyBtn?.classList.toggle('hidden', !changed);
1280
+ if (!changed && warningEl) { warningEl.classList.add('hidden'); warningEl.textContent = ''; }
1281
+ };
1282
+
1283
+ const computeRamPct = () => {
1284
+ if (!ctxSlider) return 0;
1285
+ const kvPT = parseFloat(ctxSlider.dataset.kvPerToken);
1286
+ const modelB = parseInt(ctxSlider.dataset.modelBytes);
1287
+ const ram = parseInt(ctxSlider.dataset.totalRam);
1288
+ const newKv = parseInt(ctxSlider.value) * kvPT;
1289
+ return ram ? Math.round((modelB + newKv) / ram * 100) : 0;
1290
+ };
1291
+
1292
+ const updateWarning = (pct) => {
1293
+ if (!warningEl) return;
1294
+ if (pct > 90) {
1295
+ warningEl.textContent = 'Exceeds available RAM — will cause heavy swapping and very slow performance';
1296
+ warningEl.className = 'llm-slider-warning llm-slider-warning-red';
1297
+ } else if (pct > 75) {
1298
+ warningEl.textContent = 'High memory usage — may cause swapping and reduced performance';
1299
+ warningEl.className = 'llm-slider-warning llm-slider-warning-amber';
1300
+ } else {
1301
+ warningEl.classList.add('hidden');
1302
+ warningEl.textContent = '';
1303
+ }
1304
+ };
1305
+
1306
+ // Set initial track fill
1307
+ if (ctxSlider) { updateSliderTrack(ctxSlider, computeRamPct()); }
1308
+ if (maxTokSlider) {
1309
+ const orig = parseInt(maxTokSlider.dataset.orig);
1310
+ const pos = ((orig - 256) / (16384 - 256)) * 100;
1311
+ maxTokSlider.style.setProperty('--range-color', 'var(--green)');
1312
+ maxTokSlider.style.setProperty('--range-fill', `${pos}%`);
1313
+ }
1314
+
1315
+ ctxSlider?.addEventListener('input', () => {
1316
+ const val = parseInt(ctxSlider.value);
1317
+ const kvPT = parseFloat(ctxSlider.dataset.kvPerToken);
1318
+ const modelB = parseInt(ctxSlider.dataset.modelBytes);
1319
+ const ram = parseInt(ctxSlider.dataset.totalRam);
1320
+ const newKv = val * kvPT;
1321
+ const newTotal = modelB + newKv;
1322
+ this.querySelector('#ctxValue').textContent = val >= 1024 ? `${(val / 1024).toFixed(0)}K` : val;
1323
+ this.querySelector('#ctxMemEstimate').textContent = `${formatBytes(newKv)} KV + ${formatBytes(modelB)} model = ${formatBytes(newTotal)} / ${formatBytes(ram)}`;
1324
+ const pct = computeRamPct();
1325
+ updateSliderTrack(ctxSlider, pct);
1326
+ updateWarning(pct);
1327
+ updateApplyVisibility();
1328
+ });
1329
+ maxTokSlider?.addEventListener('input', () => {
1330
+ this.querySelector('#maxTokValue').textContent = parseInt(maxTokSlider.value).toLocaleString();
1331
+ const pos = ((parseInt(maxTokSlider.value) - 256) / (16384 - 256)) * 100;
1332
+ maxTokSlider.style.setProperty('--range-fill', `${pos}%`);
1333
+ updateApplyVisibility();
1334
+ });
1335
+ const thinkingColor = (val) => val > 256 ? 'var(--red-400, #f87171)' : 'var(--purple-400, #a855f7)';
1336
+ const thinkingLabelClass = (val) => val > 256 ? 'font-mono text-xs text-red' : 'font-mono text-xs text-purple';
1337
+
1338
+ thinkingToggle?.addEventListener('change', () => {
1339
+ const on = thinkingToggle.checked;
1340
+ thinkingSliderWrap?.classList.toggle('hidden', !on);
1341
+ const label = this.querySelector('#thinkingValue');
1342
+ if (label) {
1343
+ if (on) {
1344
+ const val = parseInt(thinkingSlider?.value || '128');
1345
+ label.textContent = val.toLocaleString() + ' tokens';
1346
+ label.className = thinkingLabelClass(val);
1347
+ } else {
1348
+ label.textContent = 'Off';
1349
+ label.className = 'font-mono text-xs text-muted';
1350
+ }
1351
+ }
1352
+ updateApplyVisibility();
1353
+ });
1354
+ thinkingSlider?.addEventListener('input', () => {
1355
+ const val = parseInt(thinkingSlider.value);
1356
+ const label = this.querySelector('#thinkingValue');
1357
+ if (label) {
1358
+ label.textContent = val.toLocaleString() + ' tokens';
1359
+ label.className = thinkingLabelClass(val);
1360
+ }
1361
+ const pos = ((val - 128) / (1024 - 128)) * 100;
1362
+ thinkingSlider.style.setProperty('--range-color', thinkingColor(val));
1363
+ thinkingSlider.style.setProperty('--range-fill', `${pos}%`);
1364
+ updateApplyVisibility();
1365
+ });
1366
+ // Set initial track fill for thinking slider
1367
+ if (thinkingSlider && thinkingToggle?.checked) {
1368
+ const val = parseInt(thinkingSlider.value);
1369
+ const pos = ((val - 128) / (1024 - 128)) * 100;
1370
+ thinkingSlider.style.setProperty('--range-color', thinkingColor(val));
1371
+ thinkingSlider.style.setProperty('--range-fill', `${pos}%`);
1372
+ }
1373
+ applyBtn?.addEventListener('click', () => this.applyModelSettings());
1374
+ }
1375
+
1376
+ async checkForUpdate() {
1377
+ const btn = this.querySelector('#checkUpdateBtn');
1378
+ if (btn) {
1379
+ btn.disabled = true;
1380
+ btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i>Checking...';
1381
+ }
1382
+ try {
1383
+ const isMlx = this._selectedEngine === 'mlx-serve';
1384
+ if (isMlx) {
1385
+ this.mlxUpdateInfo = await api.checkMlxUpdate();
1386
+ } else {
1387
+ this.updateInfo = await api.checkLlamaUpdate();
1388
+ }
1389
+ this.renderStatus();
1390
+ } catch (e) {
1391
+ console.error('Failed to check for updates:', e);
1392
+ if (btn) {
1393
+ btn.disabled = false;
1394
+ btn.innerHTML = '<i class="fas fa-exclamation-circle mr-1 text-red"></i>Failed';
1395
+ setTimeout(() => { btn.innerHTML = '<i class="fas fa-arrows-rotate mr-1"></i>Check for updates'; }, 3000);
1396
+ }
1397
+ }
1398
+ }
1399
+
1400
+ async applyModelSettings() {
1401
+ const ctxSlider = this.querySelector('#ctxSlider');
1402
+ const maxTokSlider = this.querySelector('#maxTokSlider');
1403
+ const btn = this.querySelector('#applySettingsBtn');
1404
+ if (!ctxSlider || !maxTokSlider) return;
1405
+
1406
+ const newCtx = parseInt(ctxSlider.value);
1407
+ const newMaxTokens = parseInt(maxTokSlider.value);
1408
+ const engineSt = this.status?.engines?.[this._selectedEngine];
1409
+ const currentCtx = engineSt?.chat?.contextSize;
1410
+ const ctxChanged = newCtx !== currentCtx;
1411
+
1412
+ const thinkingToggle = this.querySelector('#thinkingToggle');
1413
+ const thinkingSlider = this.querySelector('#thinkingSlider');
1414
+ const newReasoningBudget = thinkingToggle ? (thinkingToggle.checked ? parseInt(thinkingSlider?.value || '128') : 0) : undefined;
1415
+ const existingBudget = this._resolveDefault('models')?.reasoningBudget || 0;
1416
+ const reasoningChanged = newReasoningBudget !== undefined && newReasoningBudget !== existingBudget;
1417
+ const needsRestart = ctxChanged || reasoningChanged;
1418
+
1419
+ if (btn) { btn.disabled = true; btn.innerHTML = `<i class="fas fa-spinner fa-spin mr-1"></i>${needsRestart ? 'Restarting...' : 'Saving...'}`; }
1420
+
1421
+ try {
1422
+ const resolvedKey = this._resolveDefaultKey('models');
1423
+ const existing = this._resolveDefault('models') || {};
1424
+ await api.saveLlmModel(resolvedKey, {
1425
+ provider: existing.provider || existing._provider || 'local',
1426
+ model: existing.model,
1427
+ contextSize: newCtx,
1428
+ maxTokens: newMaxTokens,
1429
+ ...(newReasoningBudget !== undefined ? { reasoningBudget: newReasoningBudget } : existing.reasoningBudget != null ? { reasoningBudget: existing.reasoningBudget } : {}),
1430
+ ...(existing.temperature != null ? { temperature: existing.temperature } : {}),
1431
+ });
1432
+
1433
+ if (needsRestart) {
1434
+ await api.stopLocalLlm(this._selectedEngine);
1435
+ const activeModelPath = this.status?.engines?.[this._selectedEngine]?.chat?.activeModel;
1436
+ const model = this.models.find(m => activeModelPath === m.filePath);
1437
+ if (model) {
1438
+ await api.activateLocalModel(model.id);
1439
+ }
1440
+ }
1441
+
1442
+ await this.loadLlmConfig();
1443
+ await this.refresh();
1444
+ } catch (e) {
1445
+ console.error('Failed to apply settings:', e);
1446
+ if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-save mr-1"></i>Apply'; }
1447
+ }
1448
+ }
1449
+
1450
+ async updateBinary() {
1451
+ const btn = this.querySelector('#updateBinaryBtn');
1452
+ if (btn) {
1453
+ btn.disabled = true;
1454
+ btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i>Updating...';
1455
+ }
1456
+ try {
1457
+ const isMlx = this._selectedEngine === 'mlx-serve';
1458
+ if (isMlx) {
1459
+ await api.updateMlxBinary();
1460
+ this.mlxUpdateInfo = null;
1461
+ } else {
1462
+ await api.updateLlamaBinary();
1463
+ this.updateInfo = null;
1464
+ }
1465
+ await this.refresh();
1466
+ } catch (e) {
1467
+ console.error(`Failed to update ${this._selectedEngine === 'mlx-serve' ? 'mlx-serve' : 'llama-server'}:`, e);
1468
+ if (btn) {
1469
+ btn.disabled = false;
1470
+ btn.innerHTML = '<i class="fas fa-exclamation-circle mr-1 text-red"></i>Failed';
1471
+ setTimeout(() => { btn.innerHTML = '<i class="fas fa-arrows-rotate mr-1"></i>Update'; }, 3000);
1472
+ }
1473
+ }
1474
+ }
1475
+
1476
+ async loadLlmConfig() {
1477
+ try {
1478
+ this.llmConfig = await api.getLlmConfig();
1479
+ const defaultModel = this._resolveDefault('models');
1480
+ if (defaultModel?._provider) {
1481
+ this.activeProvider = defaultModel._provider;
1482
+ }
1483
+ this.renderProviderTabs();
1484
+ this.switchProviderView(this.activeProvider);
1485
+ } catch (e) {
1486
+ console.error('Failed to load LLM config:', e);
1487
+ }
1488
+ }
1489
+
1490
+ renderProviderTabs() {
1491
+ const container = this.querySelector('#providerTabs');
1492
+ if (!container) return;
1493
+
1494
+ const defaultProvider = this._resolveDefault('models')?._provider || 'local';
1495
+
1496
+ container.innerHTML = PROVIDERS.map(p => {
1497
+ const meta = PROVIDER_META[p];
1498
+ const isActive = this.activeProvider === p;
1499
+ const isDefault = defaultProvider === p;
1500
+ return `
1501
+ <button class="llm-provider-tab ${isActive ? 'active' : ''}" data-provider="${p}">
1502
+ <span class="text-${meta.color}">${providerIcon(p)}</span>
1503
+ <span>${meta.label}</span>
1504
+ ${isDefault ? '<span class="default-dot" title="Default provider"></span>' : ''}
1505
+ </button>`;
1506
+ }).join('');
1507
+
1508
+ container.querySelectorAll('.llm-provider-tab').forEach(btn => {
1509
+ btn.addEventListener('click', () => {
1510
+ this.activeProvider = btn.dataset.provider;
1511
+ this.renderProviderTabs();
1512
+ this.switchProviderView(this.activeProvider);
1513
+ });
1514
+ });
1515
+ }
1516
+
1517
+ switchProviderView(provider) {
1518
+ const localEl = this.querySelector('#localContent');
1519
+ const cloudEl = this.querySelector('#cloudContent');
1520
+ if (!localEl || !cloudEl) return;
1521
+
1522
+ if (provider === 'local') {
1523
+ localEl.classList.remove('hidden');
1524
+ cloudEl.classList.add('hidden');
1525
+ } else {
1526
+ localEl.classList.add('hidden');
1527
+ cloudEl.classList.remove('hidden');
1528
+ this.renderCloudConfig(provider);
1529
+ }
1530
+ }
1531
+
1532
+ renderCloudConfig(provider) {
1533
+ const container = this.querySelector('#cloudContent');
1534
+ if (!container) return;
1535
+
1536
+ const meta = PROVIDER_META[provider];
1537
+ const models = POPULAR_MODELS[provider] || [];
1538
+ const embeddings = POPULAR_EMBEDDINGS[provider] || [];
1539
+ const config = this.llmConfig;
1540
+ const defaultModel = this._resolveDefault('models');
1541
+ const isDefault = defaultModel?._provider === provider;
1542
+ const defaultEmbedding = this._resolveDefault('embeddings');
1543
+
1544
+ // Find the config entry for this provider (check default first, then named entries)
1545
+ let modelEntry = null;
1546
+ let modelEntryName = null;
1547
+ if (isDefault) {
1548
+ modelEntry = defaultModel;
1549
+ modelEntryName = this._resolveDefaultKey('models');
1550
+ } else {
1551
+ // Check named entries (skip string pointers)
1552
+ for (const [name, m] of Object.entries(config?.models || {})) {
1553
+ if (typeof m === 'string') continue;
1554
+ if (m._provider === provider && name !== 'default') {
1555
+ modelEntry = m;
1556
+ modelEntryName = name;
1557
+ break;
1558
+ }
1559
+ }
1560
+ }
1561
+
1562
+ const currentModel = modelEntry?.model || models[0] || '';
1563
+ const hasEnvKey = modelEntry?._hasEnvKey || false;
1564
+ const isEnvRef = modelEntry?.apiKey && /^\$\{.+\}$/.test(modelEntry.apiKey);
1565
+ const hasConfigKey = isEnvRef ? false : (modelEntry?.apiKey && !modelEntry.apiKey.startsWith('••••') ? false : !!modelEntry?.apiKey);
1566
+ const envVarName = PROVIDER_ENV_NAMES[provider] || '';
1567
+
1568
+ // Key status
1569
+ let keyStatusHtml = '';
1570
+ if (hasConfigKey) {
1571
+ keyStatusHtml = `<span class="llm-key-status llm-key-status-set"><i class="fas fa-check"></i>Set in config</span>`;
1572
+ } else if (isEnvRef || hasEnvKey) {
1573
+ keyStatusHtml = `<span class="llm-key-status llm-key-status-env"><i class="fas fa-leaf"></i>Using ${envVarName}</span>`;
1574
+ } else {
1575
+ keyStatusHtml = `<span class="llm-key-status llm-key-status-missing"><i class="fas fa-exclamation-triangle"></i>Not configured</span>`;
1576
+ }
1577
+
1578
+ // Model select options
1579
+ const isCustomModel = currentModel && !models.includes(currentModel);
1580
+ const modelOptions = models.map(m =>
1581
+ `<option value="${escapeHtml(m)}" ${m === currentModel ? 'selected' : ''}>${escapeHtml(m)}</option>`
1582
+ ).join('');
1583
+
1584
+ // Embedding select options (for providers that have them)
1585
+ const embSection = embeddings.length > 0 ? (() => {
1586
+ const isEmbDefault = defaultEmbedding?.provider === provider;
1587
+ const currentEmb = isEmbDefault ? defaultEmbedding?.model : embeddings[0];
1588
+ const isCustomEmb = currentEmb && !embeddings.includes(currentEmb);
1589
+ const embOptions = embeddings.map(m =>
1590
+ `<option value="${escapeHtml(m)}" ${m === currentEmb ? 'selected' : ''}>${escapeHtml(m)}</option>`
1591
+ ).join('');
1592
+ return `
1593
+ <div class="llm-form-group">
1594
+ <label class="llm-form-label">Embedding Model</label>
1595
+ <div class="llm-form-row">
1596
+ <select id="cloudEmbModel" class="select">${embOptions}<option value="" ${isCustomEmb ? 'selected' : ''}>Custom...</option></select>
1597
+ <input id="cloudEmbModelCustom" type="text" class="input ${isCustomEmb ? '' : 'hidden'}"
1598
+ placeholder="Custom embedding model name" value="${isCustomEmb ? escapeHtml(currentEmb) : ''}" />
1599
+ </div>
1600
+ </div>`;
1601
+ })() : '';
1602
+
1603
+ container.innerHTML = `
1604
+ <div class="llm-config-card">
1605
+ <div class="flex items-center justify-between mb-4">
1606
+ <div class="flex items-center gap-2">
1607
+ <span class="text-${meta.color}">${providerIcon(provider)}</span>
1608
+ <span class="font-semibold text-primary">${meta.label} Configuration</span>
1609
+ </div>
1610
+ ${isDefault
1611
+ ? `<span class="badge badge-green"><i class="fas fa-check mr-1"></i>Default</span>`
1612
+ : `<button id="setDefaultBtn" class="btn btn-accent btn-sm"><i class="fas fa-star mr-1"></i>Set as Default</button>`}
1613
+ </div>
1614
+
1615
+ <div class="llm-cloud-form">
1616
+ <div class="llm-form-group">
1617
+ <div class="flex items-center justify-between">
1618
+ <label class="llm-form-label">API Key</label>
1619
+ ${keyStatusHtml}
1620
+ </div>
1621
+ <input id="cloudApiKey" type="password" class="input" placeholder="${envVarName ? `Or set ${envVarName} env var` : 'Enter API key'}"
1622
+ value="${hasConfigKey ? (modelEntry?.apiKey || '') : ''}" />
1623
+ ${envVarName ? `<span class="llm-form-hint">Environment variable: ${envVarName}</span>` : ''}
1624
+ </div>
1625
+
1626
+ <div class="llm-form-group">
1627
+ <label class="llm-form-label">Chat Model</label>
1628
+ <div class="llm-form-row">
1629
+ <select id="cloudModel" class="select">${modelOptions}<option value="" ${isCustomModel ? 'selected' : ''}>Custom...</option></select>
1630
+ <input id="cloudModelCustom" type="text" class="input ${isCustomModel ? '' : 'hidden'}"
1631
+ placeholder="Custom model name" value="${isCustomModel ? escapeHtml(currentModel) : ''}" />
1632
+ </div>
1633
+ </div>
1634
+
1635
+ ${embSection}
1636
+
1637
+ <div class="llm-form-group">
1638
+ <label class="llm-form-label">Advanced</label>
1639
+ <div class="llm-form-row">
1640
+ <div class="llm-form-group">
1641
+ <label class="llm-form-label">Temperature</label>
1642
+ <input id="cloudTemp" type="number" class="input" min="0" max="2" step="0.1"
1643
+ placeholder="Default" value="${modelEntry?.temperature ?? ''}" />
1644
+ </div>
1645
+ <div class="llm-form-group">
1646
+ <label class="llm-form-label">Max Tokens</label>
1647
+ <input id="cloudMaxTokens" type="number" class="input" min="1"
1648
+ placeholder="Default" value="${modelEntry?.maxTokens ?? ''}" />
1649
+ </div>
1650
+ </div>
1651
+ ${provider === 'anthropic' ? `
1652
+ <div class="llm-form-group">
1653
+ <label class="llm-form-label">Thinking Budget</label>
1654
+ <input id="cloudThinkingBudget" type="number" class="input" min="0"
1655
+ placeholder="0 = disabled" value="${modelEntry?.thinkingBudget ?? ''}" />
1656
+ </div>` : ''}
1657
+ <div class="llm-form-group">
1658
+ <label class="llm-form-label">Base URL</label>
1659
+ <input id="cloudBaseUrl" type="text" class="input"
1660
+ placeholder="Default (leave empty for standard API)" value="${modelEntry?.baseUrl ? escapeHtml(modelEntry.baseUrl) : ''}" />
1661
+ </div>
1662
+ </div>
1663
+
1664
+ <div id="cloudSaveStatus" class="text-xs text-muted"></div>
1665
+
1666
+ <div class="flex items-center gap-2">
1667
+ <button id="cloudSaveBtn" class="btn btn-accent">
1668
+ <i class="fas fa-save mr-1"></i>Save Configuration
1669
+ </button>
1670
+ </div>
1671
+ </div>
1672
+ </div>
1673
+
1674
+ ${this.renderNamedConfigs(provider)}
1675
+ `;
1676
+
1677
+ // Wire up events
1678
+ const modelSelect = container.querySelector('#cloudModel');
1679
+ const modelCustom = container.querySelector('#cloudModelCustom');
1680
+ modelSelect?.addEventListener('change', () => {
1681
+ modelCustom.classList.toggle('hidden', modelSelect.value !== '');
1682
+ if (modelSelect.value === '') modelCustom.focus();
1683
+ });
1684
+
1685
+ const embSelect = container.querySelector('#cloudEmbModel');
1686
+ const embCustom = container.querySelector('#cloudEmbModelCustom');
1687
+ embSelect?.addEventListener('change', () => {
1688
+ embCustom?.classList.toggle('hidden', embSelect.value !== '');
1689
+ if (embSelect.value === '') embCustom?.focus();
1690
+ });
1691
+
1692
+ container.querySelector('#cloudSaveBtn')?.addEventListener('click', () => this.saveCloudConfig(provider));
1693
+ container.querySelector('#setDefaultBtn')?.addEventListener('click', () => this.setProviderAsDefault(provider));
1694
+ }
1695
+
1696
+ renderNamedConfigs(excludeProvider) {
1697
+ if (!this.llmConfig?.models) return '';
1698
+ const entries = Object.entries(this.llmConfig.models)
1699
+ .filter(([name, m]) => name !== 'default' && typeof m !== 'string' && m._provider !== excludeProvider);
1700
+ if (!entries.length) return '';
1701
+
1702
+ const rows = entries.map(([name, m]) => `
1703
+ <div class="llm-named-row">
1704
+ <span class="name">${escapeHtml(name)}</span>
1705
+ <span class="badge badge-${PROVIDER_META[m._provider]?.color || 'gray'}">${PROVIDER_META[m._provider]?.label || m._provider}</span>
1706
+ <span class="model">${escapeHtml(m.model)}</span>
1707
+ </div>`).join('');
1708
+
1709
+ return `
1710
+ <div class="mt-4">
1711
+ <h4 class="text-xs font-medium text-muted mb-2">Other Named Configs</h4>
1712
+ <div class="llm-named-configs">${rows}</div>
1713
+ </div>`;
1714
+ }
1715
+
1716
+ async saveCloudConfig(provider) {
1717
+ const btn = this.querySelector('#cloudSaveBtn');
1718
+ const statusEl = this.querySelector('#cloudSaveStatus');
1719
+ if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i>Saving...'; }
1720
+
1721
+ try {
1722
+ const modelSelect = this.querySelector('#cloudModel');
1723
+ const modelCustom = this.querySelector('#cloudModelCustom');
1724
+ const model = modelSelect?.value || modelCustom?.value?.trim();
1725
+ if (!model) throw new Error('Please select or enter a model name');
1726
+
1727
+ const apiKey = this.querySelector('#cloudApiKey')?.value?.trim() || (PROVIDER_ENV_NAMES[provider] ? `\${${PROVIDER_ENV_NAMES[provider]}}` : undefined);
1728
+ const temp = this.querySelector('#cloudTemp')?.value;
1729
+ const maxTokens = this.querySelector('#cloudMaxTokens')?.value;
1730
+ const baseUrl = this.querySelector('#cloudBaseUrl')?.value?.trim() || undefined;
1731
+ const thinkingBudget = this.querySelector('#cloudThinkingBudget')?.value;
1732
+
1733
+ const config = {
1734
+ provider,
1735
+ model,
1736
+ ...(apiKey ? { apiKey } : {}),
1737
+ ...(baseUrl ? { baseUrl } : {}),
1738
+ ...(temp !== '' && temp != null ? { temperature: parseFloat(temp) } : {}),
1739
+ ...(maxTokens !== '' && maxTokens != null ? { maxTokens: parseInt(maxTokens) } : {}),
1740
+ ...(thinkingBudget !== '' && thinkingBudget != null ? { thinkingBudget: parseInt(thinkingBudget) } : {}),
1741
+ };
1742
+
1743
+ // Write config to the provider's named key, then set default pointer
1744
+ await api.saveLlmModel(provider, config);
1745
+ await api.saveLlmModel('default', { _pointer: provider });
1746
+
1747
+ // Save embedding config if applicable
1748
+ const embSelect = this.querySelector('#cloudEmbModel');
1749
+ const embCustom = this.querySelector('#cloudEmbModelCustom');
1750
+ const embModel = embSelect?.value || embCustom?.value?.trim();
1751
+ if (embModel) {
1752
+ await api.saveLlmEmbedding(provider, {
1753
+ provider,
1754
+ model: embModel,
1755
+ ...(apiKey ? { apiKey } : {}),
1756
+ ...(baseUrl ? { baseUrl } : {}),
1757
+ });
1758
+ await api.saveLlmEmbedding('default', { _pointer: provider });
1759
+ }
1760
+
1761
+ await this.loadLlmConfig();
1762
+ if (statusEl) { statusEl.innerHTML = '<i class="fas fa-check text-green mr-1"></i>Saved'; }
1763
+ setTimeout(() => { if (statusEl) statusEl.innerHTML = ''; }, 3000);
1764
+ } catch (e) {
1765
+ console.error('Failed to save config:', e);
1766
+ if (statusEl) { statusEl.innerHTML = `<i class="fas fa-exclamation-circle text-red mr-1"></i>${escapeHtml(e.message)}`; }
1767
+ } finally {
1768
+ if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-save mr-1"></i>Save Configuration'; }
1769
+ }
1770
+ }
1771
+
1772
+ async setProviderAsDefault(provider) {
1773
+ const btn = this.querySelector('#setDefaultBtn');
1774
+ if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i>Switching...'; }
1775
+
1776
+ try {
1777
+ // Find the config for this provider
1778
+ const modelSelect = this.querySelector('#cloudModel');
1779
+ const modelCustom = this.querySelector('#cloudModelCustom');
1780
+ const model = modelSelect?.value || modelCustom?.value?.trim();
1781
+ if (!model) throw new Error('Please select or enter a model name first');
1782
+
1783
+ const apiKey = this.querySelector('#cloudApiKey')?.value?.trim() || (PROVIDER_ENV_NAMES[provider] ? `\${${PROVIDER_ENV_NAMES[provider]}}` : undefined);
1784
+ const temp = this.querySelector('#cloudTemp')?.value;
1785
+ const maxTokens = this.querySelector('#cloudMaxTokens')?.value;
1786
+ const baseUrl = this.querySelector('#cloudBaseUrl')?.value?.trim() || undefined;
1787
+ const thinkingBudget = this.querySelector('#cloudThinkingBudget')?.value;
1788
+
1789
+ // Save config to provider's named key, then set default pointer
1790
+ await api.saveLlmModel(provider, {
1791
+ provider,
1792
+ model,
1793
+ ...(apiKey ? { apiKey } : {}),
1794
+ ...(baseUrl ? { baseUrl } : {}),
1795
+ ...(temp !== '' && temp != null ? { temperature: parseFloat(temp) } : {}),
1796
+ ...(maxTokens !== '' && maxTokens != null ? { maxTokens: parseInt(maxTokens) } : {}),
1797
+ ...(thinkingBudget !== '' && thinkingBudget != null ? { thinkingBudget: parseInt(thinkingBudget) } : {}),
1798
+ });
1799
+ await api.saveLlmModel('default', { _pointer: provider });
1800
+
1801
+ await this.loadLlmConfig();
1802
+ } catch (e) {
1803
+ console.error('Failed to set default:', e);
1804
+ if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-star mr-1"></i>Set as Default'; }
1805
+ }
1806
+ }
1807
+
1808
+ renderModels() {
1809
+ const container = this.querySelector('#modelsGrid');
1810
+ if (!container) return;
1811
+
1812
+ if (!this.models.length) {
1813
+ container.innerHTML = `
1814
+ <div class="col-span-full text-muted text-center py-8">
1815
+ <i class="fas fa-box-open text-4xl mb-4 block text-muted"></i>
1816
+ <p class="text-lg mb-2">No models downloaded</p>
1817
+ <p class="text-sm">Search HuggingFace below, or download a recommended model</p>
1818
+ </div>`;
1819
+ return;
1820
+ }
1821
+
1822
+ // Collect active models across all engines
1823
+ let activeModelPath = null;
1824
+ let activeEmbPath = null;
1825
+ if (this.status?.engines) {
1826
+ for (const eng of Object.values(this.status.engines)) {
1827
+ if (eng.chat?.running && eng.chat.activeModel) activeModelPath = activeModelPath || eng.chat.activeModel;
1828
+ if (eng.embedding?.running && eng.embedding.activeModel) activeEmbPath = activeEmbPath || eng.embedding.activeModel;
1829
+ }
1830
+ }
1831
+
1832
+ const selectedEng = this._selectedEngine;
1833
+
1834
+ // Filter to only show models matching the selected engine
1835
+ const filteredModels = this.models.filter(model => {
1836
+ if (!selectedEng || !MANAGED_ENGINES.includes(selectedEng)) return true;
1837
+ const modelEngine = model.type === 'mlx' ? 'mlx-serve' : 'llama-cpp';
1838
+ return modelEngine === selectedEng;
1839
+ });
1840
+
1841
+ if (!filteredModels.length) {
1842
+ const formatLabel = selectedEng === 'mlx-serve' ? 'MLX' : 'GGUF';
1843
+ container.innerHTML = `
1844
+ <div class="col-span-full text-muted text-center py-8">
1845
+ <i class="fas fa-box-open text-4xl mb-4 block text-muted"></i>
1846
+ <p class="text-lg mb-2">No ${formatLabel} models downloaded</p>
1847
+ <p class="text-sm">Search HuggingFace below, or download a recommended model</p>
1848
+ </div>`;
1849
+ return;
1850
+ }
1851
+
1852
+ container.innerHTML = filteredModels.map(model => {
1853
+ const isChat = activeModelPath && activeModelPath === model.filePath;
1854
+ const isEmb = activeEmbPath && activeEmbPath === model.filePath;
1855
+ const looksLikeEmbedding = /embed|MiniLM/i.test(model.fileName);
1856
+
1857
+ const cardCls = isChat ? 'llm-model-card active-chat' : isEmb ? 'llm-model-card active-emb' : 'llm-model-card';
1858
+ const caps = looksLikeEmbedding ? null : detectCapabilitiesFromFile(model);
1859
+ const badges = caps ? capabilityBadges(caps) : '';
1860
+
1861
+ return `
1862
+ <div class="${cardCls}" data-model-id="${escapeHtml(model.id)}">
1863
+ <div class="flex items-start justify-between mb-3">
1864
+ <div class="min-w-0 flex-1">
1865
+ <div class="flex items-center gap-2 mb-1">
1866
+ ${isChat ? '<i class="fas fa-circle text-amber text-2xs"></i>' : ''}
1867
+ ${isEmb ? '<i class="fas fa-circle text-blue text-2xs"></i>' : ''}
1868
+ <span class="font-medium text-primary text-sm truncate">${escapeHtml(model.fileName)}</span>
1869
+ <span class="badge badge-${model.type === 'mlx' ? 'green' : 'amber'} text-2xs">${(model.type || 'gguf').toUpperCase()}</span>
1870
+ </div>
1871
+ ${model.repo ? `<div class="text-xs text-muted truncate">${escapeHtml(model.repo)}</div>` : ''}
1872
+ ${badges ? `<div class="flex items-center gap-1 mt-1">${badges}</div>` : ''}
1873
+ </div>
1874
+ </div>
1875
+ <div class="flex items-center justify-between">
1876
+ <div class="flex items-center gap-3 text-xs text-muted">
1877
+ <span><i class="fas fa-hard-drive mr-1"></i>${formatBytes(model.sizeBytes)}</span>
1878
+ <span>${timeAgo(model.downloadedAt)}</span>
1879
+ </div>
1880
+ <div class="flex items-center gap-2">
1881
+ ${looksLikeEmbedding
1882
+ ? (isEmb
1883
+ ? '<span class="badge badge-blue">Embedding</span>'
1884
+ : `<button class="activate-emb-btn btn btn-blue btn-sm" data-id="${escapeHtml(model.id)}" title="Activate as embedding model">
1885
+ <i class="fas fa-vector-square mr-1"></i>Embed
1886
+ </button>`)
1887
+ : (isChat
1888
+ ? '<span class="badge badge-amber">Active</span>'
1889
+ : `<button class="activate-btn btn btn-amber btn-sm" data-id="${escapeHtml(model.id)}" title="Activate as chat model">
1890
+ <i class="fas fa-play mr-1"></i>Activate
1891
+ </button>`)
1892
+ }
1893
+ ${!isChat && !isEmb
1894
+ ? `<button class="delete-btn text-xs text-muted transition-colors" data-id="${escapeHtml(model.id)}" title="Delete model">
1895
+ <i class="fas fa-trash-alt"></i>
1896
+ </button>`
1897
+ : ''
1898
+ }
1899
+ </div>
1900
+ </div>
1901
+ </div>`;
1902
+ }).join('');
1903
+
1904
+ container.querySelectorAll('.activate-btn').forEach(btn => {
1905
+ btn.addEventListener('click', (e) => {
1906
+ e.stopPropagation();
1907
+ this.activateModel(btn.dataset.id);
1908
+ });
1909
+ });
1910
+
1911
+ container.querySelectorAll('.activate-emb-btn').forEach(btn => {
1912
+ btn.addEventListener('click', (e) => {
1913
+ e.stopPropagation();
1914
+ this.activateEmbedding(btn.dataset.id);
1915
+ });
1916
+ });
1917
+
1918
+ container.querySelectorAll('.delete-btn').forEach(btn => {
1919
+ btn.addEventListener('click', (e) => {
1920
+ e.stopPropagation();
1921
+ this.deleteModel(btn.dataset.id);
1922
+ });
1923
+ });
1924
+ }
1925
+
1926
+ renderRecommendations() {
1927
+ const container = this.querySelector('#recommendedSection');
1928
+ if (!container) return;
1929
+
1930
+ const recommended = getRecommendedModels(this.status);
1931
+ const pending = recommended.filter(r => !this.isModelDownloaded(r.repo, r.file));
1932
+ if (!pending.length) {
1933
+ container.innerHTML = '';
1934
+ return;
1935
+ }
1936
+
1937
+ container.innerHTML = `
1938
+ <h3 class="section-title mb-3">Recommended Models</h3>
1939
+ <div class="llm-rec-grid">
1940
+ ${pending.map(r => {
1941
+ const downloadId = r.type === 'mlx' ? `mlx:${r.repo}` : `${r.repo}/${r.file}`;
1942
+ const isDownloading = this.activeDownloads.has(downloadId);
1943
+ return `
1944
+ <div class="llm-rec-card llm-rec-card-${r.color}">
1945
+ <div class="flex items-center gap-2 mb-2">
1946
+ <i class="fa${r.icon === 'fa-apple' ? 'b' : 's'} ${r.icon} text-${r.color} text-sm"></i>
1947
+ <span class="font-medium text-primary text-sm">${escapeHtml(r.label)}</span>
1948
+ <span class="badge badge-${r.type === 'mlx' ? 'green' : 'amber'} text-2xs">${r.type.toUpperCase()}</span>
1949
+ </div>
1950
+ <p class="text-xs text-muted mb-3">${escapeHtml(r.desc)}</p>
1951
+ <div class="flex items-center justify-between">
1952
+ <span class="text-xs text-muted">${r.size}</span>
1953
+ <button class="rec-download-btn btn btn-${r.color} btn-sm"
1954
+ data-repo="${escapeHtml(r.repo)}" data-file="${escapeHtml(r.file)}" data-type="${r.type}" ${isDownloading ? 'disabled' : ''}>
1955
+ ${isDownloading
1956
+ ? '<i class="fas fa-spinner fa-spin mr-1"></i>Downloading...'
1957
+ : '<i class="fas fa-download mr-1"></i>Download'}
1958
+ </button>
1959
+ </div>
1960
+ </div>`;
1961
+ }).join('')}
1962
+ </div>`;
1963
+
1964
+ container.querySelectorAll('.rec-download-btn').forEach(btn => {
1965
+ btn.addEventListener('click', () => {
1966
+ btn.disabled = true;
1967
+ btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i>Downloading...';
1968
+ this.downloadModel(btn.dataset.repo, btn.dataset.file, null, btn.dataset.type);
1969
+ this.startDownloadPolling();
1970
+ });
1971
+ });
1972
+ }
1973
+
1974
+ async activateModel(id) {
1975
+ const gridBtn = this.querySelector(`.activate-btn[data-id="${id}"]`);
1976
+ const startBtn = this.querySelector('#startLastBtn');
1977
+ const card = gridBtn?.closest('[data-model-id]');
1978
+ const spinnerHtml = '<span class="spinner-sm"></span>';
1979
+ if (gridBtn) {
1980
+ gridBtn.disabled = true;
1981
+ gridBtn.innerHTML = `${spinnerHtml} Starting...`;
1982
+ }
1983
+ if (startBtn) {
1984
+ startBtn.disabled = true;
1985
+ startBtn.innerHTML = `${spinnerHtml} Starting...`;
1986
+ }
1987
+ // Clear any previous error
1988
+ card?.querySelector('.activate-error')?.remove();
1989
+
1990
+ try {
1991
+ const result = await api.activateLocalModel(id);
1992
+ if (result.error) {
1993
+ throw new Error(result.error);
1994
+ }
1995
+ await this.loadLlmConfig();
1996
+ await this.refresh();
1997
+ this.renderEngineTabs();
1998
+ } catch (e) {
1999
+ console.error('Failed to activate model:', e);
2000
+ if (gridBtn) {
2001
+ gridBtn.disabled = false;
2002
+ gridBtn.innerHTML = '<i class="fas fa-play mr-1"></i>Activate';
2003
+ }
2004
+ if (startBtn) {
2005
+ startBtn.disabled = false;
2006
+ startBtn.innerHTML = '<i class="fas fa-play mr-1"></i>Start';
2007
+ }
2008
+ if (card) {
2009
+ const errorEl = document.createElement('div');
2010
+ errorEl.className = 'activate-error';
2011
+ errorEl.innerHTML = `<i class="fas fa-exclamation-circle mr-1"></i>${escapeHtml(e.message)}`;
2012
+ card.appendChild(errorEl);
2013
+ }
2014
+ }
2015
+ }
2016
+
2017
+ async deleteModel(id) {
2018
+ if (!confirm('Delete this model? This cannot be undone.')) return;
2019
+
2020
+ try {
2021
+ await api.deleteLocalModel(id);
2022
+ await this.loadModels();
2023
+ } catch (e) {
2024
+ console.error('Failed to delete model:', e);
2025
+ }
2026
+ }
2027
+
2028
+ async activateEmbedding(id) {
2029
+ const btn = this.querySelector(`.activate-emb-btn[data-id="${id}"]`);
2030
+ const card = btn?.closest('[data-model-id]');
2031
+ if (btn) {
2032
+ btn.disabled = true;
2033
+ btn.innerHTML = '<span class="spinner-sm"></span> Loading...';
2034
+ }
2035
+ card?.querySelector('.activate-error')?.remove();
2036
+
2037
+ try {
2038
+ const result = await api.activateLocalEmbedding(id);
2039
+ if (result.error) {
2040
+ throw new Error(result.error);
2041
+ }
2042
+ await this.refresh();
2043
+ } catch (e) {
2044
+ console.error('Failed to activate embedding model:', e);
2045
+ if (btn) {
2046
+ btn.disabled = false;
2047
+ btn.innerHTML = '<i class="fas fa-vector-square mr-1"></i>Embed';
2048
+ }
2049
+ if (card) {
2050
+ const errorEl = document.createElement('div');
2051
+ errorEl.className = 'activate-error';
2052
+ errorEl.innerHTML = `<i class="fas fa-exclamation-circle mr-1"></i>${escapeHtml(e.message)}`;
2053
+ card.appendChild(errorEl);
2054
+ }
2055
+ }
2056
+ }
2057
+
2058
+ async stopServer() {
2059
+ const btn = this.querySelector('#stopBtn');
2060
+ if (btn) {
2061
+ btn.disabled = true;
2062
+ btn.textContent = 'Stopping...';
2063
+ }
2064
+
2065
+ try {
2066
+ await api.stopLocalLlm(this._selectedEngine);
2067
+ await this.refresh();
2068
+ } catch (e) {
2069
+ console.error('Failed to stop server:', e);
2070
+ }
2071
+ }
2072
+
2073
+ async stopEmbedding() {
2074
+ const btn = this.querySelector('#stopEmbBtn');
2075
+ if (btn) {
2076
+ btn.disabled = true;
2077
+ btn.textContent = 'Stopping...';
2078
+ }
2079
+
2080
+ try {
2081
+ await api.stopLocalEmbedding(this._selectedEngine);
2082
+ await this.refresh();
2083
+ } catch (e) {
2084
+ console.error('Failed to stop embedding:', e);
2085
+ }
2086
+ }
2087
+
2088
+ async searchHuggingFace() {
2089
+ const input = this.querySelector('#hfSearchInput');
2090
+ const query = input?.value?.trim();
2091
+ if (!query) return;
2092
+
2093
+ const formatSelect = this.querySelector('#hfFormatSelect');
2094
+ this._browseFormat = formatSelect?.value || 'gguf';
2095
+
2096
+ const container = this.querySelector('#hfResults');
2097
+ const btn = this.querySelector('#hfSearchBtn');
2098
+ container.innerHTML = '<div class="text-muted text-center py-8"><i class="fas fa-spinner fa-spin mr-2"></i>Searching HuggingFace...</div>';
2099
+ btn.disabled = true;
2100
+
2101
+ try {
2102
+ this.searchResults = await api.browseHuggingFace(query, 10, this._browseFormat);
2103
+ this.renderSearchResults();
2104
+ } catch (e) {
2105
+ container.innerHTML = `<div class="text-red text-center py-4">Error: ${escapeHtml(e.message)}</div>`;
2106
+ } finally {
2107
+ btn.disabled = false;
2108
+ }
2109
+ }
2110
+
2111
+ selectedFileForRow(idx) {
2112
+ const select = this.querySelector(`#gguf-select-${idx}`);
2113
+ if (!select) return null;
2114
+ const fileName = select.value;
2115
+ const result = this.searchResults[idx];
2116
+ return result?.ggufFiles.find(f => f.fileName === fileName) ?? null;
2117
+ }
2118
+
2119
+ updateRowDownloadState(idx) {
2120
+ const btn = this.querySelector(`.download-btn[data-idx="${idx}"]`);
2121
+ if (!btn) return;
2122
+ const result = this.searchResults[idx];
2123
+ const file = this.selectedFileForRow(idx);
2124
+ if (!result || !file) return;
2125
+
2126
+ const downloaded = this.isModelDownloaded(result.repoId, file.fileName);
2127
+ const downloadId = `${result.repoId}/${file.fileName}`;
2128
+ const isDownloading = this.activeDownloads.has(downloadId);
2129
+
2130
+ // Always clear download-in-progress classes first
2131
+ btn.classList.remove('opacity-50', 'pointer-events-none');
2132
+
2133
+ if (downloaded) {
2134
+ btn.disabled = true;
2135
+ btn.classList.remove('btn-amber');
2136
+ btn.classList.add('btn-green', 'cursor-not-allowed');
2137
+ btn.innerHTML = '<i class="fas fa-check mr-1"></i>Downloaded';
2138
+ } else if (isDownloading) {
2139
+ btn.classList.add('opacity-50', 'pointer-events-none');
2140
+ btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
2141
+ } else {
2142
+ btn.disabled = false;
2143
+ btn.classList.remove('btn-green', 'cursor-not-allowed');
2144
+ btn.classList.add('btn-amber');
2145
+ btn.innerHTML = '<i class="fas fa-download mr-1"></i>Download';
2146
+ }
2147
+ }
2148
+
2149
+ updateRowWarning(idx) {
2150
+ const warning = this.querySelector(`.ram-warning[data-idx="${idx}"]`);
2151
+ if (!warning) return;
2152
+ const file = this.selectedFileForRow(idx);
2153
+ if (file && this.systemRamBytes > 0 && file.sizeBytes > this.systemRamBytes) {
2154
+ warning.classList.remove('hidden');
2155
+ } else {
2156
+ warning.classList.add('hidden');
2157
+ }
2158
+ }
2159
+
2160
+ isModelDownloaded(repoId, fileName) {
2161
+ // For MLX repos, match by repo ID (fileName is __mlx_repo__ sentinel)
2162
+ if (fileName === '__mlx_repo__') {
2163
+ return this.models.some(m => m.repo === repoId && m.type === 'mlx');
2164
+ }
2165
+ return this.models.some(m => m.repo === repoId && m.fileName === fileName);
2166
+ }
2167
+
2168
+ renderSearchResults() {
2169
+ const container = this.querySelector('#hfResults');
2170
+ if (!container) return;
2171
+
2172
+ const isMlx = this._browseFormat === 'mlx';
2173
+ const emptyLabel = isMlx ? 'MLX' : 'GGUF';
2174
+
2175
+ if (!this.searchResults.length) {
2176
+ container.innerHTML = `<div class="text-muted text-center py-8">No ${emptyLabel} models found for this query.</div>`;
2177
+ return;
2178
+ }
2179
+
2180
+ // Filter out results with no files
2181
+ const resultsWithFiles = this.searchResults.filter(r => r.ggufFiles.length > 0);
2182
+ if (!resultsWithFiles.length) {
2183
+ container.innerHTML = `<div class="text-muted text-center py-8">No ${emptyLabel} files found in the results.</div>`;
2184
+ return;
2185
+ }
2186
+
2187
+ const ram = this.systemRamBytes;
2188
+
2189
+ const rows = this.searchResults.map((result, idx) => {
2190
+ if (result.ggufFiles.length === 0) return '';
2191
+
2192
+ const caps = detectCapabilities(result);
2193
+ const capIcons = [
2194
+ caps.tools ? '<i class="fas fa-wrench text-green" title="Tool calling"></i>' : '<i class="fas fa-wrench text-muted" title="No tool calling"></i>',
2195
+ caps.vision ? '<i class="fas fa-eye text-blue" title="Vision"></i>' : '<i class="fas fa-eye text-muted" title="No vision"></i>',
2196
+ caps.reasoning ? '<i class="fas fa-brain text-purple" title="Reasoning / Thinking"></i>' : '<i class="fas fa-brain text-muted" title="No reasoning"></i>',
2197
+ ].join('');
2198
+
2199
+ if (isMlx) {
2200
+ // MLX: single entry per repo, no file select
2201
+ const totalSize = result.ggufFiles[0]?.sizeBytes || 0;
2202
+ const downloaded = this.isModelDownloaded(result.repoId, '__mlx_repo__');
2203
+ const tooLarge = ram > 0 && totalSize > ram;
2204
+
2205
+ return `
2206
+ <div class="hf-result-row" data-idx="${idx}">
2207
+ <div class="min-w-0 flex-shrink-0">
2208
+ <div class="font-medium text-primary text-sm truncate" title="${escapeHtml(result.repoId)}">${escapeHtml(result.modelName)}</div>
2209
+ <div class="text-xs text-muted truncate">${escapeHtml(result.author)}</div>
2210
+ </div>
2211
+ <div class="flex items-center gap-2 text-xs flex-shrink-0">
2212
+ <span class="text-muted" title="Downloads"><i class="fas fa-download mr-1"></i>${result.downloads?.toLocaleString() ?? 0}</span>
2213
+ <span class="flex items-center gap-1">${capIcons}</span>
2214
+ </div>
2215
+ <span class="text-xs text-muted">${formatBytes(totalSize)}</span>
2216
+ ${tooLarge ? '<span class="ram-warning ram-warning-badge" title="Exceeds system RAM"><i class="fas fa-memory mr-1"></i>won\'t fit</span>' : ''}
2217
+ <div class="flex items-center gap-2 flex-shrink-0">
2218
+ <button class="download-btn btn btn-sm ${downloaded ? 'btn-green cursor-not-allowed' : 'btn-amber'}" data-idx="${idx}" data-type="mlx" ${downloaded ? 'disabled' : ''}>
2219
+ ${downloaded ? '<i class="fas fa-check mr-1"></i>Downloaded' : '<i class="fas fa-download mr-1"></i>Download'}
2220
+ </button>
2221
+ </div>
2222
+ </div>`;
2223
+ }
2224
+
2225
+ // GGUF: file select
2226
+ const options = result.ggufFiles.map(f =>
2227
+ `<option value="${escapeHtml(f.fileName)}" data-size="${f.sizeBytes}">${escapeHtml(f.fileName)} (${formatBytes(f.sizeBytes)})</option>`
2228
+ ).join('');
2229
+
2230
+ const firstFile = result.ggufFiles[0];
2231
+ const firstTooLarge = ram > 0 && firstFile.sizeBytes > ram;
2232
+ const firstDownloaded = this.isModelDownloaded(result.repoId, firstFile.fileName);
2233
+
2234
+ return `
2235
+ <div class="hf-result-row" data-idx="${idx}">
2236
+ <div class="min-w-0 flex-shrink-0">
2237
+ <div class="font-medium text-primary text-sm truncate" title="${escapeHtml(result.repoId)}">${escapeHtml(result.modelName)}</div>
2238
+ <div class="text-xs text-muted truncate">${escapeHtml(result.author)}</div>
2239
+ </div>
2240
+ <div class="flex items-center gap-2 text-xs flex-shrink-0">
2241
+ <span class="text-muted" title="Downloads"><i class="fas fa-download mr-1"></i>${result.downloads?.toLocaleString() ?? 0}</span>
2242
+ <span class="flex items-center gap-1">${capIcons}</span>
2243
+ </div>
2244
+ <select class="gguf-select hf-select" id="gguf-select-${idx}">
2245
+ ${options}
2246
+ </select>
2247
+ <span class="ram-warning ram-warning-badge ${firstTooLarge ? '' : 'hidden'}" data-idx="${idx}" title="File size exceeds system RAM (${formatBytes(ram)})"><i class="fas fa-memory mr-1"></i>won't fit</span>
2248
+ <div class="flex items-center gap-2 flex-shrink-0">
2249
+ <button class="download-btn btn btn-sm ${firstDownloaded
2250
+ ? 'btn-green cursor-not-allowed'
2251
+ : 'btn-amber'}" data-idx="${idx}" ${firstDownloaded ? 'disabled' : ''}>
2252
+ ${firstDownloaded
2253
+ ? '<i class="fas fa-check mr-1"></i>Downloaded'
2254
+ : '<i class="fas fa-download mr-1"></i>Download'}
2255
+ </button>
2256
+ </div>
2257
+ </div>`;
2258
+ }).join('');
2259
+
2260
+ container.innerHTML = `<div class="space-y-2">${rows}</div>`;
2261
+
2262
+ // Update RAM warning and download button when select changes (GGUF only)
2263
+ container.querySelectorAll('.gguf-select').forEach(select => {
2264
+ select.addEventListener('change', () => {
2265
+ const idx = parseInt(select.id.replace('gguf-select-', ''));
2266
+ this.updateRowWarning(idx);
2267
+ this.updateRowDownloadState(idx);
2268
+ });
2269
+ });
2270
+
2271
+ container.querySelectorAll('.download-btn').forEach(btn => {
2272
+ btn.addEventListener('click', () => {
2273
+ const idx = parseInt(btn.dataset.idx);
2274
+ const result = this.searchResults[idx];
2275
+ const dlType = btn.dataset.type || 'gguf';
2276
+
2277
+ if (dlType === 'mlx') {
2278
+ this.downloadModel(result.repoId, '__mlx_repo__', idx, 'mlx');
2279
+ } else {
2280
+ const select = container.querySelector(`#gguf-select-${idx}`);
2281
+ const fileName = select?.value;
2282
+ if (result && fileName) {
2283
+ this.downloadModel(result.repoId, fileName, idx);
2284
+ }
2285
+ }
2286
+ this.startDownloadPolling();
2287
+ });
2288
+ });
2289
+ }
2290
+
2291
+ downloadModel(repo, fileName, rowIdx, type = 'gguf') {
2292
+ const downloadId = type === 'mlx' ? `mlx:${repo}` : `${repo}/${fileName}`;
2293
+ if (this.activeDownloads.has(downloadId)) return;
2294
+
2295
+ const es = api.downloadLocalModel(repo, fileName, type);
2296
+ this.activeDownloads.set(downloadId, es);
2297
+
2298
+ // Update button state for the search result row (if any)
2299
+ if (rowIdx != null) this.updateRowDownloadState(rowIdx);
2300
+
2301
+ es.onmessage = (event) => {
2302
+ try {
2303
+ const data = JSON.parse(event.data);
2304
+
2305
+ if (data.type === 'complete') {
2306
+ this.cleanupDownload(downloadId);
2307
+ if (rowIdx != null) this.updateRowDownloadState(rowIdx);
2308
+ this.loadModels();
2309
+ }
2310
+
2311
+ if (data.type === 'error') {
2312
+ console.error('Download error:', data.error);
2313
+ this.cleanupDownload(downloadId);
2314
+ if (rowIdx != null) this.updateRowDownloadState(rowIdx);
2315
+ }
2316
+ } catch {
2317
+ // ignore parse errors
2318
+ }
2319
+ };
2320
+
2321
+ es.onerror = () => {
2322
+ this.cleanupDownload(downloadId);
2323
+ if (rowIdx != null) this.updateRowDownloadState(rowIdx);
2324
+ };
2325
+ }
2326
+
2327
+ cleanupDownload(downloadId) {
2328
+ const es = this.activeDownloads.get(downloadId);
2329
+ if (es) {
2330
+ es.close();
2331
+ this.activeDownloads.delete(downloadId);
2332
+ }
2333
+ }
2334
+
2335
+ postRender() {
2336
+ this.querySelector('#refreshBtn')?.addEventListener('click', async () => {
2337
+ await this.loadLlmConfig();
2338
+ await this.refresh();
2339
+ });
2340
+
2341
+ this.querySelector('#hfSearchBtn')?.addEventListener('click', () => this.searchHuggingFace());
2342
+ this.querySelector('#hfSearchInput')?.addEventListener('keydown', (e) => {
2343
+ if (e.key === 'Enter') this.searchHuggingFace();
2344
+ });
2345
+
2346
+ // Default to MLX format on Apple Silicon
2347
+ if (isAppleSilicon(this.status)) {
2348
+ const formatSelect = this.querySelector('#hfFormatSelect');
2349
+ if (formatSelect) formatSelect.value = 'mlx';
2350
+ this._browseFormat = 'mlx';
2351
+ }
2352
+ }
2353
+
2354
+ template() {
2355
+ return `
2356
+ <div class="space-y-6 h-full overflow-y-auto pb-6 custom-scrollbar view-panel">
2357
+ <div class="flex items-center justify-between border-b pb-4">
2358
+ <div>
2359
+ <h2 class="text-lg font-semibold text-primary">LLM Configuration</h2>
2360
+ <p class="text-xs text-muted mt-1">Configure your AI model provider</p>
2361
+ </div>
2362
+ <button id="refreshBtn" class="btn-ghost" title="Refresh">
2363
+ <i class="fas fa-sync-alt text-sm"></i>
2364
+ </button>
2365
+ </div>
2366
+
2367
+ <!-- Provider Tabs -->
2368
+ <div id="providerTabs" class="llm-provider-tabs"></div>
2369
+
2370
+ <!-- Cloud Provider Config (hidden by default) -->
2371
+ <div id="cloudContent" class="hidden"></div>
2372
+
2373
+ <!-- Local LLM Content -->
2374
+ <div id="localContent">
2375
+ <!-- Engine Tabs -->
2376
+ <div id="engineTabs" class="llm-engine-tabs"></div>
2377
+
2378
+ <!-- Status Bar (managed engines) -->
2379
+ <div id="statusBar" class="mb-6">
2380
+ <div class="llm-alert">
2381
+ <i class="fas fa-spinner fa-spin text-muted text-sm"></i>
2382
+ <span class="text-sm text-secondary">Loading...</span>
2383
+ </div>
2384
+ </div>
2385
+
2386
+ <!-- External Models (ollama/lmstudio) -->
2387
+ <div id="externalModelsSection" class="hidden mb-6"></div>
2388
+
2389
+ <!-- Active / Interrupted Downloads -->
2390
+ <div id="activeDownloads" class="mb-4"></div>
2391
+ <div id="interruptedDownloads" class="mb-4"></div>
2392
+
2393
+ <!-- Downloaded Models (managed engines) -->
2394
+ <div id="managedModelsSection" class="mb-6">
2395
+ <h3 class="section-title mb-3">Downloaded Models</h3>
2396
+ <div id="modelsGrid" class="llm-model-grid">
2397
+ <div class="text-muted text-center py-8 col-span-full">Loading...</div>
2398
+ </div>
2399
+ </div>
2400
+
2401
+ <!-- Recommended Models (shown when not all are downloaded) -->
2402
+ <div id="recommendedSection" class="mb-6"></div>
2403
+
2404
+ <!-- HuggingFace Browser (managed engines only) -->
2405
+ <div id="hfSection" class="border-t pt-4">
2406
+ <h3 class="section-title mb-3">HuggingFace Browser</h3>
2407
+ <div class="flex gap-2 mb-2">
2408
+ <input id="hfSearchInput" type="text" placeholder="Search models (e.g. Qwen3, Llama, Phi)..."
2409
+ class="input flex-1" />
2410
+ <select id="hfFormatSelect" class="input" style="width: auto; min-width: 90px;">
2411
+ <option value="gguf">GGUF</option>
2412
+ <option value="mlx">MLX</option>
2413
+ </select>
2414
+ <button id="hfSearchBtn" class="btn btn-accent flex-shrink-0">
2415
+ <i class="fas fa-search mr-1"></i>Search
2416
+ </button>
2417
+ </div>
2418
+ <p class="text-xs text-muted mb-4">
2419
+ <i class="fas fa-wrench text-green mr-1"></i>tool calling
2420
+ <span class="mx-2">|</span>
2421
+ <i class="fas fa-eye text-blue mr-1"></i>vision
2422
+ <span class="mx-2">|</span>
2423
+ <i class="fas fa-brain text-purple mr-1"></i>reasoning
2424
+ <span class="mx-2">|</span>
2425
+ <span class="text-muted">gray = not supported</span>
2426
+ </p>
2427
+ <div id="hfResults">
2428
+ <div class="text-muted text-center py-8 text-sm">
2429
+ <i class="fas fa-cube text-2xl mb-3 block text-muted"></i>
2430
+ Search HuggingFace to find and download models
2431
+ </div>
2432
+ </div>
2433
+ </div>
2434
+ </div>
2435
+ </div>
2436
+ `;
2437
+ }
2438
+ }
2439
+
2440
+ customElements.define('local-llm-view', LocalLlmView);