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.
- checksums.yaml +7 -0
- data/.clacky/skills/commit/SKILL.md +423 -0
- data/.clacky/skills/gem-release/SKILL.md +199 -0
- data/.clacky/skills/gem-release/scripts/release.sh +304 -0
- data/.clacky/skills/oss-upload/SKILL.md +47 -0
- data/.octorules +106 -0
- data/.rspec +3 -0
- data/.rubocop.yml +8 -0
- data/CHANGELOG.md +76 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/CONTRIBUTING.md +92 -0
- data/Dockerfile +28 -0
- data/LICENSE.txt +22 -0
- data/POSITIONING.md +46 -0
- data/README.md +134 -0
- data/README_CN.md +134 -0
- data/Rakefile +34 -0
- data/benchmark/fixtures/sample_project/Gemfile +3 -0
- data/benchmark/fixtures/sample_project/lib/api_handler.rb +32 -0
- data/benchmark/fixtures/sample_project/lib/order_calculator.rb +23 -0
- data/benchmark/fixtures/sample_project/lib/user_renderer.rb +20 -0
- data/benchmark/fixtures/sample_project/spec/order_calculator_spec.rb +20 -0
- data/benchmark/results/EVALUATION_REPORT.md +165 -0
- data/benchmark/results/baseline_20260511_174424.json +128 -0
- data/benchmark/results/report_20260511_175256.json +271 -0
- data/benchmark/results/report_20260511_175444.json +271 -0
- data/benchmark/results/treatment_20260511_175103.json +130 -0
- data/benchmark/runner.rb +441 -0
- data/bin/octo +7 -0
- data/docs/agent-first-ui-design.md +77 -0
- data/docs/billing-system.md +318 -0
- data/docs/channel-architecture.md +235 -0
- data/docs/engineering-article.md +343 -0
- data/docs/session-skill-invocation.md +69 -0
- data/docs/time_machine_design.md +247 -0
- data/docs/ui2-architecture.md +124 -0
- data/homebrew/README.md +96 -0
- data/homebrew/openocto.rb +24 -0
- data/lib/octo/agent/hook_manager.rb +61 -0
- data/lib/octo/agent/llm_caller.rb +800 -0
- data/lib/octo/agent/memory_updater.rb +246 -0
- data/lib/octo/agent/message_compressor.rb +225 -0
- data/lib/octo/agent/message_compressor_helper.rb +869 -0
- data/lib/octo/agent/next_message_suggester.rb +215 -0
- data/lib/octo/agent/session_serializer.rb +685 -0
- data/lib/octo/agent/skill_auto_creator.rb +114 -0
- data/lib/octo/agent/skill_evolution.rb +61 -0
- data/lib/octo/agent/skill_manager.rb +466 -0
- data/lib/octo/agent/skill_reflector.rb +89 -0
- data/lib/octo/agent/system_prompt_builder.rb +101 -0
- data/lib/octo/agent/time_machine.rb +214 -0
- data/lib/octo/agent/tool_executor.rb +454 -0
- data/lib/octo/agent/tool_registry.rb +150 -0
- data/lib/octo/agent.rb +2180 -0
- data/lib/octo/agent_config.rb +989 -0
- data/lib/octo/agent_profile.rb +112 -0
- data/lib/octo/anthropic_stream_aggregator.rb +137 -0
- data/lib/octo/background_task_registry.rb +324 -0
- data/lib/octo/banner.rb +34 -0
- data/lib/octo/bedrock_stream_aggregator.rb +137 -0
- data/lib/octo/block_font.rb +331 -0
- data/lib/octo/cli.rb +968 -0
- data/lib/octo/client.rb +623 -0
- data/lib/octo/default_agents/SOUL.md +3 -0
- data/lib/octo/default_agents/USER.md +1 -0
- data/lib/octo/default_agents/base_prompt.md +66 -0
- data/lib/octo/default_agents/coding/profile.yml +2 -0
- data/lib/octo/default_agents/coding/system_prompt.md +67 -0
- data/lib/octo/default_agents/general/profile.yml +2 -0
- data/lib/octo/default_agents/general/system_prompt.md +16 -0
- data/lib/octo/default_parsers/doc_parser.rb +69 -0
- data/lib/octo/default_parsers/docx_parser.rb +188 -0
- data/lib/octo/default_parsers/pdf_parser.rb +120 -0
- data/lib/octo/default_parsers/pdf_parser_ocr.py +103 -0
- data/lib/octo/default_parsers/pdf_parser_plumber.py +62 -0
- data/lib/octo/default_parsers/pptx_parser.rb +140 -0
- data/lib/octo/default_parsers/xlsx_parser.rb +121 -0
- data/lib/octo/default_skills/browser-setup/SKILL.md +426 -0
- data/lib/octo/default_skills/channel-manager/SKILL.md +623 -0
- data/lib/octo/default_skills/channel-manager/dingtalk_setup.rb +191 -0
- data/lib/octo/default_skills/channel-manager/discord_setup.rb +199 -0
- data/lib/octo/default_skills/channel-manager/feishu_setup.rb +574 -0
- data/lib/octo/default_skills/channel-manager/import_lark_skills.rb +97 -0
- data/lib/octo/default_skills/channel-manager/install_feishu_skills.rb +105 -0
- data/lib/octo/default_skills/channel-manager/weixin_setup.rb +274 -0
- data/lib/octo/default_skills/code-explorer/SKILL.md +36 -0
- data/lib/octo/default_skills/cron-task-creator/SKILL.md +257 -0
- data/lib/octo/default_skills/cron-task-creator/evals/evals.json +38 -0
- data/lib/octo/default_skills/onboard/SKILL.md +578 -0
- data/lib/octo/default_skills/onboard/scripts/import_external_skills.rb +413 -0
- data/lib/octo/default_skills/onboard/scripts/install_builtin_skills.rb +97 -0
- data/lib/octo/default_skills/persist-memory/SKILL.md +59 -0
- data/lib/octo/default_skills/personal-website/SKILL.md +113 -0
- data/lib/octo/default_skills/personal-website/publish.rb +235 -0
- data/lib/octo/default_skills/product-help/SKILL.md +123 -0
- data/lib/octo/default_skills/product-help/docs/agent-config.md +74 -0
- data/lib/octo/default_skills/product-help/docs/best-practices.md +49 -0
- data/lib/octo/default_skills/product-help/docs/browser-tool.md +53 -0
- data/lib/octo/default_skills/product-help/docs/built-in-skills.md +43 -0
- data/lib/octo/default_skills/product-help/docs/cli-reference.md +82 -0
- data/lib/octo/default_skills/product-help/docs/create-your-first-skill.md +47 -0
- data/lib/octo/default_skills/product-help/docs/faq.md +98 -0
- data/lib/octo/default_skills/product-help/docs/how-to-use-a-skill.md +58 -0
- data/lib/octo/default_skills/product-help/docs/installation.md +59 -0
- data/lib/octo/default_skills/product-help/docs/memory-system.md +61 -0
- data/lib/octo/default_skills/product-help/docs/octorules.md +62 -0
- data/lib/octo/default_skills/product-help/docs/session-management.md +63 -0
- data/lib/octo/default_skills/product-help/docs/skill-basics.md +55 -0
- data/lib/octo/default_skills/product-help/docs/skill-frontmatter.md +61 -0
- data/lib/octo/default_skills/product-help/docs/web-server.md +49 -0
- data/lib/octo/default_skills/product-help/docs/what-is-octo.md +37 -0
- data/lib/octo/default_skills/product-help/docs/windows-installation.md +36 -0
- data/lib/octo/default_skills/product-help/docs/writing-tips.md +53 -0
- data/lib/octo/default_skills/recall-memory/SKILL.md +65 -0
- data/lib/octo/default_skills/skill-add/SKILL.md +59 -0
- data/lib/octo/default_skills/skill-add/scripts/install_from_zip.rb +295 -0
- data/lib/octo/default_skills/skill-creator/SKILL.md +602 -0
- data/lib/octo/default_skills/skill-creator/agents/analyzer.md +274 -0
- data/lib/octo/default_skills/skill-creator/agents/comparator.md +202 -0
- data/lib/octo/default_skills/skill-creator/agents/grader.md +223 -0
- data/lib/octo/default_skills/skill-creator/eval-viewer/generate_review.py +471 -0
- data/lib/octo/default_skills/skill-creator/eval-viewer/viewer.html +1325 -0
- data/lib/octo/default_skills/skill-creator/references/schemas.md +430 -0
- data/lib/octo/default_skills/skill-creator/scripts/__init__.py +0 -0
- data/lib/octo/default_skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
- data/lib/octo/default_skills/skill-creator/scripts/generate_report.py +326 -0
- data/lib/octo/default_skills/skill-creator/scripts/improve_description.py +310 -0
- data/lib/octo/default_skills/skill-creator/scripts/quick_validate.py +103 -0
- data/lib/octo/default_skills/skill-creator/scripts/run_eval.py +317 -0
- data/lib/octo/default_skills/skill-creator/scripts/run_loop.py +331 -0
- data/lib/octo/default_skills/skill-creator/scripts/utils.py +47 -0
- data/lib/octo/default_skills/skill-creator/scripts/validate_skill_frontmatter.rb +143 -0
- data/lib/octo/idle_compression_timer.rb +115 -0
- data/lib/octo/json_ui_controller.rb +204 -0
- data/lib/octo/message_format/anthropic.rb +409 -0
- data/lib/octo/message_format/bedrock.rb +361 -0
- data/lib/octo/message_format/open_ai.rb +222 -0
- data/lib/octo/message_history.rb +373 -0
- data/lib/octo/openai_stream_aggregator.rb +130 -0
- data/lib/octo/plain_ui_controller.rb +166 -0
- data/lib/octo/providers.rb +534 -0
- data/lib/octo/server/browser_manager.rb +397 -0
- data/lib/octo/server/channel/adapters/base.rb +82 -0
- data/lib/octo/server/channel/adapters/dingtalk/adapter.rb +314 -0
- data/lib/octo/server/channel/adapters/dingtalk/api_client.rb +391 -0
- data/lib/octo/server/channel/adapters/dingtalk/stream_client.rb +203 -0
- data/lib/octo/server/channel/adapters/discord/adapter.rb +229 -0
- data/lib/octo/server/channel/adapters/discord/api_client.rb +107 -0
- data/lib/octo/server/channel/adapters/discord/gateway_client.rb +270 -0
- data/lib/octo/server/channel/adapters/feishu/adapter.rb +320 -0
- data/lib/octo/server/channel/adapters/feishu/bot.rb +478 -0
- data/lib/octo/server/channel/adapters/feishu/file_processor.rb +36 -0
- data/lib/octo/server/channel/adapters/feishu/message_parser.rb +129 -0
- data/lib/octo/server/channel/adapters/feishu/ws_client.rb +423 -0
- data/lib/octo/server/channel/adapters/telegram/adapter.rb +375 -0
- data/lib/octo/server/channel/adapters/telegram/api_client.rb +205 -0
- data/lib/octo/server/channel/adapters/wecom/adapter.rb +148 -0
- data/lib/octo/server/channel/adapters/wecom/media_downloader.rb +115 -0
- data/lib/octo/server/channel/adapters/wecom/ws_client.rb +395 -0
- data/lib/octo/server/channel/adapters/weixin/adapter.rb +692 -0
- data/lib/octo/server/channel/adapters/weixin/api_client.rb +402 -0
- data/lib/octo/server/channel/channel_config.rb +178 -0
- data/lib/octo/server/channel/channel_manager.rb +468 -0
- data/lib/octo/server/channel/channel_ui_controller.rb +224 -0
- data/lib/octo/server/channel.rb +33 -0
- data/lib/octo/server/discover.rb +77 -0
- data/lib/octo/server/epipe_safe_io.rb +105 -0
- data/lib/octo/server/http_server.rb +3554 -0
- data/lib/octo/server/scheduler.rb +317 -0
- data/lib/octo/server/server_master.rb +325 -0
- data/lib/octo/server/session_registry.rb +431 -0
- data/lib/octo/server/web_ui_controller.rb +487 -0
- data/lib/octo/session_manager.rb +385 -0
- data/lib/octo/skill.rb +466 -0
- data/lib/octo/skill_loader.rb +328 -0
- data/lib/octo/tools/base.rb +118 -0
- data/lib/octo/tools/browser.rb +625 -0
- data/lib/octo/tools/edit.rb +165 -0
- data/lib/octo/tools/file_reader.rb +549 -0
- data/lib/octo/tools/glob.rb +162 -0
- data/lib/octo/tools/grep.rb +356 -0
- data/lib/octo/tools/invoke_skill.rb +96 -0
- data/lib/octo/tools/list_tasks.rb +54 -0
- data/lib/octo/tools/redo_task.rb +41 -0
- data/lib/octo/tools/request_user_feedback.rb +84 -0
- data/lib/octo/tools/security.rb +333 -0
- data/lib/octo/tools/terminal/output_cleaner.rb +63 -0
- data/lib/octo/tools/terminal/persistent_session.rb +268 -0
- data/lib/octo/tools/terminal/safe_rm.sh +106 -0
- data/lib/octo/tools/terminal/session_manager.rb +213 -0
- data/lib/octo/tools/terminal.rb +1828 -0
- data/lib/octo/tools/todo_manager.rb +374 -0
- data/lib/octo/tools/trash_manager.rb +388 -0
- data/lib/octo/tools/undo_task.rb +35 -0
- data/lib/octo/tools/web_fetch.rb +242 -0
- data/lib/octo/tools/web_search.rb +260 -0
- data/lib/octo/tools/write.rb +77 -0
- data/lib/octo/ui2/block_font.rb +10 -0
- data/lib/octo/ui2/components/base_component.rb +163 -0
- data/lib/octo/ui2/components/command_suggestions.rb +290 -0
- data/lib/octo/ui2/components/common_component.rb +96 -0
- data/lib/octo/ui2/components/inline_input.rb +226 -0
- data/lib/octo/ui2/components/input_area.rb +1338 -0
- data/lib/octo/ui2/components/message_component.rb +99 -0
- data/lib/octo/ui2/components/modal_component.rb +419 -0
- data/lib/octo/ui2/components/todo_area.rb +149 -0
- data/lib/octo/ui2/components/tool_component.rb +107 -0
- data/lib/octo/ui2/components/welcome_banner.rb +139 -0
- data/lib/octo/ui2/layout_manager.rb +807 -0
- data/lib/octo/ui2/line_editor.rb +363 -0
- data/lib/octo/ui2/markdown_renderer.rb +100 -0
- data/lib/octo/ui2/output_buffer.rb +370 -0
- data/lib/octo/ui2/progress_handle.rb +362 -0
- data/lib/octo/ui2/progress_indicator.rb +55 -0
- data/lib/octo/ui2/screen_buffer.rb +273 -0
- data/lib/octo/ui2/terminal_detector.rb +119 -0
- data/lib/octo/ui2/theme_manager.rb +85 -0
- data/lib/octo/ui2/themes/base_theme.rb +105 -0
- data/lib/octo/ui2/themes/hacker_theme.rb +62 -0
- data/lib/octo/ui2/themes/minimal_theme.rb +56 -0
- data/lib/octo/ui2/thinking_verbs.rb +26 -0
- data/lib/octo/ui2/ui_controller.rb +1625 -0
- data/lib/octo/ui2/view_renderer.rb +177 -0
- data/lib/octo/ui2.rb +40 -0
- data/lib/octo/ui_interface.rb +154 -0
- data/lib/octo/utils/arguments_parser.rb +191 -0
- data/lib/octo/utils/browser_detector.rb +195 -0
- data/lib/octo/utils/encoding.rb +92 -0
- data/lib/octo/utils/environment_detector.rb +140 -0
- data/lib/octo/utils/file_ignore_helper.rb +170 -0
- data/lib/octo/utils/file_processor.rb +601 -0
- data/lib/octo/utils/gitignore_parser.rb +154 -0
- data/lib/octo/utils/limit_stack.rb +152 -0
- data/lib/octo/utils/logger.rb +124 -0
- data/lib/octo/utils/login_shell.rb +72 -0
- data/lib/octo/utils/model_pricing.rb +646 -0
- data/lib/octo/utils/parser_manager.rb +165 -0
- data/lib/octo/utils/path_helper.rb +15 -0
- data/lib/octo/utils/scripts_manager.rb +59 -0
- data/lib/octo/utils/string_matcher.rb +158 -0
- data/lib/octo/utils/trash_directory.rb +112 -0
- data/lib/octo/utils/workspace_rules.rb +46 -0
- data/lib/octo/version.rb +5 -0
- data/lib/octo/web/app.css +7141 -0
- data/lib/octo/web/app.js +543 -0
- data/lib/octo/web/apple-touch-icon.png +0 -0
- data/lib/octo/web/auth.js +150 -0
- data/lib/octo/web/channels.js +276 -0
- data/lib/octo/web/datepicker.js +205 -0
- data/lib/octo/web/favicon.png +0 -0
- data/lib/octo/web/i18n.js +1073 -0
- data/lib/octo/web/icon-512.png +0 -0
- data/lib/octo/web/icon-dark.svg +25 -0
- data/lib/octo/web/icon.svg +29 -0
- data/lib/octo/web/index.html +871 -0
- data/lib/octo/web/marked.min.js +69 -0
- data/lib/octo/web/onboard.js +491 -0
- data/lib/octo/web/profile.js +442 -0
- data/lib/octo/web/sessions.js +4421 -0
- data/lib/octo/web/settings.js +913 -0
- data/lib/octo/web/sidebar.js +32 -0
- data/lib/octo/web/skills.js +885 -0
- data/lib/octo/web/tasks.js +297 -0
- data/lib/octo/web/theme.js +105 -0
- data/lib/octo/web/trash.js +343 -0
- data/lib/octo/web/vendor/hljs/highlight.min.js +1244 -0
- data/lib/octo/web/vendor/hljs/hljs-theme.css +95 -0
- data/lib/octo/web/vendor/katex/auto-render.min.js +1 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/katex.min.css +1 -0
- data/lib/octo/web/vendor/katex/katex.min.js +1 -0
- data/lib/octo/web/version.js +449 -0
- data/lib/octo/web/weixin-qr.html +209 -0
- data/lib/octo/web/ws-dispatcher.js +357 -0
- data/lib/octo/web/ws.js +128 -0
- data/lib/octo.rb +145 -0
- data/scripts/build/build.sh +329 -0
- data/scripts/build/lib/apt.sh +56 -0
- data/scripts/build/lib/brew.sh +89 -0
- data/scripts/build/lib/colors.sh +17 -0
- data/scripts/build/lib/gem.sh +95 -0
- data/scripts/build/lib/mise.sh +125 -0
- data/scripts/build/lib/network.sh +157 -0
- data/scripts/build/lib/os.sh +57 -0
- data/scripts/build/lib/shell.sh +37 -0
- data/scripts/build/src/install.sh.cc +174 -0
- data/scripts/build/src/install_browser.sh.cc +101 -0
- data/scripts/build/src/install_full.sh.cc +290 -0
- data/scripts/build/src/install_rails_deps.sh.cc +145 -0
- data/scripts/build/src/install_system_deps.sh.cc +123 -0
- data/scripts/build/src/uninstall.sh.cc +101 -0
- data/scripts/install.ps1 +532 -0
- data/scripts/install.sh +567 -0
- data/scripts/install_browser.sh +479 -0
- data/scripts/install_full.sh +838 -0
- data/scripts/install_rails_deps.sh +746 -0
- data/scripts/install_system_deps.sh +518 -0
- data/scripts/uninstall.sh +287 -0
- data/sig/octo.rbs +4 -0
- 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, "&").replace(/"/g, """).replace(/</g, "<");
|
|
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
|
+
})();
|