octo-agent 0.11.2

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 (319) hide show
  1. checksums.yaml +7 -0
  2. data/.clacky/skills/commit/SKILL.md +423 -0
  3. data/.clacky/skills/gem-release/SKILL.md +199 -0
  4. data/.clacky/skills/gem-release/scripts/release.sh +304 -0
  5. data/.clacky/skills/oss-upload/SKILL.md +47 -0
  6. data/.octorules +106 -0
  7. data/.rspec +3 -0
  8. data/.rubocop.yml +8 -0
  9. data/CHANGELOG.md +76 -0
  10. data/CODE_OF_CONDUCT.md +132 -0
  11. data/CONTRIBUTING.md +92 -0
  12. data/Dockerfile +28 -0
  13. data/LICENSE.txt +22 -0
  14. data/POSITIONING.md +46 -0
  15. data/README.md +134 -0
  16. data/README_CN.md +134 -0
  17. data/Rakefile +34 -0
  18. data/benchmark/fixtures/sample_project/Gemfile +3 -0
  19. data/benchmark/fixtures/sample_project/lib/api_handler.rb +32 -0
  20. data/benchmark/fixtures/sample_project/lib/order_calculator.rb +23 -0
  21. data/benchmark/fixtures/sample_project/lib/user_renderer.rb +20 -0
  22. data/benchmark/fixtures/sample_project/spec/order_calculator_spec.rb +20 -0
  23. data/benchmark/results/EVALUATION_REPORT.md +165 -0
  24. data/benchmark/results/baseline_20260511_174424.json +128 -0
  25. data/benchmark/results/report_20260511_175256.json +271 -0
  26. data/benchmark/results/report_20260511_175444.json +271 -0
  27. data/benchmark/results/treatment_20260511_175103.json +130 -0
  28. data/benchmark/runner.rb +441 -0
  29. data/bin/octo +7 -0
  30. data/docs/agent-first-ui-design.md +77 -0
  31. data/docs/billing-system.md +318 -0
  32. data/docs/channel-architecture.md +235 -0
  33. data/docs/engineering-article.md +343 -0
  34. data/docs/session-skill-invocation.md +69 -0
  35. data/docs/time_machine_design.md +247 -0
  36. data/docs/ui2-architecture.md +124 -0
  37. data/homebrew/README.md +96 -0
  38. data/homebrew/openocto.rb +24 -0
  39. data/lib/octo/agent/hook_manager.rb +61 -0
  40. data/lib/octo/agent/llm_caller.rb +800 -0
  41. data/lib/octo/agent/memory_updater.rb +246 -0
  42. data/lib/octo/agent/message_compressor.rb +225 -0
  43. data/lib/octo/agent/message_compressor_helper.rb +869 -0
  44. data/lib/octo/agent/next_message_suggester.rb +215 -0
  45. data/lib/octo/agent/session_serializer.rb +685 -0
  46. data/lib/octo/agent/skill_auto_creator.rb +114 -0
  47. data/lib/octo/agent/skill_evolution.rb +61 -0
  48. data/lib/octo/agent/skill_manager.rb +466 -0
  49. data/lib/octo/agent/skill_reflector.rb +89 -0
  50. data/lib/octo/agent/system_prompt_builder.rb +101 -0
  51. data/lib/octo/agent/time_machine.rb +214 -0
  52. data/lib/octo/agent/tool_executor.rb +454 -0
  53. data/lib/octo/agent/tool_registry.rb +150 -0
  54. data/lib/octo/agent.rb +2180 -0
  55. data/lib/octo/agent_config.rb +989 -0
  56. data/lib/octo/agent_profile.rb +112 -0
  57. data/lib/octo/anthropic_stream_aggregator.rb +137 -0
  58. data/lib/octo/background_task_registry.rb +324 -0
  59. data/lib/octo/banner.rb +34 -0
  60. data/lib/octo/bedrock_stream_aggregator.rb +137 -0
  61. data/lib/octo/block_font.rb +331 -0
  62. data/lib/octo/cli.rb +968 -0
  63. data/lib/octo/client.rb +623 -0
  64. data/lib/octo/default_agents/SOUL.md +3 -0
  65. data/lib/octo/default_agents/USER.md +1 -0
  66. data/lib/octo/default_agents/base_prompt.md +66 -0
  67. data/lib/octo/default_agents/coding/profile.yml +2 -0
  68. data/lib/octo/default_agents/coding/system_prompt.md +67 -0
  69. data/lib/octo/default_agents/general/profile.yml +2 -0
  70. data/lib/octo/default_agents/general/system_prompt.md +16 -0
  71. data/lib/octo/default_parsers/doc_parser.rb +69 -0
  72. data/lib/octo/default_parsers/docx_parser.rb +188 -0
  73. data/lib/octo/default_parsers/pdf_parser.rb +120 -0
  74. data/lib/octo/default_parsers/pdf_parser_ocr.py +103 -0
  75. data/lib/octo/default_parsers/pdf_parser_plumber.py +62 -0
  76. data/lib/octo/default_parsers/pptx_parser.rb +140 -0
  77. data/lib/octo/default_parsers/xlsx_parser.rb +121 -0
  78. data/lib/octo/default_skills/browser-setup/SKILL.md +426 -0
  79. data/lib/octo/default_skills/channel-manager/SKILL.md +623 -0
  80. data/lib/octo/default_skills/channel-manager/dingtalk_setup.rb +191 -0
  81. data/lib/octo/default_skills/channel-manager/discord_setup.rb +199 -0
  82. data/lib/octo/default_skills/channel-manager/feishu_setup.rb +574 -0
  83. data/lib/octo/default_skills/channel-manager/import_lark_skills.rb +97 -0
  84. data/lib/octo/default_skills/channel-manager/install_feishu_skills.rb +105 -0
  85. data/lib/octo/default_skills/channel-manager/weixin_setup.rb +274 -0
  86. data/lib/octo/default_skills/code-explorer/SKILL.md +36 -0
  87. data/lib/octo/default_skills/cron-task-creator/SKILL.md +257 -0
  88. data/lib/octo/default_skills/cron-task-creator/evals/evals.json +38 -0
  89. data/lib/octo/default_skills/onboard/SKILL.md +578 -0
  90. data/lib/octo/default_skills/onboard/scripts/import_external_skills.rb +413 -0
  91. data/lib/octo/default_skills/onboard/scripts/install_builtin_skills.rb +97 -0
  92. data/lib/octo/default_skills/persist-memory/SKILL.md +59 -0
  93. data/lib/octo/default_skills/personal-website/SKILL.md +113 -0
  94. data/lib/octo/default_skills/personal-website/publish.rb +235 -0
  95. data/lib/octo/default_skills/product-help/SKILL.md +123 -0
  96. data/lib/octo/default_skills/product-help/docs/agent-config.md +74 -0
  97. data/lib/octo/default_skills/product-help/docs/best-practices.md +49 -0
  98. data/lib/octo/default_skills/product-help/docs/browser-tool.md +53 -0
  99. data/lib/octo/default_skills/product-help/docs/built-in-skills.md +43 -0
  100. data/lib/octo/default_skills/product-help/docs/cli-reference.md +82 -0
  101. data/lib/octo/default_skills/product-help/docs/create-your-first-skill.md +47 -0
  102. data/lib/octo/default_skills/product-help/docs/faq.md +98 -0
  103. data/lib/octo/default_skills/product-help/docs/how-to-use-a-skill.md +58 -0
  104. data/lib/octo/default_skills/product-help/docs/installation.md +59 -0
  105. data/lib/octo/default_skills/product-help/docs/memory-system.md +61 -0
  106. data/lib/octo/default_skills/product-help/docs/octorules.md +62 -0
  107. data/lib/octo/default_skills/product-help/docs/session-management.md +63 -0
  108. data/lib/octo/default_skills/product-help/docs/skill-basics.md +55 -0
  109. data/lib/octo/default_skills/product-help/docs/skill-frontmatter.md +61 -0
  110. data/lib/octo/default_skills/product-help/docs/web-server.md +49 -0
  111. data/lib/octo/default_skills/product-help/docs/what-is-octo.md +37 -0
  112. data/lib/octo/default_skills/product-help/docs/windows-installation.md +36 -0
  113. data/lib/octo/default_skills/product-help/docs/writing-tips.md +53 -0
  114. data/lib/octo/default_skills/recall-memory/SKILL.md +65 -0
  115. data/lib/octo/default_skills/skill-add/SKILL.md +59 -0
  116. data/lib/octo/default_skills/skill-add/scripts/install_from_zip.rb +295 -0
  117. data/lib/octo/default_skills/skill-creator/SKILL.md +602 -0
  118. data/lib/octo/default_skills/skill-creator/agents/analyzer.md +274 -0
  119. data/lib/octo/default_skills/skill-creator/agents/comparator.md +202 -0
  120. data/lib/octo/default_skills/skill-creator/agents/grader.md +223 -0
  121. data/lib/octo/default_skills/skill-creator/eval-viewer/generate_review.py +471 -0
  122. data/lib/octo/default_skills/skill-creator/eval-viewer/viewer.html +1325 -0
  123. data/lib/octo/default_skills/skill-creator/references/schemas.md +430 -0
  124. data/lib/octo/default_skills/skill-creator/scripts/__init__.py +0 -0
  125. data/lib/octo/default_skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
  126. data/lib/octo/default_skills/skill-creator/scripts/generate_report.py +326 -0
  127. data/lib/octo/default_skills/skill-creator/scripts/improve_description.py +310 -0
  128. data/lib/octo/default_skills/skill-creator/scripts/quick_validate.py +103 -0
  129. data/lib/octo/default_skills/skill-creator/scripts/run_eval.py +317 -0
  130. data/lib/octo/default_skills/skill-creator/scripts/run_loop.py +331 -0
  131. data/lib/octo/default_skills/skill-creator/scripts/utils.py +47 -0
  132. data/lib/octo/default_skills/skill-creator/scripts/validate_skill_frontmatter.rb +143 -0
  133. data/lib/octo/idle_compression_timer.rb +115 -0
  134. data/lib/octo/json_ui_controller.rb +204 -0
  135. data/lib/octo/message_format/anthropic.rb +409 -0
  136. data/lib/octo/message_format/bedrock.rb +361 -0
  137. data/lib/octo/message_format/open_ai.rb +222 -0
  138. data/lib/octo/message_history.rb +373 -0
  139. data/lib/octo/openai_stream_aggregator.rb +130 -0
  140. data/lib/octo/plain_ui_controller.rb +166 -0
  141. data/lib/octo/providers.rb +534 -0
  142. data/lib/octo/server/browser_manager.rb +397 -0
  143. data/lib/octo/server/channel/adapters/base.rb +82 -0
  144. data/lib/octo/server/channel/adapters/dingtalk/adapter.rb +314 -0
  145. data/lib/octo/server/channel/adapters/dingtalk/api_client.rb +391 -0
  146. data/lib/octo/server/channel/adapters/dingtalk/stream_client.rb +203 -0
  147. data/lib/octo/server/channel/adapters/discord/adapter.rb +229 -0
  148. data/lib/octo/server/channel/adapters/discord/api_client.rb +107 -0
  149. data/lib/octo/server/channel/adapters/discord/gateway_client.rb +270 -0
  150. data/lib/octo/server/channel/adapters/feishu/adapter.rb +320 -0
  151. data/lib/octo/server/channel/adapters/feishu/bot.rb +478 -0
  152. data/lib/octo/server/channel/adapters/feishu/file_processor.rb +36 -0
  153. data/lib/octo/server/channel/adapters/feishu/message_parser.rb +129 -0
  154. data/lib/octo/server/channel/adapters/feishu/ws_client.rb +423 -0
  155. data/lib/octo/server/channel/adapters/telegram/adapter.rb +375 -0
  156. data/lib/octo/server/channel/adapters/telegram/api_client.rb +205 -0
  157. data/lib/octo/server/channel/adapters/wecom/adapter.rb +148 -0
  158. data/lib/octo/server/channel/adapters/wecom/media_downloader.rb +115 -0
  159. data/lib/octo/server/channel/adapters/wecom/ws_client.rb +395 -0
  160. data/lib/octo/server/channel/adapters/weixin/adapter.rb +692 -0
  161. data/lib/octo/server/channel/adapters/weixin/api_client.rb +402 -0
  162. data/lib/octo/server/channel/channel_config.rb +178 -0
  163. data/lib/octo/server/channel/channel_manager.rb +468 -0
  164. data/lib/octo/server/channel/channel_ui_controller.rb +224 -0
  165. data/lib/octo/server/channel.rb +33 -0
  166. data/lib/octo/server/discover.rb +77 -0
  167. data/lib/octo/server/epipe_safe_io.rb +105 -0
  168. data/lib/octo/server/http_server.rb +3554 -0
  169. data/lib/octo/server/scheduler.rb +317 -0
  170. data/lib/octo/server/server_master.rb +325 -0
  171. data/lib/octo/server/session_registry.rb +431 -0
  172. data/lib/octo/server/web_ui_controller.rb +487 -0
  173. data/lib/octo/session_manager.rb +385 -0
  174. data/lib/octo/skill.rb +466 -0
  175. data/lib/octo/skill_loader.rb +328 -0
  176. data/lib/octo/tools/base.rb +118 -0
  177. data/lib/octo/tools/browser.rb +625 -0
  178. data/lib/octo/tools/edit.rb +165 -0
  179. data/lib/octo/tools/file_reader.rb +549 -0
  180. data/lib/octo/tools/glob.rb +162 -0
  181. data/lib/octo/tools/grep.rb +356 -0
  182. data/lib/octo/tools/invoke_skill.rb +96 -0
  183. data/lib/octo/tools/list_tasks.rb +54 -0
  184. data/lib/octo/tools/redo_task.rb +41 -0
  185. data/lib/octo/tools/request_user_feedback.rb +84 -0
  186. data/lib/octo/tools/security.rb +333 -0
  187. data/lib/octo/tools/terminal/output_cleaner.rb +63 -0
  188. data/lib/octo/tools/terminal/persistent_session.rb +268 -0
  189. data/lib/octo/tools/terminal/safe_rm.sh +106 -0
  190. data/lib/octo/tools/terminal/session_manager.rb +213 -0
  191. data/lib/octo/tools/terminal.rb +1828 -0
  192. data/lib/octo/tools/todo_manager.rb +374 -0
  193. data/lib/octo/tools/trash_manager.rb +388 -0
  194. data/lib/octo/tools/undo_task.rb +35 -0
  195. data/lib/octo/tools/web_fetch.rb +242 -0
  196. data/lib/octo/tools/web_search.rb +260 -0
  197. data/lib/octo/tools/write.rb +77 -0
  198. data/lib/octo/ui2/block_font.rb +10 -0
  199. data/lib/octo/ui2/components/base_component.rb +163 -0
  200. data/lib/octo/ui2/components/command_suggestions.rb +290 -0
  201. data/lib/octo/ui2/components/common_component.rb +96 -0
  202. data/lib/octo/ui2/components/inline_input.rb +226 -0
  203. data/lib/octo/ui2/components/input_area.rb +1338 -0
  204. data/lib/octo/ui2/components/message_component.rb +99 -0
  205. data/lib/octo/ui2/components/modal_component.rb +419 -0
  206. data/lib/octo/ui2/components/todo_area.rb +149 -0
  207. data/lib/octo/ui2/components/tool_component.rb +107 -0
  208. data/lib/octo/ui2/components/welcome_banner.rb +139 -0
  209. data/lib/octo/ui2/layout_manager.rb +807 -0
  210. data/lib/octo/ui2/line_editor.rb +363 -0
  211. data/lib/octo/ui2/markdown_renderer.rb +100 -0
  212. data/lib/octo/ui2/output_buffer.rb +370 -0
  213. data/lib/octo/ui2/progress_handle.rb +362 -0
  214. data/lib/octo/ui2/progress_indicator.rb +55 -0
  215. data/lib/octo/ui2/screen_buffer.rb +273 -0
  216. data/lib/octo/ui2/terminal_detector.rb +119 -0
  217. data/lib/octo/ui2/theme_manager.rb +85 -0
  218. data/lib/octo/ui2/themes/base_theme.rb +105 -0
  219. data/lib/octo/ui2/themes/hacker_theme.rb +62 -0
  220. data/lib/octo/ui2/themes/minimal_theme.rb +56 -0
  221. data/lib/octo/ui2/thinking_verbs.rb +26 -0
  222. data/lib/octo/ui2/ui_controller.rb +1625 -0
  223. data/lib/octo/ui2/view_renderer.rb +177 -0
  224. data/lib/octo/ui2.rb +40 -0
  225. data/lib/octo/ui_interface.rb +154 -0
  226. data/lib/octo/utils/arguments_parser.rb +191 -0
  227. data/lib/octo/utils/browser_detector.rb +195 -0
  228. data/lib/octo/utils/encoding.rb +92 -0
  229. data/lib/octo/utils/environment_detector.rb +140 -0
  230. data/lib/octo/utils/file_ignore_helper.rb +170 -0
  231. data/lib/octo/utils/file_processor.rb +601 -0
  232. data/lib/octo/utils/gitignore_parser.rb +154 -0
  233. data/lib/octo/utils/limit_stack.rb +152 -0
  234. data/lib/octo/utils/logger.rb +124 -0
  235. data/lib/octo/utils/login_shell.rb +72 -0
  236. data/lib/octo/utils/model_pricing.rb +646 -0
  237. data/lib/octo/utils/parser_manager.rb +165 -0
  238. data/lib/octo/utils/path_helper.rb +15 -0
  239. data/lib/octo/utils/scripts_manager.rb +59 -0
  240. data/lib/octo/utils/string_matcher.rb +158 -0
  241. data/lib/octo/utils/trash_directory.rb +112 -0
  242. data/lib/octo/utils/workspace_rules.rb +46 -0
  243. data/lib/octo/version.rb +5 -0
  244. data/lib/octo/web/app.css +7141 -0
  245. data/lib/octo/web/app.js +543 -0
  246. data/lib/octo/web/apple-touch-icon.png +0 -0
  247. data/lib/octo/web/auth.js +150 -0
  248. data/lib/octo/web/channels.js +276 -0
  249. data/lib/octo/web/datepicker.js +205 -0
  250. data/lib/octo/web/favicon.png +0 -0
  251. data/lib/octo/web/i18n.js +1073 -0
  252. data/lib/octo/web/icon-512.png +0 -0
  253. data/lib/octo/web/icon-dark.svg +25 -0
  254. data/lib/octo/web/icon.svg +29 -0
  255. data/lib/octo/web/index.html +871 -0
  256. data/lib/octo/web/marked.min.js +69 -0
  257. data/lib/octo/web/onboard.js +491 -0
  258. data/lib/octo/web/profile.js +442 -0
  259. data/lib/octo/web/sessions.js +4421 -0
  260. data/lib/octo/web/settings.js +913 -0
  261. data/lib/octo/web/sidebar.js +32 -0
  262. data/lib/octo/web/skills.js +885 -0
  263. data/lib/octo/web/tasks.js +297 -0
  264. data/lib/octo/web/theme.js +105 -0
  265. data/lib/octo/web/trash.js +343 -0
  266. data/lib/octo/web/vendor/hljs/highlight.min.js +1244 -0
  267. data/lib/octo/web/vendor/hljs/hljs-theme.css +95 -0
  268. data/lib/octo/web/vendor/katex/auto-render.min.js +1 -0
  269. data/lib/octo/web/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  270. data/lib/octo/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  271. data/lib/octo/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  272. data/lib/octo/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  273. data/lib/octo/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  274. data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  275. data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  276. data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  277. data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  278. data/lib/octo/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  279. data/lib/octo/web/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  280. data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  281. data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  282. data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  283. data/lib/octo/web/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  284. data/lib/octo/web/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  285. data/lib/octo/web/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  286. data/lib/octo/web/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  287. data/lib/octo/web/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  288. data/lib/octo/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  289. data/lib/octo/web/vendor/katex/katex.min.css +1 -0
  290. data/lib/octo/web/vendor/katex/katex.min.js +1 -0
  291. data/lib/octo/web/version.js +449 -0
  292. data/lib/octo/web/weixin-qr.html +209 -0
  293. data/lib/octo/web/ws-dispatcher.js +357 -0
  294. data/lib/octo/web/ws.js +128 -0
  295. data/lib/octo.rb +145 -0
  296. data/scripts/build/build.sh +329 -0
  297. data/scripts/build/lib/apt.sh +56 -0
  298. data/scripts/build/lib/brew.sh +89 -0
  299. data/scripts/build/lib/colors.sh +17 -0
  300. data/scripts/build/lib/gem.sh +95 -0
  301. data/scripts/build/lib/mise.sh +125 -0
  302. data/scripts/build/lib/network.sh +157 -0
  303. data/scripts/build/lib/os.sh +57 -0
  304. data/scripts/build/lib/shell.sh +37 -0
  305. data/scripts/build/src/install.sh.cc +174 -0
  306. data/scripts/build/src/install_browser.sh.cc +101 -0
  307. data/scripts/build/src/install_full.sh.cc +290 -0
  308. data/scripts/build/src/install_rails_deps.sh.cc +145 -0
  309. data/scripts/build/src/install_system_deps.sh.cc +123 -0
  310. data/scripts/build/src/uninstall.sh.cc +101 -0
  311. data/scripts/install.ps1 +532 -0
  312. data/scripts/install.sh +567 -0
  313. data/scripts/install_browser.sh +479 -0
  314. data/scripts/install_full.sh +838 -0
  315. data/scripts/install_rails_deps.sh +746 -0
  316. data/scripts/install_system_deps.sh +518 -0
  317. data/scripts/uninstall.sh +287 -0
  318. data/sig/octo.rbs +4 -0
  319. metadata +614 -0
@@ -0,0 +1,913 @@
1
+ // settings.js — Settings panel logic
2
+ // Handles reading, editing, saving, testing AI model configurations.
3
+
4
+ const Settings = (() => {
5
+ // Local copy of models loaded from server
6
+ let _models = [];
7
+ // Provider presets loaded from server
8
+ let _providers = [];
9
+
10
+ // ── Public API ──────────────────────────────────────────────────────────────
11
+
12
+ function open() {
13
+ _load();
14
+ _loadBrowserStatus();
15
+ }
16
+
17
+ // ── Data Loading ────────────────────────────────────────────────────────────
18
+
19
+ async function _load() {
20
+ const container = document.getElementById("model-cards");
21
+ container.innerHTML = `<div class="settings-loading">${I18n.t("settings.models.loading")}</div>`;
22
+ try {
23
+ // Load config and providers in parallel
24
+ const [configRes, providerRes] = await Promise.all([
25
+ fetch("/api/config"),
26
+ fetch("/api/providers")
27
+ ]);
28
+ const configData = await configRes.json();
29
+ const providerData = await providerRes.json();
30
+ _models = configData.models || [];
31
+ _providers = providerData.providers || [];
32
+ _renderCards();
33
+ } catch (e) {
34
+ container.innerHTML = `<div class="settings-error">${I18n.t("settings.models.error", { msg: e.message })}</div>`;
35
+ }
36
+ }
37
+
38
+ // ── Rendering ───────────────────────────────────────────────────────────────
39
+
40
+ function _renderCards() {
41
+ const container = document.getElementById("model-cards");
42
+ container.innerHTML = "";
43
+
44
+ if (_models.length === 0) {
45
+ container.innerHTML = `<div class="settings-empty">${I18n.t("settings.models.empty")}</div>`;
46
+ return;
47
+ }
48
+
49
+ _models.forEach((m, i) => _renderCard(container, m, i));
50
+ }
51
+
52
+ function _renderCard(container, model, index) {
53
+ const isDefault = model.type === "default";
54
+ const isLite = model.type === "lite";
55
+
56
+ const card = document.createElement("div");
57
+ card.className = "model-card";
58
+ card.dataset.index = index;
59
+
60
+ // Build provider options
61
+ const providerOptions = _providers.map(p =>
62
+ `<option value="${p.id}">${p.name}</option>`
63
+ ).join("");
64
+
65
+ card.innerHTML = `
66
+ <div class="model-card-header">
67
+ <div class="model-card-badges">
68
+ ${isDefault ? `<span class="badge badge-default">${I18n.t("settings.models.badge.default")}</span>` : ""}
69
+ ${isLite ? `<span class="badge badge-lite">${I18n.t("settings.models.badge.lite")}</span>` : ""}
70
+ ${!isDefault && !isLite ? `<span class="badge badge-secondary">${I18n.t("settings.models.badge.model", { n: index + 1 })}</span>` : ""}
71
+ </div>
72
+ <div class="model-card-actions">
73
+ ${_models.length > 1
74
+ ? `<button class="btn-model-remove" data-index="${index}" title="Remove this model">×</button>`
75
+ : ""}
76
+ </div>
77
+ </div>
78
+
79
+ <div class="model-fields">
80
+ <label class="model-field quick-setup-field" ${(model.model || model.base_url) ? 'style="display:none"' : ''}>
81
+ <span class="field-label">${I18n.t("settings.models.field.quicksetup")}</span>
82
+ <div class="custom-select-wrapper" data-index="${index}">
83
+ <div class="custom-select-trigger" tabindex="0">
84
+ <span class="custom-select-value placeholder">${I18n.t("settings.models.placeholder.provider")}</span>
85
+ <svg class="custom-select-arrow" fill="none" stroke="currentColor" viewBox="0 0 24 24">
86
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
87
+ </svg>
88
+ </div>
89
+ <div class="custom-select-dropdown">
90
+ <div class="custom-select-option" data-value="">${I18n.t("settings.models.placeholder.provider")}</div>
91
+ ${_providers.map(p => `<div class="custom-select-option" data-value="${p.id}" data-label="${_esc(p.name)}">${_esc(p.name)}</div>`).join("")}
92
+ <div class="custom-select-option" data-value="custom">${I18n.t("settings.models.custom")}</div>
93
+ </div>
94
+ </div>
95
+ <div class="provider-promo-hint" data-index="${index}"></div>
96
+ </label>
97
+ <label class="model-field">
98
+ <span class="field-label">${I18n.t("settings.models.field.model")}</span>
99
+ <div class="model-name-combobox" data-index="${index}">
100
+ <input type="text" class="field-input model-name-input" data-key="model" data-index="${index}"
101
+ placeholder="${I18n.t("settings.models.placeholder.model")}" value="${_esc(model.model)}"
102
+ autocomplete="off">
103
+ <button class="model-name-dropdown-btn" type="button" title="Select from presets">
104
+ <svg width="12" height="12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
105
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
106
+ </svg>
107
+ </button>
108
+ <div class="model-name-dropdown" style="display:none"></div>
109
+ </div>
110
+ </label>
111
+ <label class="model-field">
112
+ <span class="field-label">${I18n.t("settings.models.field.baseurl")}</span>
113
+ <div class="base-url-combobox" data-index="${index}">
114
+ <input type="text" class="field-input base-url-input" data-key="base_url" data-index="${index}"
115
+ placeholder="${I18n.t("settings.models.placeholder.baseurl")}" value="${_esc(model.base_url)}"
116
+ autocomplete="off">
117
+ <button class="base-url-dropdown-btn" type="button" title="Select preset endpoint">
118
+ <svg width="12" height="12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
119
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
120
+ </svg>
121
+ </button>
122
+ <div class="base-url-dropdown" style="display:none"></div>
123
+ </div>
124
+ </label>
125
+ <label class="model-field">
126
+ <span class="field-label">
127
+ ${I18n.t("settings.models.field.apikey")}
128
+ <a class="get-apikey-link" data-index="${index}" href="#" target="_blank" rel="noopener" style="display:none;margin-left:0.5rem;font-size:0.75rem;color:var(--accent,#6366f1);text-decoration:none;opacity:0.85;">${I18n.t("settings.models.field.getApiKey")}</a>
129
+ </span>
130
+ <div class="field-input-row">
131
+ <input type="password" class="field-input api-key-input" data-key="api_key" data-index="${index}"
132
+ placeholder="${I18n.t("settings.models.placeholder.apikey")}" value="${_esc(model.api_key_masked)}">
133
+ <button class="btn-toggle-key" data-index="${index}" title="Show/hide key">
134
+ <svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
135
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
136
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
137
+ </svg>
138
+ </button>
139
+ </div>
140
+ </label>
141
+ </div>
142
+
143
+ <div class="model-card-footer">
144
+ ${!model.api_key_masked ? `<span class="model-card-docs-link" style="font-size:0.75rem;">
145
+ <span style="color:var(--muted,#6b7280);">${I18n.t("settings.models.field.docsGuide.question")}</span>
146
+ <a href="https://www.octo.com/docs/ai-key-guide" target="_blank" rel="noopener" style="margin-left:0.25rem;color:var(--accent,#6366f1);text-decoration:none;">${I18n.t("settings.models.field.docsGuide.cta")}</a>
147
+ </span>` : ""}
148
+ <span class="model-test-result" data-index="${index}"></span>
149
+ <div class="model-card-actions-row">
150
+ ${!isDefault ? `<button class="btn-set-default" data-index="${index}" title="${I18n.t("settings.models.btn.setDefault")}">${I18n.t("settings.models.btn.setDefault")}</button>` : ""}
151
+ <button class="btn-save-model btn-primary" data-index="${index}">${I18n.t("settings.models.btn.save")}</button>
152
+ </div>
153
+ </div>
154
+ `;
155
+
156
+ container.appendChild(card);
157
+ _bindCardEvents(card, index);
158
+ }
159
+
160
+ function _bindCardEvents(card, index) {
161
+ // Custom dropdown interactions
162
+ const customSelectWrapper = card.querySelector(".custom-select-wrapper");
163
+ const trigger = customSelectWrapper.querySelector(".custom-select-trigger");
164
+ const dropdown = customSelectWrapper.querySelector(".custom-select-dropdown");
165
+ const valueSpan = trigger.querySelector(".custom-select-value");
166
+ const options = dropdown.querySelectorAll(".custom-select-option");
167
+
168
+ // Toggle dropdown
169
+ trigger.addEventListener("click", (e) => {
170
+ e.stopPropagation();
171
+ const isOpen = dropdown.classList.contains("open");
172
+ // Close all other dropdowns
173
+ document.querySelectorAll(".custom-select-dropdown.open").forEach(d => {
174
+ d.classList.remove("open");
175
+ d.previousElementSibling.classList.remove("open");
176
+ });
177
+ if (!isOpen) {
178
+ dropdown.classList.add("open");
179
+ trigger.classList.add("open");
180
+ }
181
+ });
182
+
183
+ // Select option
184
+ options.forEach(option => {
185
+ option.addEventListener("click", (e) => {
186
+ e.stopPropagation();
187
+ const value = option.dataset.value;
188
+ const text = option.dataset.label || option.textContent;
189
+
190
+ // Update UI
191
+ valueSpan.textContent = text;
192
+ if (value) {
193
+ valueSpan.classList.remove("placeholder");
194
+ } else {
195
+ valueSpan.classList.add("placeholder");
196
+ }
197
+
198
+ // Update selected state
199
+ options.forEach(opt => opt.classList.remove("selected"));
200
+ option.classList.add("selected");
201
+
202
+ // Close dropdown
203
+ dropdown.classList.remove("open");
204
+ trigger.classList.remove("open");
205
+
206
+ // Auto-fill model & base_url if a provider preset was selected
207
+ const getApiKeyLink = card.querySelector(`.get-apikey-link[data-index="${index}"]`);
208
+ const promoHint = card.querySelector(`.provider-promo-hint[data-index="${index}"]`);
209
+ if (value && value !== "custom") {
210
+ const preset = _providers.find(p => p.id === value);
211
+ if (preset) {
212
+ const modelInput = card.querySelector(`[data-key="model"]`);
213
+ const baseUrlInput = card.querySelector(`[data-key="base_url"]`);
214
+ if (modelInput) modelInput.value = preset.default_model || "";
215
+ if (baseUrlInput) baseUrlInput.value = preset.base_url || "";
216
+ // Show "how to get" link if provider has a website_url
217
+ if (getApiKeyLink && preset.website_url) {
218
+ getApiKeyLink.href = preset.website_url;
219
+ getApiKeyLink.style.display = "";
220
+ } else if (getApiKeyLink) {
221
+ getApiKeyLink.style.display = "none";
222
+ }
223
+ }
224
+ if (promoHint) promoHint.classList.remove("visible");
225
+ } else {
226
+ if (getApiKeyLink) getApiKeyLink.style.display = "none";
227
+ }
228
+ });
229
+ });
230
+
231
+ // Close dropdown when clicking outside
232
+ document.addEventListener("click", () => {
233
+ dropdown.classList.remove("open");
234
+ trigger.classList.remove("open");
235
+ });
236
+
237
+ // Toggle API key visibility
238
+ const toggleKeyBtn = card.querySelector(".btn-toggle-key");
239
+ const apiKeyInput = card.querySelector(".api-key-input");
240
+ const eyeIcon = toggleKeyBtn.querySelector("svg");
241
+
242
+ toggleKeyBtn.addEventListener("click", () => {
243
+ const isPassword = apiKeyInput.type === "password";
244
+ apiKeyInput.type = isPassword ? "text" : "password";
245
+
246
+ // Update icon
247
+ if (isPassword) {
248
+ // Show eye-off icon
249
+ eyeIcon.innerHTML = `
250
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"></path>
251
+ `;
252
+ } else {
253
+ // Show eye icon
254
+ eyeIcon.innerHTML = `
255
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
256
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
257
+ `;
258
+ }
259
+ });
260
+
261
+ // Save: auto-test first, then save if passed
262
+ card.querySelector(".btn-save-model").addEventListener("click", () => _saveModel(index));
263
+
264
+ // Remove model
265
+ const removeBtn = card.querySelector(".btn-model-remove");
266
+ if (removeBtn) {
267
+ removeBtn.addEventListener("click", () => _removeModel(index));
268
+ }
269
+
270
+ // Set as default model
271
+ const setDefaultBtn = card.querySelector(".btn-set-default");
272
+ if (setDefaultBtn) {
273
+ setDefaultBtn.addEventListener("click", () => _setAsDefault(index));
274
+ }
275
+
276
+ // Model name combobox: dropdown button + model list
277
+ const modelCombobox = card.querySelector(".model-name-combobox");
278
+ const modelInput = modelCombobox.querySelector(".model-name-input");
279
+ const modelDropdownBtn = modelCombobox.querySelector(".model-name-dropdown-btn");
280
+ const modelDropdown = modelCombobox.querySelector(".model-name-dropdown");
281
+
282
+ // Build model list from current base_url's provider
283
+ const _updateModelDropdown = () => {
284
+ const baseUrlInput = card.querySelector(`[data-key="base_url"]`);
285
+ const baseUrl = baseUrlInput ? baseUrlInput.value.trim().replace(/\/+$/, "") : "";
286
+
287
+ // Find provider by matching base_url against BOTH the canonical
288
+ // preset.base_url AND every endpoint_variants[].base_url — otherwise
289
+ // picking e.g. GLM's Coding-Plan variant would wipe the model list
290
+ // because only the canonical URL would match.
291
+ const provider = _providers.find(p => {
292
+ const candidates = [p.base_url].concat(
293
+ Array.isArray(p.endpoint_variants) ? p.endpoint_variants.map(v => v.base_url) : []
294
+ ).filter(Boolean);
295
+ return candidates.some(c => {
296
+ const norm = String(c).replace(/\/+$/, "");
297
+ return baseUrl === norm || baseUrl.startsWith(norm + "/");
298
+ });
299
+ });
300
+ const models = provider?.models || [];
301
+
302
+ if (models.length === 0) {
303
+ modelDropdown.innerHTML = '<div class="model-dropdown-empty">No preset models available</div>';
304
+ return;
305
+ }
306
+
307
+ // Render model options
308
+ modelDropdown.innerHTML = models.map(m =>
309
+ `<div class="model-dropdown-option" data-value="${_esc(m)}">${_esc(m)}</div>`
310
+ ).join("");
311
+
312
+ // Bind click events
313
+ modelDropdown.querySelectorAll(".model-dropdown-option").forEach(opt => {
314
+ opt.addEventListener("click", (e) => {
315
+ e.stopPropagation();
316
+ const value = opt.dataset.value;
317
+ if (modelInput) modelInput.value = value;
318
+ modelDropdown.style.display = "none";
319
+ });
320
+ });
321
+ };
322
+
323
+ // Toggle dropdown
324
+ modelDropdownBtn.addEventListener("click", (e) => {
325
+ e.stopPropagation();
326
+ const isOpen = modelDropdown.style.display === "block";
327
+
328
+ // Close all other model dropdowns
329
+ document.querySelectorAll(".model-name-dropdown").forEach(d => {
330
+ d.style.display = "none";
331
+ });
332
+
333
+ if (!isOpen) {
334
+ _updateModelDropdown();
335
+ modelDropdown.style.display = "block";
336
+ }
337
+ });
338
+
339
+ // Close dropdown when clicking outside
340
+ document.addEventListener("click", () => {
341
+ modelDropdown.style.display = "none";
342
+ });
343
+
344
+ // Re-populate model list when base_url changes
345
+ const baseUrlInput = card.querySelector(`[data-key="base_url"]`);
346
+ if (baseUrlInput) {
347
+ baseUrlInput.addEventListener("blur", () => {
348
+ _updateModelDropdown();
349
+ });
350
+ }
351
+
352
+ // Base URL combobox: dropdown button + endpoint_variants list.
353
+ //
354
+ // Rationale: some providers (GLM on Zhipu/Z.ai, MiniMax on .com/.io) run
355
+ // multiple regional / billing-plan endpoints under a single identity.
356
+ // Listing every variant lets the user pick the right one instead of
357
+ // hand-editing the URL, while still allowing free-form input for
358
+ // unknown / self-hosted proxies. Mirrors the model-name combobox.
359
+ //
360
+ // Data source: the endpoint_variants[] field on each provider preset,
361
+ // resolved by matching the currently-entered base_url against every
362
+ // preset's {base_url + endpoint_variants[].base_url}. When no variants
363
+ // are declared for the matched provider (single-endpoint providers like
364
+ // Anthropic, Octo), the dropdown shows an "empty" hint.
365
+ const baseUrlCombobox = card.querySelector(".base-url-combobox");
366
+ const baseUrlDropdownBtn = baseUrlCombobox.querySelector(".base-url-dropdown-btn");
367
+ const baseUrlDropdown = baseUrlCombobox.querySelector(".base-url-dropdown");
368
+
369
+ // Resolve the "active" provider preset from the current form values:
370
+ // 1. If the Quick Setup select points at a known provider, use that
371
+ // (even before the base_url input is typed into).
372
+ // 2. Otherwise fall back to matching the current base_url against all
373
+ // preset base_url + endpoint_variants. Unknown URLs → null.
374
+ const _currentProvider = () => {
375
+ const selected = card.querySelector(".custom-select-option.selected");
376
+ const selectedId = selected?.dataset.value;
377
+ if (selectedId && selectedId !== "custom") {
378
+ const byId = _providers.find(p => p.id === selectedId);
379
+ if (byId) return byId;
380
+ }
381
+ const url = (baseUrlInput?.value || "").trim().replace(/\/+$/, "");
382
+ if (!url) return null;
383
+ return _providers.find(p => {
384
+ const candidates = [p.base_url].concat(
385
+ Array.isArray(p.endpoint_variants) ? p.endpoint_variants.map(v => v.base_url) : []
386
+ ).filter(Boolean);
387
+ return candidates.some(c => {
388
+ const norm = String(c).replace(/\/+$/, "");
389
+ return url === norm || url.startsWith(norm + "/");
390
+ });
391
+ }) || null;
392
+ };
393
+
394
+ const _renderBaseUrlDropdown = () => {
395
+ const provider = _currentProvider();
396
+ const variants = provider && Array.isArray(provider.endpoint_variants)
397
+ ? provider.endpoint_variants
398
+ : [];
399
+
400
+ if (variants.length === 0) {
401
+ baseUrlDropdown.innerHTML =
402
+ `<div class="model-dropdown-empty">${I18n.t("settings.models.baseurl.noVariants")}</div>`;
403
+ return;
404
+ }
405
+
406
+ baseUrlDropdown.innerHTML = variants.map(v => {
407
+ // Prefer i18n key (localised per UI language); fall back to literal
408
+ // `label` (shipped English copy) and finally to base_url for safety.
409
+ // Pattern: _translateVariant(v) -> "大陆 · 按量付费" in zh, "Mainland · Pay-as-you-go" in en.
410
+ const translated = v.label_key ? I18n.t(v.label_key) : null;
411
+ // I18n.t typically returns the key itself when missing — treat that as a miss.
412
+ const labelText = (translated && translated !== v.label_key) ? translated : (v.label || v.base_url);
413
+ const label = _esc(labelText);
414
+ const url = _esc(v.base_url);
415
+ return `
416
+ <div class="model-dropdown-option base-url-dropdown-option" data-value="${url}">
417
+ <div class="base-url-dropdown-label">${label}</div>
418
+ <div class="base-url-dropdown-url">${url}</div>
419
+ </div>`;
420
+ }).join("");
421
+
422
+ baseUrlDropdown.querySelectorAll(".base-url-dropdown-option").forEach(opt => {
423
+ opt.addEventListener("click", (e) => {
424
+ e.stopPropagation();
425
+ if (baseUrlInput) {
426
+ baseUrlInput.value = opt.dataset.value;
427
+ // Trigger model-list refresh since base_url just changed.
428
+ _updateModelDropdown();
429
+ }
430
+ baseUrlDropdown.style.display = "none";
431
+ });
432
+ });
433
+ };
434
+
435
+ baseUrlDropdownBtn.addEventListener("click", (e) => {
436
+ e.stopPropagation();
437
+ const isOpen = baseUrlDropdown.style.display === "block";
438
+ // Close sibling dropdowns (model-name + other base-url) to avoid overlap.
439
+ document.querySelectorAll(".model-name-dropdown, .base-url-dropdown").forEach(d => {
440
+ d.style.display = "none";
441
+ });
442
+ if (!isOpen) {
443
+ _renderBaseUrlDropdown();
444
+ baseUrlDropdown.style.display = "block";
445
+ }
446
+ });
447
+
448
+ // Close dropdown when clicking outside
449
+ document.addEventListener("click", () => {
450
+ baseUrlDropdown.style.display = "none";
451
+ });
452
+ }
453
+
454
+ // ── Read form values from a card ────────────────────────────────────────────
455
+
456
+ function _readCard(index) {
457
+ const card = document.querySelector(`.model-card[data-index="${index}"]`);
458
+ if (!card) return null;
459
+ return {
460
+ index,
461
+ model: card.querySelector(`[data-key="model"]`).value.trim(),
462
+ base_url: card.querySelector(`[data-key="base_url"]`).value.trim(),
463
+ api_key: card.querySelector(`[data-key="api_key"]`).value.trim(),
464
+ anthropic_format: false,
465
+ type: _models[index]?.type ?? null
466
+ };
467
+ }
468
+
469
+ // ── Save ─────────────────────────────────────────────────────────────────────
470
+
471
+ async function _saveModel(index) {
472
+ const saveBtn = document.querySelector(`.btn-save-model[data-index="${index}"]`);
473
+ const updated = _readCard(index);
474
+ if (!updated) return;
475
+
476
+ saveBtn.disabled = true;
477
+
478
+ // Step 1: auto-test first
479
+ saveBtn.textContent = I18n.t("settings.models.btn.testing");
480
+ _showTestResult(index, null, "");
481
+
482
+ try {
483
+ const testRes = await fetch("/api/config/test", {
484
+ method: "POST",
485
+ headers: { "Content-Type": "application/json" },
486
+ body: JSON.stringify({ ...updated, index })
487
+ });
488
+ const testData = await testRes.json();
489
+ _showTestResult(index, testData.ok, testData.message);
490
+
491
+ if (!testData.ok) {
492
+ // Test failed — stop, let user fix
493
+ saveBtn.textContent = I18n.t("settings.models.btn.save");
494
+ saveBtn.disabled = false;
495
+ return;
496
+ }
497
+ } catch (e) {
498
+ _showTestResult(index, false, e.message);
499
+ saveBtn.textContent = I18n.t("settings.models.btn.save");
500
+ saveBtn.disabled = false;
501
+ return;
502
+ }
503
+
504
+ // Step 2: test passed — now save via single-item endpoint.
505
+ //
506
+ // Contract (see http_server.rb):
507
+ // - Row has an id already → PATCH /api/config/models/:id
508
+ // - No id yet (locally-added row) → POST /api/config/models to
509
+ // create, then capture the server-assigned id.
510
+ // We NEVER send "the whole list" — each save touches exactly one row,
511
+ // so no bug in this function can ever affect another model's api_key.
512
+ saveBtn.textContent = I18n.t("settings.models.btn.saving");
513
+
514
+ const existing = _models[index] || {};
515
+ const hasId = !!existing.id;
516
+
517
+ // For PATCH: only send api_key if the user actually typed something
518
+ // non-masked. The masked display value ("sk-ab12****...5678") must
519
+ // never be sent as api_key — the server treats it as "no change"
520
+ // defensively, but the cleanest path is simply to omit it.
521
+ const payload = {
522
+ model: updated.model,
523
+ base_url: updated.base_url,
524
+ anthropic_format: updated.anthropic_format,
525
+ type: updated.type
526
+ };
527
+ if (updated.api_key && !updated.api_key.includes("****")) {
528
+ payload.api_key = updated.api_key;
529
+ }
530
+
531
+ try {
532
+ let res, data;
533
+ if (hasId) {
534
+ res = await fetch(`/api/config/models/${encodeURIComponent(existing.id)}`, {
535
+ method: "PATCH",
536
+ headers: { "Content-Type": "application/json" },
537
+ body: JSON.stringify(payload)
538
+ });
539
+ data = await res.json();
540
+ } else {
541
+ // Creation requires a non-empty api_key — surface a friendly
542
+ // error rather than a server 422.
543
+ if (!payload.api_key) {
544
+ saveBtn.textContent = I18n.t("settings.models.btn.save");
545
+ saveBtn.disabled = false;
546
+ _showTestResult(index, false, I18n.t("settings.models.placeholder.apikey"));
547
+ return;
548
+ }
549
+ res = await fetch(`/api/config/models`, {
550
+ method: "POST",
551
+ headers: { "Content-Type": "application/json" },
552
+ body: JSON.stringify(payload)
553
+ });
554
+ data = await res.json();
555
+ if (data.ok && data.id) {
556
+ // Record the assigned id so subsequent saves become PATCH.
557
+ _models[index].id = data.id;
558
+ }
559
+ }
560
+
561
+ if (data.ok) {
562
+ saveBtn.textContent = I18n.t("settings.models.btn.saved");
563
+ setTimeout(() => { saveBtn.textContent = I18n.t("settings.models.btn.save"); saveBtn.disabled = false; }, 1500);
564
+ // Reload to get fresh masked keys
565
+ setTimeout(_load, 1600);
566
+ } else {
567
+ saveBtn.textContent = I18n.t("settings.models.btn.save");
568
+ saveBtn.disabled = false;
569
+ _showTestResult(index, false, data.error || I18n.t("settings.models.saveFailed"));
570
+ }
571
+ } catch (e) {
572
+ saveBtn.textContent = I18n.t("settings.models.btn.save");
573
+ saveBtn.disabled = false;
574
+ _showTestResult(index, false, e.message);
575
+ }
576
+ }
577
+
578
+ function _showTestResult(index, ok, message) {
579
+ const el = document.querySelector(`.model-test-result[data-index="${index}"]`);
580
+ if (!el) return;
581
+ if (ok === null) { el.textContent = ""; el.className = "model-test-result"; return; }
582
+ el.textContent = ok ? `✓ ${message || I18n.t("settings.models.connected")}` : `✗ ${I18n.t("settings.models.testFail")}: ${message || I18n.t("settings.models.failed")}`;
583
+ el.className = `model-test-result ${ok ? "result-ok" : "result-fail"}`;
584
+ }
585
+
586
+ // ── Set as Default Model ───────────────────────────────────────────────────
587
+
588
+ async function _setAsDefault(index) {
589
+ const btn = document.querySelector(`.btn-set-default[data-index="${index}"]`);
590
+ if (!btn) return;
591
+
592
+ const target = _models[index];
593
+ if (!target || !target.id) {
594
+ // Can only promote saved models. Locally-added unsaved cards have
595
+ // no id yet — user must save them first.
596
+ alert(I18n.t("settings.models.setDefaultFailed"));
597
+ return;
598
+ }
599
+
600
+ btn.disabled = true;
601
+ btn.textContent = I18n.t("settings.models.btn.setting");
602
+
603
+ try {
604
+ const res = await fetch(`/api/config/models/${encodeURIComponent(target.id)}/default`, {
605
+ method: "POST"
606
+ });
607
+ const data = await res.json();
608
+
609
+ if (data.ok) {
610
+ btn.textContent = I18n.t("settings.models.btn.done");
611
+ // Reload to refresh the UI
612
+ setTimeout(_load, 800);
613
+ } else {
614
+ btn.textContent = I18n.t("settings.models.btn.setDefault");
615
+ btn.disabled = false;
616
+ alert(data.error || I18n.t("settings.models.setDefaultFailed"));
617
+ }
618
+ } catch (e) {
619
+ btn.textContent = I18n.t("settings.models.btn.setDefault");
620
+ btn.disabled = false;
621
+ alert(I18n.t("settings.models.errorPrefix") + e.message);
622
+ }
623
+ }
624
+
625
+ // ── Add / Remove model ───────────────────────────────────────────────────────
626
+
627
+ function _addModel() {
628
+ // Locally append an empty card. No server call here — the new entry
629
+ // is persisted via POST /api/config/models only when the user clicks
630
+ // "Save" (inside _saveModel). This means typing into a half-filled
631
+ // new card can never mutate the real config on the backend.
632
+ //
633
+ // UX convention: a newly added model becomes the "default" once
634
+ // saved — users overwhelmingly add a new model because they want to
635
+ // use it. We mark it default locally for the preview, and flip the
636
+ // other cards' type to null so the UI only shows one default badge.
637
+ // The backend enforces the single-default invariant on save anyway.
638
+ _models.forEach(m => { if (m.type === "default") m.type = null; });
639
+ _models.push({
640
+ id: null, // will be assigned by server on first save
641
+ index: _models.length,
642
+ model: "",
643
+ base_url: "",
644
+ api_key_masked: "",
645
+ anthropic_format: false,
646
+ type: "default"
647
+ });
648
+ _renderCards();
649
+
650
+ // Scroll the new (last) card into view.
651
+ //
652
+ // Why not just scrollIntoView: the settings panel uses #settings-body
653
+ // as a flex scroll container. When the page hasn't overflowed yet,
654
+ // scrollIntoView is a no-op; and across browsers the "smooth" path
655
+ // has been flaky when the scrolling element is a flex child.
656
+ //
657
+ // Two rAFs ensure the freshly rendered card has been laid out before
658
+ // we compute its position.
659
+ requestAnimationFrame(() => {
660
+ requestAnimationFrame(() => {
661
+ const container = document.getElementById("settings-body");
662
+ if (!container) return;
663
+ const cards = container.querySelectorAll(".model-card");
664
+ const last = cards[cards.length - 1];
665
+ if (!last) return;
666
+
667
+ // Align the new card's top with the container's top, minus a
668
+ // small breathing-room offset so it doesn't touch the edge.
669
+ // offsetTop is relative to the nearest positioned ancestor, so
670
+ // we compute position via getBoundingClientRect delta — robust
671
+ // regardless of the card's offsetParent chain.
672
+ const OFFSET = 16;
673
+ const delta = last.getBoundingClientRect().top
674
+ - container.getBoundingClientRect().top;
675
+ container.scrollTo({
676
+ top: container.scrollTop + delta - OFFSET,
677
+ behavior: "smooth"
678
+ });
679
+
680
+ // Put focus on the new card so the user can start configuring.
681
+ // Priority order:
682
+ // 1. The provider `.custom-select-trigger` — nudges the user to
683
+ // pick a provider first (step 1 of the 3-field form). It's a
684
+ // div with tabindex="0", so it gets the accent border via
685
+ // `:focus` without expanding the dropdown.
686
+ // 2. Fall back to the first form input (used when the quick-setup
687
+ // field is hidden, e.g. on a model card that already has values).
688
+ const providerTrigger = last.querySelector(".custom-select-wrapper .custom-select-trigger");
689
+ const isVisible = el => el && el.offsetParent !== null;
690
+ if (isVisible(providerTrigger)) {
691
+ providerTrigger.focus({ preventScroll: true });
692
+ } else {
693
+ const firstInput = last.querySelector("input, select, textarea");
694
+ if (firstInput) firstInput.focus({ preventScroll: true });
695
+ }
696
+ });
697
+ });
698
+ }
699
+
700
+ async function _removeModel(index) {
701
+ if (_models.length <= 1) return;
702
+ const modelName = _models[index]?.model || String(index + 1);
703
+ const confirmed = await Modal.confirm(I18n.t("settings.models.confirmRemove", { model: modelName }));
704
+ if (!confirmed) return;
705
+
706
+ const target = _models[index];
707
+
708
+ // Unsaved local card → just drop it from the local list, no server call.
709
+ if (!target || !target.id) {
710
+ _models.splice(index, 1);
711
+ _renderCards();
712
+ return;
713
+ }
714
+
715
+ try {
716
+ const res = await fetch(`/api/config/models/${encodeURIComponent(target.id)}`, {
717
+ method: "DELETE"
718
+ });
719
+ // Whatever the server says, reload to reflect the true state.
720
+ // (On error, _load will re-show the model.)
721
+ if (!res.ok) {
722
+ const data = await res.json().catch(() => ({}));
723
+ alert(data.error || I18n.t("settings.models.setDefaultFailed"));
724
+ }
725
+ } catch (_) { /* ignore */ }
726
+
727
+ // Reload fresh state
728
+ _load();
729
+ }
730
+
731
+ // ── Helpers ──────────────────────────────────────────────────────────────────
732
+
733
+ function _esc(str) {
734
+ return (str || "").replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
735
+ }
736
+
737
+ // ── Rerun onboard ────────────────────────────────────────────────────────────
738
+
739
+ async function _rerunOnboard() {
740
+ const btn = document.getElementById("btn-rerun-onboard");
741
+ btn.disabled = true;
742
+ btn.textContent = I18n.t("settings.personalize.btn.starting");
743
+
744
+ try {
745
+ // Close settings panel and navigate to chat, then start the onboard session.
746
+ // Onboard.startSoulSession() creates a new session, selects it, and sends /onboard.
747
+ Router.navigate("chat");
748
+ await Onboard.startSoulSession();
749
+ } catch (e) {
750
+ btn.disabled = false;
751
+ btn.textContent = I18n.t("settings.personalize.btn.rerun");
752
+ }
753
+ }
754
+
755
+ // ── Browser Setup ─────────────────────────────────────────────────────────────
756
+
757
+ async function _loadBrowserStatus() {
758
+ try {
759
+ const res = await fetch("/api/browser/status");
760
+ const data = await res.json();
761
+ const desc = document.getElementById("browser-status-desc");
762
+ const btn = document.getElementById("btn-browser-setup");
763
+ const toggleWrap = document.getElementById("browser-toggle-wrap");
764
+ const toggleInput = document.getElementById("browser-toggle-input");
765
+
766
+ // File doesn't exist → not set up yet
767
+ if (data.enabled === undefined || data.enabled === null) {
768
+ desc.textContent = I18n.t("settings.browser.desc");
769
+ btn.textContent = I18n.t("settings.browser.btn");
770
+ toggleWrap.style.display = "none";
771
+ return;
772
+ }
773
+
774
+ // Configured — show toggle + reconfigure button
775
+ const version = data.chrome_version ? ` (Chrome v${data.chrome_version})` : "";
776
+ desc.textContent = I18n.t(data.enabled ? "settings.browser.configured" : "settings.browser.disabled") + version;
777
+ btn.textContent = I18n.t("settings.browser.btn.reconfigure");
778
+ toggleWrap.style.display = "inline-block";
779
+ toggleInput.checked = data.enabled;
780
+
781
+ // Only bind once
782
+ if (!toggleInput.dataset.bound) {
783
+ toggleInput.dataset.bound = "1";
784
+ toggleInput.addEventListener("change", _toggleBrowser);
785
+ }
786
+ } catch (_) { /* non-critical */ }
787
+ }
788
+
789
+ async function _toggleBrowser() {
790
+ const toggleInput = document.getElementById("browser-toggle-input");
791
+ toggleInput.disabled = true;
792
+ try {
793
+ const res = await fetch("/api/browser/toggle", { method: "POST" });
794
+ const data = await res.json();
795
+ if (!data.ok) throw new Error(data.error || "toggle failed");
796
+ await _loadBrowserStatus();
797
+ } catch (_) {
798
+ // Revert on failure
799
+ toggleInput.checked = !toggleInput.checked;
800
+ } finally {
801
+ toggleInput.disabled = false;
802
+ }
803
+ }
804
+
805
+ async function _setupBrowser() {
806
+ const btn = document.getElementById("btn-browser-setup");
807
+ btn.disabled = true;
808
+ btn.textContent = I18n.t("settings.browser.btn.starting");
809
+ try {
810
+ const res = await fetch("/api/sessions", {
811
+ method: "POST",
812
+ headers: { "Content-Type": "application/json" },
813
+ body: JSON.stringify({ name: "🌐 Browser Setup", source: "setup" })
814
+ });
815
+ const data = await res.json();
816
+ const session = data.session;
817
+ if (!session) throw new Error("No session returned");
818
+ Sessions.add(session);
819
+ Sessions.renderList();
820
+ Sessions.setPendingMessage(session.id, "/browser-setup");
821
+ Sessions.select(session.id);
822
+ } catch (e) {
823
+ btn.disabled = false;
824
+ btn.textContent = I18n.t("settings.browser.btn");
825
+ }
826
+ }
827
+
828
+ // ── Init ──────────────────────────────────────────────────────────────────────
829
+
830
+ function _initLangBtns() {
831
+ // Highlight the active language button on open
832
+ document.querySelectorAll("#language-section .settings-lang-btn").forEach(btn => {
833
+ btn.classList.toggle("active", btn.dataset.lang === I18n.lang());
834
+ btn.addEventListener("click", () => {
835
+ I18n.setLang(btn.dataset.lang);
836
+ document.querySelectorAll("#language-section .settings-lang-btn").forEach(b =>
837
+ b.classList.toggle("active", b.dataset.lang === I18n.lang())
838
+ );
839
+ });
840
+ });
841
+ }
842
+
843
+ function init() {
844
+ document.getElementById("btn-add-model").addEventListener("click", _addModel);
845
+ document.getElementById("btn-rerun-onboard").addEventListener("click", _rerunOnboard);
846
+ document.getElementById("btn-browser-setup").addEventListener("click", _setupBrowser);
847
+
848
+ _initLangBtns();
849
+ _initFontBtns();
850
+ _initCurrencyBtns();
851
+
852
+ // Re-render model cards when language changes (dynamic HTML, not data-i18n)
853
+ document.addEventListener("langchange", () => _renderCards());
854
+ }
855
+
856
+ // ── Currency ──────────────────────────────────────────────────────────
857
+ const CURRENCY_STORAGE_KEY = "octo-currency";
858
+ const CURRENCY_DEFAULT = "USD";
859
+
860
+ function _applyCurrency(currency) {
861
+ try { localStorage.setItem(CURRENCY_STORAGE_KEY, currency); } catch (_) {}
862
+ // Update active state on all currency buttons
863
+ document.querySelectorAll("#currency-section .settings-lang-btn").forEach(btn => {
864
+ btn.classList.toggle("active", btn.dataset.currency === currency);
865
+ });
866
+ // Currency change saved to localStorage; visual state updated above
867
+ }
868
+
869
+ function _initCurrencyBtns() {
870
+ // Apply saved preference (or default) on page load
871
+ let saved = null;
872
+ try { saved = localStorage.getItem(CURRENCY_STORAGE_KEY); } catch (_) {}
873
+ const current = saved || CURRENCY_DEFAULT;
874
+
875
+ // Wire up button clicks
876
+ document.querySelectorAll("#currency-section .settings-lang-btn").forEach(btn => {
877
+ btn.classList.toggle("active", btn.dataset.currency === current);
878
+ btn.addEventListener("click", () => {
879
+ _applyCurrency(btn.dataset.currency);
880
+ });
881
+ });
882
+ }
883
+
884
+ // ── Font Size ──────────────────────────────────────────────────────────
885
+ const FONT_STORAGE_KEY = "octo-font-size";
886
+ const FONT_DEFAULT = "medium";
887
+
888
+ function _applyFontSize(size) {
889
+ document.documentElement.setAttribute("data-font-size", size);
890
+ try { localStorage.setItem(FONT_STORAGE_KEY, size); } catch (_) {}
891
+ // Update active state on all font-size buttons (if settings panel is open)
892
+ document.querySelectorAll("#font-size-section .settings-lang-btn").forEach(btn => {
893
+ btn.classList.toggle("active", btn.dataset.font === size);
894
+ });
895
+ }
896
+
897
+ function _initFontBtns() {
898
+ // Apply saved preference (or default) on page load
899
+ let saved = null;
900
+ try { saved = localStorage.getItem(FONT_STORAGE_KEY); } catch (_) {}
901
+ _applyFontSize(saved || FONT_DEFAULT);
902
+
903
+ // Wire up button clicks
904
+ document.querySelectorAll("#font-size-section .settings-lang-btn").forEach(btn => {
905
+ btn.classList.toggle("active", btn.dataset.font === (saved || FONT_DEFAULT));
906
+ btn.addEventListener("click", () => {
907
+ _applyFontSize(btn.dataset.font);
908
+ });
909
+ });
910
+ }
911
+
912
+ return { open, init };
913
+ })();