openclacky 1.1.6 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +37 -0
- data/CODE_OF_CONDUCT.md +1 -1
- data/CONTRIBUTING.md +92 -0
- data/README.md +10 -0
- data/README_CN.md +10 -0
- data/ROADMAP.md +29 -0
- data/docs/billing-system.md +340 -0
- data/docs/mcp-architecture.md +114 -0
- data/docs/mcp.example.json +22 -0
- data/lib/clacky/agent/cost_tracker.rb +37 -0
- data/lib/clacky/agent/llm_caller.rb +0 -1
- data/lib/clacky/agent/session_serializer.rb +2 -11
- data/lib/clacky/agent/skill_manager.rb +73 -26
- data/lib/clacky/agent/system_prompt_builder.rb +0 -5
- data/lib/clacky/agent/time_machine.rb +6 -0
- data/lib/clacky/agent.rb +26 -1
- data/lib/clacky/agent_config.rb +9 -19
- data/lib/clacky/billing/billing_record.rb +67 -0
- data/lib/clacky/billing/billing_store.rb +193 -0
- data/lib/clacky/cli.rb +108 -6
- data/lib/clacky/default_skills/browser-setup/SKILL.md +26 -4
- data/lib/clacky/default_skills/mcp-manager/SKILL.md +343 -0
- data/lib/clacky/idle_compression_timer.rb +4 -2
- data/lib/clacky/mcp/client.rb +204 -0
- data/lib/clacky/mcp/http_transport.rb +155 -0
- data/lib/clacky/mcp/registry.rb +229 -0
- data/lib/clacky/mcp/skill_provider.rb +75 -0
- data/lib/clacky/mcp/stdio_transport.rb +112 -0
- data/lib/clacky/mcp/transport.rb +23 -0
- data/lib/clacky/mcp/virtual_skill.rb +131 -0
- data/lib/clacky/message_history.rb +0 -1
- data/lib/clacky/server/channel/adapters/weixin/adapter.rb +2 -35
- data/lib/clacky/server/http_server.rb +519 -15
- data/lib/clacky/server/server_master.rb +8 -14
- data/lib/clacky/server/session_registry.rb +24 -2
- data/lib/clacky/server/web_ui_controller.rb +4 -0
- data/lib/clacky/session_manager.rb +41 -12
- data/lib/clacky/skill.rb +1 -5
- data/lib/clacky/skill_loader.rb +36 -5
- data/lib/clacky/tools/browser.rb +217 -38
- data/lib/clacky/tools/trash_manager.rb +154 -3
- data/lib/clacky/ui2/components/command_suggestions.rb +6 -2
- data/lib/clacky/ui_interface.rb +1 -0
- data/lib/clacky/utils/model_pricing.rb +11 -7
- data/lib/clacky/utils/trash_directory.rb +37 -6
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +2907 -1764
- data/lib/clacky/web/app.js +84 -10
- data/lib/clacky/web/billing.js +275 -0
- data/lib/clacky/web/brand.js +3 -0
- data/lib/clacky/web/i18n.js +242 -24
- data/lib/clacky/web/index.html +351 -134
- data/lib/clacky/web/mcp.js +328 -0
- data/lib/clacky/web/sessions.js +193 -11
- data/lib/clacky/web/settings.js +686 -174
- data/lib/clacky/web/sidebar.js +2 -0
- data/lib/clacky/web/trash.js +323 -60
- data/lib/clacky/web/ws-dispatcher.js +14 -1
- data/lib/clacky.rb +4 -0
- data/scripts/install.ps1 +23 -11
- metadata +30 -10
data/lib/clacky/web/settings.js
CHANGED
|
@@ -13,6 +13,7 @@ const Settings = (() => {
|
|
|
13
13
|
_load();
|
|
14
14
|
_loadBrand();
|
|
15
15
|
_loadBrowserStatus();
|
|
16
|
+
_applyAboutTabVisibility();
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
// ── Data Loading ────────────────────────────────────────────────────────────
|
|
@@ -50,112 +51,499 @@ const Settings = (() => {
|
|
|
50
51
|
_models.forEach((m, i) => _renderCard(container, m, i));
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
function _getProviderName(model) {
|
|
55
|
+
if (!model.base_url) return I18n.t("settings.models.provider.custom");
|
|
56
|
+
const url = model.base_url.trim().replace(/\/+$/, "");
|
|
57
|
+
const provider = _providers.find(p => {
|
|
58
|
+
const candidates = [p.base_url].concat(
|
|
59
|
+
Array.isArray(p.endpoint_variants) ? p.endpoint_variants.map(v => v.base_url) : []
|
|
60
|
+
).filter(Boolean);
|
|
61
|
+
return candidates.some(c => {
|
|
62
|
+
const norm = String(c).replace(/\/+$/, "");
|
|
63
|
+
return url === norm || url.startsWith(norm + "/");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
return provider ? provider.name : I18n.t("settings.models.provider.custom");
|
|
67
|
+
}
|
|
68
|
+
|
|
53
69
|
function _renderCard(container, model, index) {
|
|
54
70
|
const isDefault = model.type === "default";
|
|
55
71
|
const isLite = model.type === "lite";
|
|
72
|
+
const providerName = _getProviderName(model);
|
|
73
|
+
const displayName = model.model || I18n.t("settings.models.unnamed");
|
|
56
74
|
|
|
57
75
|
const card = document.createElement("div");
|
|
58
|
-
card.className = "model-card";
|
|
76
|
+
card.className = "model-card-grid" + (isDefault ? " model-card-grid-default" : "");
|
|
59
77
|
card.dataset.index = index;
|
|
60
78
|
|
|
61
|
-
// Build provider options
|
|
62
|
-
const providerOptions = _providers.map(p =>
|
|
63
|
-
`<option value="${p.id}">${p.name}</option>`
|
|
64
|
-
).join("");
|
|
65
|
-
|
|
66
79
|
card.innerHTML = `
|
|
67
|
-
<div class="model-card-
|
|
68
|
-
<div class="model-card-
|
|
80
|
+
<div class="model-card-grid-info">
|
|
81
|
+
<div class="model-card-grid-name-row">
|
|
82
|
+
<span class="model-card-grid-name">${_esc(displayName)}</span>
|
|
69
83
|
${isDefault ? `<span class="badge badge-default">${I18n.t("settings.models.badge.default")}</span>` : ""}
|
|
70
|
-
${isLite
|
|
71
|
-
${!isDefault && !isLite ? `<span class="badge badge-secondary">${I18n.t("settings.models.badge.model", { n: index + 1 })}</span>` : ""}
|
|
84
|
+
${isLite ? `<span class="badge badge-lite">${I18n.t("settings.models.badge.lite")}</span>` : ""}
|
|
72
85
|
</div>
|
|
73
|
-
<div class="model-card-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
86
|
+
<div class="model-card-grid-provider">${_esc(providerName)}</div>
|
|
87
|
+
${model.model ? `<div class="model-card-grid-model">${_esc(model.model)}</div>` : ""}
|
|
88
|
+
<div class="model-card-grid-status">
|
|
89
|
+
<span class="model-test-result" data-index="${index}"></span>
|
|
77
90
|
</div>
|
|
78
91
|
</div>
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
<span
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
<div class="provider-promo-hint" data-index="${index}"></div>
|
|
97
|
-
</label>
|
|
98
|
-
<label class="model-field">
|
|
99
|
-
<span class="field-label">${I18n.t("settings.models.field.model")}</span>
|
|
100
|
-
<div class="model-name-combobox" data-index="${index}">
|
|
101
|
-
<input type="text" class="field-input model-name-input" data-key="model" data-index="${index}"
|
|
102
|
-
placeholder="${I18n.t("settings.models.placeholder.model")}" value="${_esc(model.model)}"
|
|
103
|
-
autocomplete="off">
|
|
104
|
-
<button class="model-name-dropdown-btn" type="button" title="Select from presets">
|
|
105
|
-
<svg width="12" height="12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
106
|
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
|
107
|
-
</svg>
|
|
108
|
-
</button>
|
|
109
|
-
<div class="model-name-dropdown" style="display:none"></div>
|
|
110
|
-
</div>
|
|
111
|
-
</label>
|
|
112
|
-
<label class="model-field">
|
|
113
|
-
<span class="field-label">${I18n.t("settings.models.field.baseurl")}</span>
|
|
114
|
-
<div class="base-url-combobox" data-index="${index}">
|
|
115
|
-
<input type="text" class="field-input base-url-input" data-key="base_url" data-index="${index}"
|
|
116
|
-
placeholder="${I18n.t("settings.models.placeholder.baseurl")}" value="${_esc(model.base_url)}"
|
|
117
|
-
autocomplete="off">
|
|
118
|
-
<button class="base-url-dropdown-btn" type="button" title="Select preset endpoint">
|
|
119
|
-
<svg width="12" height="12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
120
|
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
|
121
|
-
</svg>
|
|
122
|
-
</button>
|
|
123
|
-
<div class="base-url-dropdown" style="display:none"></div>
|
|
124
|
-
</div>
|
|
125
|
-
</label>
|
|
126
|
-
<label class="model-field">
|
|
127
|
-
<span class="field-label">
|
|
128
|
-
${I18n.t("settings.models.field.apikey")}
|
|
129
|
-
<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>
|
|
130
|
-
</span>
|
|
131
|
-
<div class="field-input-row">
|
|
132
|
-
<input type="password" class="field-input api-key-input" data-key="api_key" data-index="${index}"
|
|
133
|
-
placeholder="${I18n.t("settings.models.placeholder.apikey")}" value="${_esc(model.api_key_masked)}">
|
|
134
|
-
<button class="btn-toggle-key" data-index="${index}" title="Show/hide key">
|
|
135
|
-
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
136
|
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
|
137
|
-
<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>
|
|
138
|
-
</svg>
|
|
139
|
-
</button>
|
|
140
|
-
</div>
|
|
141
|
-
</label>
|
|
142
|
-
</div>
|
|
143
|
-
|
|
144
|
-
<div class="model-card-footer">
|
|
145
|
-
${!model.api_key_masked ? `<span class="model-card-docs-link" style="font-size:0.75rem;">
|
|
146
|
-
<span style="color:var(--muted,#6b7280);">${I18n.t("settings.models.field.docsGuide.question")}</span>
|
|
147
|
-
<a href="https://www.openclacky.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>
|
|
148
|
-
</span>` : ""}
|
|
149
|
-
<span class="model-test-result" data-index="${index}"></span>
|
|
150
|
-
<div class="model-card-actions-row">
|
|
151
|
-
${!isDefault ? `<button class="btn-set-default" data-index="${index}" title="${I18n.t("settings.models.btn.setDefault")}">${I18n.t("settings.models.btn.setDefault")}</button>` : ""}
|
|
152
|
-
<button class="btn-save-model btn-primary" data-index="${index}">${I18n.t("settings.models.btn.save")}</button>
|
|
153
|
-
</div>
|
|
92
|
+
<div class="model-card-grid-actions">
|
|
93
|
+
${!isDefault ? `<button class="btn-card-grid-action" data-index="${index}" data-action="default" title="${I18n.t("settings.models.btn.setDefault")}">
|
|
94
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
|
|
95
|
+
<span>${I18n.t("settings.models.btn.setDefault")}</span>
|
|
96
|
+
</button>` : ""}
|
|
97
|
+
<button class="btn-card-grid-action" data-index="${index}" data-action="test" title="${I18n.t("settings.models.btn.test")}">
|
|
98
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
|
99
|
+
<span>${I18n.t("settings.models.btn.test")}</span>
|
|
100
|
+
</button>
|
|
101
|
+
<button class="btn-card-grid-action" data-index="${index}" data-action="edit" title="${I18n.t("settings.models.btn.edit")}">
|
|
102
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
|
103
|
+
<span>${I18n.t("settings.models.btn.edit")}</span>
|
|
104
|
+
</button>
|
|
105
|
+
${_models.length > 1 ? `<button class="btn-card-grid-action btn-card-grid-action-danger" data-index="${index}" data-action="delete" title="${I18n.t("settings.models.btn.delete")}">
|
|
106
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
|
107
|
+
<span>${I18n.t("settings.models.btn.delete")}</span>
|
|
108
|
+
</button>` : ""}
|
|
154
109
|
</div>
|
|
155
110
|
`;
|
|
156
111
|
|
|
157
112
|
container.appendChild(card);
|
|
158
|
-
|
|
113
|
+
_bindCompactCardEvents(card, index);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function _bindCompactCardEvents(card, index) {
|
|
117
|
+
// Bind all action buttons using data-action attribute
|
|
118
|
+
card.querySelectorAll(".btn-card-grid-action").forEach(btn => {
|
|
119
|
+
btn.addEventListener("click", () => {
|
|
120
|
+
const action = btn.dataset.action;
|
|
121
|
+
switch (action) {
|
|
122
|
+
case "edit": _openModal(index); break;
|
|
123
|
+
case "test": _testModel(index); break;
|
|
124
|
+
case "delete": _removeModel(index); break;
|
|
125
|
+
case "default": _setAsDefault(index); break;
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── Modal Functions ────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
function _openModal(index = -1) {
|
|
134
|
+
const modal = document.getElementById("model-edit-modal");
|
|
135
|
+
const titleEl = document.getElementById("model-modal-title");
|
|
136
|
+
const indexInput = document.getElementById("model-modal-index");
|
|
137
|
+
|
|
138
|
+
indexInput.value = index;
|
|
139
|
+
|
|
140
|
+
// Populate provider dropdown
|
|
141
|
+
_populateModalProviderDropdown();
|
|
142
|
+
|
|
143
|
+
if (index >= 0 && _models[index]) {
|
|
144
|
+
// Edit mode
|
|
145
|
+
const model = _models[index];
|
|
146
|
+
titleEl.textContent = I18n.t("settings.models.modal.edit");
|
|
147
|
+
document.getElementById("model-modal-model").value = model.model || "";
|
|
148
|
+
document.getElementById("model-modal-baseurl").value = model.base_url || "";
|
|
149
|
+
document.getElementById("model-modal-apikey").value = model.api_key_masked || "";
|
|
150
|
+
document.getElementById("model-modal-default-field").style.display = "none";
|
|
151
|
+
|
|
152
|
+
// Set provider dropdown value
|
|
153
|
+
const providerName = _getProviderName(model);
|
|
154
|
+
const providerValue = document.getElementById("model-modal-provider-value");
|
|
155
|
+
providerValue.textContent = providerName;
|
|
156
|
+
providerValue.classList.remove("placeholder");
|
|
157
|
+
} else {
|
|
158
|
+
// Add mode
|
|
159
|
+
titleEl.textContent = I18n.t("settings.models.modal.add");
|
|
160
|
+
document.getElementById("model-modal-model").value = "";
|
|
161
|
+
document.getElementById("model-modal-baseurl").value = "";
|
|
162
|
+
document.getElementById("model-modal-apikey").value = "";
|
|
163
|
+
document.getElementById("model-modal-default-field").style.display = "";
|
|
164
|
+
document.getElementById("model-modal-set-default").checked = true;
|
|
165
|
+
|
|
166
|
+
// Reset provider dropdown
|
|
167
|
+
const providerValue = document.getElementById("model-modal-provider-value");
|
|
168
|
+
providerValue.textContent = I18n.t("settings.models.placeholder.provider");
|
|
169
|
+
providerValue.classList.add("placeholder");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Clear test result
|
|
173
|
+
document.getElementById("model-modal-test-result").textContent = "";
|
|
174
|
+
document.getElementById("model-modal-test-result").className = "model-test-result";
|
|
175
|
+
|
|
176
|
+
// Show promo hint by default for new models
|
|
177
|
+
const promoHint = document.getElementById("model-modal-promo-hint");
|
|
178
|
+
_showPromoHint(promoHint);
|
|
179
|
+
|
|
180
|
+
modal.style.display = "";
|
|
181
|
+
document.body.style.overflow = "hidden";
|
|
182
|
+
document.getElementById("model-modal-provider-trigger").focus();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function _closeModal() {
|
|
186
|
+
const modal = document.getElementById("model-edit-modal");
|
|
187
|
+
modal.style.display = "none";
|
|
188
|
+
document.body.style.overflow = "";
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function _populateModalProviderDropdown() {
|
|
192
|
+
const dropdown = document.getElementById("model-modal-provider-dropdown");
|
|
193
|
+
dropdown.innerHTML = `
|
|
194
|
+
<div class="custom-select-option" data-value="">${I18n.t("settings.models.placeholder.provider")}</div>
|
|
195
|
+
${_providers.map(p => `<div class="custom-select-option" data-value="${p.id}" data-label="${_esc(p.name)}">${_esc(p.name)}${p.id === "openclacky" ? ` <span class="provider-badge-recommended">${I18n.t("provider.recommended")}</span>` : ""}</div>`).join("")}
|
|
196
|
+
<div class="custom-select-option" data-value="custom">${I18n.t("settings.models.custom")}</div>
|
|
197
|
+
`;
|
|
198
|
+
|
|
199
|
+
// Bind click events for options
|
|
200
|
+
dropdown.querySelectorAll(".custom-select-option").forEach(option => {
|
|
201
|
+
option.addEventListener("click", (e) => {
|
|
202
|
+
e.stopPropagation();
|
|
203
|
+
const value = option.dataset.value;
|
|
204
|
+
const text = option.dataset.label || option.textContent.trim();
|
|
205
|
+
|
|
206
|
+
const providerValue = document.getElementById("model-modal-provider-value");
|
|
207
|
+
providerValue.textContent = text;
|
|
208
|
+
providerValue.classList.toggle("placeholder", !value);
|
|
209
|
+
|
|
210
|
+
dropdown.classList.remove("open");
|
|
211
|
+
document.getElementById("model-modal-provider-trigger").classList.remove("open");
|
|
212
|
+
|
|
213
|
+
// Show/hide promo hint
|
|
214
|
+
const promoHint = document.getElementById("model-modal-promo-hint");
|
|
215
|
+
if (value === "openclacky" || !value) {
|
|
216
|
+
_showPromoHint(promoHint);
|
|
217
|
+
} else {
|
|
218
|
+
promoHint.classList.remove("visible");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Auto-fill if provider selected
|
|
222
|
+
if (value && value !== "custom") {
|
|
223
|
+
const preset = _providers.find(p => p.id === value);
|
|
224
|
+
if (preset) {
|
|
225
|
+
document.getElementById("model-modal-model").value = preset.default_model || "";
|
|
226
|
+
document.getElementById("model-modal-baseurl").value = preset.base_url || "";
|
|
227
|
+
|
|
228
|
+
const apikeyLink = document.getElementById("model-modal-apikey-link");
|
|
229
|
+
if (preset.website_url) {
|
|
230
|
+
apikeyLink.href = preset.website_url;
|
|
231
|
+
apikeyLink.style.display = "";
|
|
232
|
+
} else {
|
|
233
|
+
apikeyLink.style.display = "none";
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Update model dropdown with provider's models
|
|
237
|
+
setTimeout(() => _updateModalModelDropdown(), 0);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function _testModel(index) {
|
|
245
|
+
const model = _models[index];
|
|
246
|
+
if (!model) return;
|
|
247
|
+
|
|
248
|
+
const testBtn = document.querySelector(`.btn-test-model[data-index="${index}"]`);
|
|
249
|
+
if (testBtn) testBtn.disabled = true;
|
|
250
|
+
|
|
251
|
+
_showTestResult(index, null, "");
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
const testRes = await fetch("/api/config/test", {
|
|
255
|
+
method: "POST",
|
|
256
|
+
headers: { "Content-Type": "application/json" },
|
|
257
|
+
body: JSON.stringify({
|
|
258
|
+
model: model.model,
|
|
259
|
+
base_url: model.base_url,
|
|
260
|
+
api_key: model.api_key_masked,
|
|
261
|
+
index
|
|
262
|
+
})
|
|
263
|
+
});
|
|
264
|
+
const testData = await testRes.json();
|
|
265
|
+
_showTestResult(index, testData.ok, testData.message);
|
|
266
|
+
} catch (e) {
|
|
267
|
+
_showTestResult(index, false, e.message);
|
|
268
|
+
} finally {
|
|
269
|
+
if (testBtn) testBtn.disabled = false;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function _saveModalModel() {
|
|
274
|
+
const saveBtn = document.getElementById("model-modal-save");
|
|
275
|
+
const index = parseInt(document.getElementById("model-modal-index").value, 10);
|
|
276
|
+
|
|
277
|
+
const model = document.getElementById("model-modal-model").value.trim();
|
|
278
|
+
const base_url = document.getElementById("model-modal-baseurl").value.trim();
|
|
279
|
+
const api_key = document.getElementById("model-modal-apikey").value.trim();
|
|
280
|
+
|
|
281
|
+
saveBtn.disabled = true;
|
|
282
|
+
|
|
283
|
+
// Step 1: Test first
|
|
284
|
+
saveBtn.textContent = I18n.t("settings.models.btn.testing");
|
|
285
|
+
_showModalTestResult(null, "");
|
|
286
|
+
|
|
287
|
+
try {
|
|
288
|
+
const testRes = await fetch("/api/config/test", {
|
|
289
|
+
method: "POST",
|
|
290
|
+
headers: { "Content-Type": "application/json" },
|
|
291
|
+
body: JSON.stringify({ model, base_url, api_key, index })
|
|
292
|
+
});
|
|
293
|
+
const testData = await testRes.json();
|
|
294
|
+
_showModalTestResult(testData.ok, testData.message);
|
|
295
|
+
|
|
296
|
+
if (!testData.ok) {
|
|
297
|
+
saveBtn.textContent = I18n.t("settings.models.btn.save");
|
|
298
|
+
saveBtn.disabled = false;
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
} catch (e) {
|
|
302
|
+
_showModalTestResult(false, e.message);
|
|
303
|
+
saveBtn.textContent = I18n.t("settings.models.btn.save");
|
|
304
|
+
saveBtn.disabled = false;
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Step 2: Save
|
|
309
|
+
saveBtn.textContent = I18n.t("settings.models.btn.saving");
|
|
310
|
+
|
|
311
|
+
const isNew = index < 0;
|
|
312
|
+
const existing = isNew ? {} : (_models[index] || {});
|
|
313
|
+
const hasId = !!existing.id;
|
|
314
|
+
|
|
315
|
+
const payload = { model, base_url, anthropic_format: false };
|
|
316
|
+
if (isNew) {
|
|
317
|
+
if (document.getElementById("model-modal-set-default").checked) {
|
|
318
|
+
payload.type = "default";
|
|
319
|
+
_models.forEach(m => { if (m.type === "default") m.type = null; });
|
|
320
|
+
} else {
|
|
321
|
+
payload.type = null;
|
|
322
|
+
}
|
|
323
|
+
} else {
|
|
324
|
+
payload.type = existing.type;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (api_key && !api_key.includes("****")) {
|
|
328
|
+
payload.api_key = api_key;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
try {
|
|
332
|
+
let res, data;
|
|
333
|
+
if (hasId) {
|
|
334
|
+
res = await fetch(`/api/config/models/${encodeURIComponent(existing.id)}`, {
|
|
335
|
+
method: "PATCH",
|
|
336
|
+
headers: { "Content-Type": "application/json" },
|
|
337
|
+
body: JSON.stringify(payload)
|
|
338
|
+
});
|
|
339
|
+
data = await res.json();
|
|
340
|
+
} else {
|
|
341
|
+
if (!payload.api_key) {
|
|
342
|
+
saveBtn.textContent = I18n.t("settings.models.btn.save");
|
|
343
|
+
saveBtn.disabled = false;
|
|
344
|
+
_showModalTestResult(false, I18n.t("settings.models.placeholder.apikey"));
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
res = await fetch(`/api/config/models`, {
|
|
348
|
+
method: "POST",
|
|
349
|
+
headers: { "Content-Type": "application/json" },
|
|
350
|
+
body: JSON.stringify(payload)
|
|
351
|
+
});
|
|
352
|
+
data = await res.json();
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (data.ok) {
|
|
356
|
+
saveBtn.textContent = I18n.t("settings.models.btn.saved");
|
|
357
|
+
setTimeout(() => {
|
|
358
|
+
_closeModal();
|
|
359
|
+
_load();
|
|
360
|
+
}, 800);
|
|
361
|
+
} else {
|
|
362
|
+
saveBtn.textContent = I18n.t("settings.models.btn.save");
|
|
363
|
+
saveBtn.disabled = false;
|
|
364
|
+
_showModalTestResult(false, data.error || I18n.t("settings.models.saveFailed"));
|
|
365
|
+
}
|
|
366
|
+
} catch (e) {
|
|
367
|
+
saveBtn.textContent = I18n.t("settings.models.btn.save");
|
|
368
|
+
saveBtn.disabled = false;
|
|
369
|
+
_showModalTestResult(false, e.message);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function _showModalTestResult(ok, message) {
|
|
374
|
+
const el = document.getElementById("model-modal-test-result");
|
|
375
|
+
if (!el) return;
|
|
376
|
+
if (ok === null) { el.textContent = I18n.t("settings.models.btn.testing"); el.className = "model-test-result result-testing"; return; }
|
|
377
|
+
el.textContent = ok ? `✓ ${message || I18n.t("settings.models.connected")}` : `✗ ${I18n.t("settings.models.testFail")}: ${message || I18n.t("settings.models.failed")}`;
|
|
378
|
+
el.className = `model-test-result ${ok ? "result-ok" : "result-fail"}`;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function _initModal() {
|
|
382
|
+
// Close button
|
|
383
|
+
document.getElementById("model-modal-close").addEventListener("click", _closeModal);
|
|
384
|
+
document.getElementById("model-modal-cancel").addEventListener("click", _closeModal);
|
|
385
|
+
|
|
386
|
+
// Save button
|
|
387
|
+
document.getElementById("model-modal-save").addEventListener("click", _saveModalModel);
|
|
388
|
+
|
|
389
|
+
// Click overlay to close
|
|
390
|
+
document.getElementById("model-edit-modal").addEventListener("click", (e) => {
|
|
391
|
+
if (e.target.id === "model-edit-modal") _closeModal();
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
// ESC to close
|
|
395
|
+
document.addEventListener("keydown", (e) => {
|
|
396
|
+
if (e.key === "Escape" && document.getElementById("model-edit-modal").style.display !== "none") {
|
|
397
|
+
_closeModal();
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
// Provider dropdown toggle
|
|
402
|
+
const providerTrigger = document.getElementById("model-modal-provider-trigger");
|
|
403
|
+
const providerDropdown = document.getElementById("model-modal-provider-dropdown");
|
|
404
|
+
providerTrigger.addEventListener("click", (e) => {
|
|
405
|
+
e.stopPropagation();
|
|
406
|
+
const isOpen = providerDropdown.classList.contains("open");
|
|
407
|
+
document.querySelectorAll(".custom-select-dropdown.open").forEach(d => {
|
|
408
|
+
d.classList.remove("open");
|
|
409
|
+
});
|
|
410
|
+
if (!isOpen) {
|
|
411
|
+
providerDropdown.classList.add("open");
|
|
412
|
+
providerTrigger.classList.add("open");
|
|
413
|
+
} else {
|
|
414
|
+
providerDropdown.classList.remove("open");
|
|
415
|
+
providerTrigger.classList.remove("open");
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
// Close dropdowns on outside click
|
|
420
|
+
document.addEventListener("click", () => {
|
|
421
|
+
providerDropdown.classList.remove("open");
|
|
422
|
+
providerTrigger.classList.remove("open");
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
// Toggle API key visibility
|
|
426
|
+
document.getElementById("model-modal-toggle-key").addEventListener("click", () => {
|
|
427
|
+
const input = document.getElementById("model-modal-apikey");
|
|
428
|
+
input.type = input.type === "password" ? "text" : "password";
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
// Model dropdown functionality
|
|
432
|
+
const modelDropdownBtn = document.getElementById("model-modal-model-dropdown-btn");
|
|
433
|
+
const modelDropdown = document.getElementById("model-modal-model-dropdown");
|
|
434
|
+
const modelInput = document.getElementById("model-modal-model");
|
|
435
|
+
|
|
436
|
+
modelDropdownBtn.addEventListener("click", (e) => {
|
|
437
|
+
e.stopPropagation();
|
|
438
|
+
const isOpen = modelDropdown.style.display === "block";
|
|
439
|
+
document.querySelectorAll(".model-name-dropdown, .base-url-dropdown").forEach(d => {
|
|
440
|
+
d.style.display = "none";
|
|
441
|
+
});
|
|
442
|
+
if (!isOpen) {
|
|
443
|
+
_updateModalModelDropdown();
|
|
444
|
+
modelDropdown.style.display = "block";
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
// Base URL dropdown functionality
|
|
449
|
+
const baseUrlDropdownBtn = document.getElementById("model-modal-baseurl-dropdown-btn");
|
|
450
|
+
const baseUrlDropdown = document.getElementById("model-modal-baseurl-dropdown");
|
|
451
|
+
const baseUrlInput = document.getElementById("model-modal-baseurl");
|
|
452
|
+
|
|
453
|
+
baseUrlDropdownBtn.addEventListener("click", (e) => {
|
|
454
|
+
e.stopPropagation();
|
|
455
|
+
const isOpen = baseUrlDropdown.style.display === "block";
|
|
456
|
+
document.querySelectorAll(".model-name-dropdown, .base-url-dropdown").forEach(d => {
|
|
457
|
+
d.style.display = "none";
|
|
458
|
+
});
|
|
459
|
+
if (!isOpen) {
|
|
460
|
+
_updateModalBaseUrlDropdown();
|
|
461
|
+
baseUrlDropdown.style.display = "block";
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
// Update model dropdown when base_url changes
|
|
466
|
+
baseUrlInput.addEventListener("blur", () => {
|
|
467
|
+
_updateModalModelDropdown();
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
// Close all modal dropdowns on document click
|
|
471
|
+
document.addEventListener("click", () => {
|
|
472
|
+
modelDropdown.style.display = "none";
|
|
473
|
+
baseUrlDropdown.style.display = "none";
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function _getModalCurrentProvider() {
|
|
478
|
+
const baseUrlInput = document.getElementById("model-modal-baseurl");
|
|
479
|
+
const url = (baseUrlInput?.value || "").trim().replace(/\/+$/, "");
|
|
480
|
+
if (!url) return null;
|
|
481
|
+
return _providers.find(p => {
|
|
482
|
+
const candidates = [p.base_url].concat(
|
|
483
|
+
Array.isArray(p.endpoint_variants) ? p.endpoint_variants.map(v => v.base_url) : []
|
|
484
|
+
).filter(Boolean);
|
|
485
|
+
return candidates.some(c => {
|
|
486
|
+
const norm = String(c).replace(/\/+$/, "");
|
|
487
|
+
return url === norm || url.startsWith(norm + "/");
|
|
488
|
+
});
|
|
489
|
+
}) || null;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function _updateModalModelDropdown() {
|
|
493
|
+
const modelDropdown = document.getElementById("model-modal-model-dropdown");
|
|
494
|
+
const modelInput = document.getElementById("model-modal-model");
|
|
495
|
+
const provider = _getModalCurrentProvider();
|
|
496
|
+
const models = provider?.models || [];
|
|
497
|
+
|
|
498
|
+
if (models.length === 0) {
|
|
499
|
+
modelDropdown.innerHTML = `<div class="model-dropdown-empty">${I18n.t("settings.models.noModels") || "No preset models available"}</div>`;
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
modelDropdown.innerHTML = models.map(m =>
|
|
504
|
+
`<div class="model-dropdown-option" data-value="${_esc(m)}">${_esc(m)}</div>`
|
|
505
|
+
).join("");
|
|
506
|
+
|
|
507
|
+
modelDropdown.querySelectorAll(".model-dropdown-option").forEach(opt => {
|
|
508
|
+
opt.addEventListener("click", (e) => {
|
|
509
|
+
e.stopPropagation();
|
|
510
|
+
modelInput.value = opt.dataset.value;
|
|
511
|
+
modelDropdown.style.display = "none";
|
|
512
|
+
});
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function _updateModalBaseUrlDropdown() {
|
|
517
|
+
const baseUrlDropdown = document.getElementById("model-modal-baseurl-dropdown");
|
|
518
|
+
const baseUrlInput = document.getElementById("model-modal-baseurl");
|
|
519
|
+
const provider = _getModalCurrentProvider();
|
|
520
|
+
const variants = provider && Array.isArray(provider.endpoint_variants) ? provider.endpoint_variants : [];
|
|
521
|
+
|
|
522
|
+
if (variants.length === 0) {
|
|
523
|
+
baseUrlDropdown.innerHTML = `<div class="model-dropdown-empty">${I18n.t("settings.models.baseurl.noVariants")}</div>`;
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
baseUrlDropdown.innerHTML = variants.map(v => {
|
|
528
|
+
const translated = v.label_key ? I18n.t(v.label_key) : null;
|
|
529
|
+
const labelText = (translated && translated !== v.label_key) ? translated : (v.label || v.base_url);
|
|
530
|
+
const label = _esc(labelText);
|
|
531
|
+
const url = _esc(v.base_url);
|
|
532
|
+
return `
|
|
533
|
+
<div class="model-dropdown-option base-url-dropdown-option" data-value="${url}">
|
|
534
|
+
<div class="base-url-dropdown-label">${label}</div>
|
|
535
|
+
<div class="base-url-dropdown-url">${url}</div>
|
|
536
|
+
</div>`;
|
|
537
|
+
}).join("");
|
|
538
|
+
|
|
539
|
+
baseUrlDropdown.querySelectorAll(".base-url-dropdown-option").forEach(opt => {
|
|
540
|
+
opt.addEventListener("click", (e) => {
|
|
541
|
+
e.stopPropagation();
|
|
542
|
+
baseUrlInput.value = opt.dataset.value;
|
|
543
|
+
baseUrlDropdown.style.display = "none";
|
|
544
|
+
_updateModalModelDropdown();
|
|
545
|
+
});
|
|
546
|
+
});
|
|
159
547
|
}
|
|
160
548
|
|
|
161
549
|
function _showPromoHint(promoHint) {
|
|
@@ -610,7 +998,7 @@ const Settings = (() => {
|
|
|
610
998
|
function _showTestResult(index, ok, message) {
|
|
611
999
|
const el = document.querySelector(`.model-test-result[data-index="${index}"]`);
|
|
612
1000
|
if (!el) return;
|
|
613
|
-
if (ok === null) { el.textContent = ""; el.className = "model-test-result"; return; }
|
|
1001
|
+
if (ok === null) { el.textContent = I18n.t("settings.models.btn.testing"); el.className = "model-test-result result-testing"; return; }
|
|
614
1002
|
el.textContent = ok ? `✓ ${message || I18n.t("settings.models.connected")}` : `✗ ${I18n.t("settings.models.testFail")}: ${message || I18n.t("settings.models.failed")}`;
|
|
615
1003
|
el.className = `model-test-result ${ok ? "result-ok" : "result-fail"}`;
|
|
616
1004
|
}
|
|
@@ -618,19 +1006,18 @@ const Settings = (() => {
|
|
|
618
1006
|
// ── Set as Default Model ───────────────────────────────────────────────────
|
|
619
1007
|
|
|
620
1008
|
async function _setAsDefault(index) {
|
|
621
|
-
const btn = document.querySelector(`.btn-
|
|
622
|
-
if (!btn) return;
|
|
623
|
-
|
|
1009
|
+
const btn = document.querySelector(`.btn-card-grid-action[data-index="${index}"][data-action="default"]`);
|
|
624
1010
|
const target = _models[index];
|
|
625
1011
|
if (!target || !target.id) {
|
|
626
|
-
// Can only promote saved models. Locally-added unsaved cards have
|
|
627
|
-
// no id yet — user must save them first.
|
|
628
1012
|
alert(I18n.t("settings.models.setDefaultFailed"));
|
|
629
1013
|
return;
|
|
630
1014
|
}
|
|
631
1015
|
|
|
632
|
-
btn
|
|
633
|
-
|
|
1016
|
+
if (btn) {
|
|
1017
|
+
btn.disabled = true;
|
|
1018
|
+
const span = btn.querySelector("span");
|
|
1019
|
+
if (span) span.textContent = I18n.t("settings.models.btn.setting");
|
|
1020
|
+
}
|
|
634
1021
|
|
|
635
1022
|
try {
|
|
636
1023
|
const res = await fetch(`/api/config/models/${encodeURIComponent(target.id)}/default`, {
|
|
@@ -639,17 +1026,25 @@ const Settings = (() => {
|
|
|
639
1026
|
const data = await res.json();
|
|
640
1027
|
|
|
641
1028
|
if (data.ok) {
|
|
642
|
-
|
|
643
|
-
|
|
1029
|
+
if (btn) {
|
|
1030
|
+
const span = btn.querySelector("span");
|
|
1031
|
+
if (span) span.textContent = I18n.t("settings.models.btn.done");
|
|
1032
|
+
}
|
|
644
1033
|
setTimeout(_load, 800);
|
|
645
1034
|
} else {
|
|
646
|
-
|
|
647
|
-
|
|
1035
|
+
if (btn) {
|
|
1036
|
+
btn.disabled = false;
|
|
1037
|
+
const span = btn.querySelector("span");
|
|
1038
|
+
if (span) span.textContent = I18n.t("settings.models.btn.setDefault");
|
|
1039
|
+
}
|
|
648
1040
|
alert(data.error || I18n.t("settings.models.setDefaultFailed"));
|
|
649
1041
|
}
|
|
650
1042
|
} catch (e) {
|
|
651
|
-
|
|
652
|
-
|
|
1043
|
+
if (btn) {
|
|
1044
|
+
btn.disabled = false;
|
|
1045
|
+
const span = btn.querySelector("span");
|
|
1046
|
+
if (span) span.textContent = I18n.t("settings.models.btn.setDefault");
|
|
1047
|
+
}
|
|
653
1048
|
alert(I18n.t("settings.models.errorPrefix") + e.message);
|
|
654
1049
|
}
|
|
655
1050
|
}
|
|
@@ -657,76 +1052,8 @@ const Settings = (() => {
|
|
|
657
1052
|
// ── Add / Remove model ───────────────────────────────────────────────────────
|
|
658
1053
|
|
|
659
1054
|
function _addModel() {
|
|
660
|
-
//
|
|
661
|
-
|
|
662
|
-
// "Save" (inside _saveModel). This means typing into a half-filled
|
|
663
|
-
// new card can never mutate the real config on the backend.
|
|
664
|
-
//
|
|
665
|
-
// UX convention: a newly added model becomes the "default" once
|
|
666
|
-
// saved — users overwhelmingly add a new model because they want to
|
|
667
|
-
// use it. We mark it default locally for the preview, and flip the
|
|
668
|
-
// other cards' type to null so the UI only shows one default badge.
|
|
669
|
-
// The backend enforces the single-default invariant on save anyway.
|
|
670
|
-
_models.forEach(m => { if (m.type === "default") m.type = null; });
|
|
671
|
-
_models.push({
|
|
672
|
-
id: null, // will be assigned by server on first save
|
|
673
|
-
index: _models.length,
|
|
674
|
-
model: "",
|
|
675
|
-
base_url: "",
|
|
676
|
-
api_key_masked: "",
|
|
677
|
-
anthropic_format: false,
|
|
678
|
-
type: "default"
|
|
679
|
-
});
|
|
680
|
-
_renderCards();
|
|
681
|
-
|
|
682
|
-
// Scroll the new (last) card into view.
|
|
683
|
-
//
|
|
684
|
-
// Why not just scrollIntoView: the settings panel uses #settings-body
|
|
685
|
-
// as a flex scroll container. When the page hasn't overflowed yet,
|
|
686
|
-
// scrollIntoView is a no-op; and across browsers the "smooth" path
|
|
687
|
-
// has been flaky when the scrolling element is a flex child.
|
|
688
|
-
//
|
|
689
|
-
// Two rAFs ensure the freshly rendered card has been laid out before
|
|
690
|
-
// we compute its position.
|
|
691
|
-
requestAnimationFrame(() => {
|
|
692
|
-
requestAnimationFrame(() => {
|
|
693
|
-
const container = document.getElementById("settings-body");
|
|
694
|
-
if (!container) return;
|
|
695
|
-
const cards = container.querySelectorAll(".model-card");
|
|
696
|
-
const last = cards[cards.length - 1];
|
|
697
|
-
if (!last) return;
|
|
698
|
-
|
|
699
|
-
// Align the new card's top with the container's top, minus a
|
|
700
|
-
// small breathing-room offset so it doesn't touch the edge.
|
|
701
|
-
// offsetTop is relative to the nearest positioned ancestor, so
|
|
702
|
-
// we compute position via getBoundingClientRect delta — robust
|
|
703
|
-
// regardless of the card's offsetParent chain.
|
|
704
|
-
const OFFSET = 16;
|
|
705
|
-
const delta = last.getBoundingClientRect().top
|
|
706
|
-
- container.getBoundingClientRect().top;
|
|
707
|
-
container.scrollTo({
|
|
708
|
-
top: container.scrollTop + delta - OFFSET,
|
|
709
|
-
behavior: "smooth"
|
|
710
|
-
});
|
|
711
|
-
|
|
712
|
-
// Put focus on the new card so the user can start configuring.
|
|
713
|
-
// Priority order:
|
|
714
|
-
// 1. The provider `.custom-select-trigger` — nudges the user to
|
|
715
|
-
// pick a provider first (step 1 of the 3-field form). It's a
|
|
716
|
-
// div with tabindex="0", so it gets the accent border via
|
|
717
|
-
// `:focus` without expanding the dropdown.
|
|
718
|
-
// 2. Fall back to the first form input (used when the quick-setup
|
|
719
|
-
// field is hidden, e.g. on a model card that already has values).
|
|
720
|
-
const providerTrigger = last.querySelector(".custom-select-wrapper .custom-select-trigger");
|
|
721
|
-
const isVisible = el => el && el.offsetParent !== null;
|
|
722
|
-
if (isVisible(providerTrigger)) {
|
|
723
|
-
providerTrigger.focus({ preventScroll: true });
|
|
724
|
-
} else {
|
|
725
|
-
const firstInput = last.querySelector("input, select, textarea");
|
|
726
|
-
if (firstInput) firstInput.focus({ preventScroll: true });
|
|
727
|
-
}
|
|
728
|
-
});
|
|
729
|
-
});
|
|
1055
|
+
// Open modal in add mode (index = -1)
|
|
1056
|
+
_openModal(-1);
|
|
730
1057
|
}
|
|
731
1058
|
|
|
732
1059
|
async function _removeModel(index) {
|
|
@@ -1058,6 +1385,52 @@ const Settings = (() => {
|
|
|
1058
1385
|
|
|
1059
1386
|
// ── Init ──────────────────────────────────────────────────────────────────────
|
|
1060
1387
|
|
|
1388
|
+
function _initTabs() {
|
|
1389
|
+
const tabs = document.querySelectorAll("#settings-tabs .settings-tab");
|
|
1390
|
+
const contents = document.querySelectorAll("#settings-body .settings-tab-content");
|
|
1391
|
+
|
|
1392
|
+
tabs.forEach(tab => {
|
|
1393
|
+
tab.addEventListener("click", () => {
|
|
1394
|
+
const targetTab = tab.dataset.tab;
|
|
1395
|
+
|
|
1396
|
+
// Update tab buttons
|
|
1397
|
+
tabs.forEach(t => t.classList.toggle("active", t.dataset.tab === targetTab));
|
|
1398
|
+
|
|
1399
|
+
// Update tab content panels
|
|
1400
|
+
contents.forEach(c => {
|
|
1401
|
+
const isActive = c.dataset.tabContent === targetTab;
|
|
1402
|
+
c.classList.toggle("active", isActive);
|
|
1403
|
+
c.style.display = isActive ? "" : "none";
|
|
1404
|
+
});
|
|
1405
|
+
});
|
|
1406
|
+
});
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
function _applyAboutTabVisibility() {
|
|
1410
|
+
const branded = typeof Brand !== "undefined" && Brand.branded;
|
|
1411
|
+
const tabBtn = document.querySelector('#settings-tabs .settings-tab[data-tab="about"]');
|
|
1412
|
+
const tabPanel = document.querySelector('#settings-body .settings-tab-content[data-tab-content="about"]');
|
|
1413
|
+
if (!tabBtn || !tabPanel) return;
|
|
1414
|
+
|
|
1415
|
+
if (branded) {
|
|
1416
|
+
tabBtn.style.display = "none";
|
|
1417
|
+
if (tabBtn.classList.contains("active")) {
|
|
1418
|
+
tabBtn.classList.remove("active");
|
|
1419
|
+
tabPanel.classList.remove("active");
|
|
1420
|
+
tabPanel.style.display = "none";
|
|
1421
|
+
const fallback = document.querySelector('#settings-tabs .settings-tab[data-tab="models"]');
|
|
1422
|
+
const fallbackPanel = document.querySelector('#settings-body .settings-tab-content[data-tab-content="models"]');
|
|
1423
|
+
if (fallback) fallback.classList.add("active");
|
|
1424
|
+
if (fallbackPanel) {
|
|
1425
|
+
fallbackPanel.classList.add("active");
|
|
1426
|
+
fallbackPanel.style.display = "";
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
} else {
|
|
1430
|
+
tabBtn.style.display = "";
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1061
1434
|
function _initLangBtns() {
|
|
1062
1435
|
// Highlight the active language button on open
|
|
1063
1436
|
document.querySelectorAll("#language-section .settings-lang-btn").forEach(btn => {
|
|
@@ -1071,7 +1444,70 @@ const Settings = (() => {
|
|
|
1071
1444
|
});
|
|
1072
1445
|
}
|
|
1073
1446
|
|
|
1447
|
+
// ── Advanced Settings ────────────────────────────────────────────────────────
|
|
1448
|
+
|
|
1449
|
+
async function _loadAdvancedSettings() {
|
|
1450
|
+
try {
|
|
1451
|
+
const res = await fetch("/api/config/settings");
|
|
1452
|
+
const data = await res.json();
|
|
1453
|
+
if (data.ok) {
|
|
1454
|
+
const comp = document.getElementById("settings-compression-toggle");
|
|
1455
|
+
const cache = document.getElementById("settings-prompt-caching-toggle");
|
|
1456
|
+
const mem = document.getElementById("settings-memory-update-toggle");
|
|
1457
|
+
if (comp) comp.checked = data.enable_compression !== false;
|
|
1458
|
+
if (cache) cache.checked = data.enable_prompt_caching !== false;
|
|
1459
|
+
if (mem) mem.checked = data.memory_update_enabled !== false;
|
|
1460
|
+
}
|
|
1461
|
+
} catch (e) {
|
|
1462
|
+
console.error("Failed to load advanced settings:", e);
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
async function _saveAdvancedSetting(key, value) {
|
|
1467
|
+
try {
|
|
1468
|
+
await fetch("/api/config/settings", {
|
|
1469
|
+
method: "PATCH",
|
|
1470
|
+
headers: { "Content-Type": "application/json" },
|
|
1471
|
+
body: JSON.stringify({ [key]: value })
|
|
1472
|
+
});
|
|
1473
|
+
} catch (e) {
|
|
1474
|
+
console.error("Failed to save setting:", e);
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
function _initAdvancedSettings() {
|
|
1479
|
+
_loadAdvancedSettings();
|
|
1480
|
+
document.getElementById("settings-compression-toggle")?.addEventListener("change", (e) => {
|
|
1481
|
+
_saveAdvancedSetting("enable_compression", e.target.checked);
|
|
1482
|
+
});
|
|
1483
|
+
document.getElementById("settings-prompt-caching-toggle")?.addEventListener("change", (e) => {
|
|
1484
|
+
_saveAdvancedSetting("enable_prompt_caching", e.target.checked);
|
|
1485
|
+
});
|
|
1486
|
+
document.getElementById("settings-memory-update-toggle")?.addEventListener("change", (e) => {
|
|
1487
|
+
_saveAdvancedSetting("memory_update_enabled", e.target.checked);
|
|
1488
|
+
});
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
// ── About Tab ───────────────────────────────────────────────────────────────
|
|
1492
|
+
|
|
1493
|
+
async function _loadAboutInfo() {
|
|
1494
|
+
try {
|
|
1495
|
+
const res = await fetch("/api/version");
|
|
1496
|
+
const data = await res.json();
|
|
1497
|
+
if (data.current) {
|
|
1498
|
+
const el = document.getElementById("about-version");
|
|
1499
|
+
if (el) el.textContent = `v${data.current}`;
|
|
1500
|
+
}
|
|
1501
|
+
} catch (e) {
|
|
1502
|
+
console.error("Failed to load version info:", e);
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1074
1506
|
function init() {
|
|
1507
|
+
_initTabs();
|
|
1508
|
+
_initModal();
|
|
1509
|
+
_initAdvancedSettings();
|
|
1510
|
+
_loadAboutInfo();
|
|
1075
1511
|
document.getElementById("btn-add-model").addEventListener("click", _addModel);
|
|
1076
1512
|
document.getElementById("btn-rerun-onboard").addEventListener("click", _rerunOnboard);
|
|
1077
1513
|
document.getElementById("btn-browser-setup").addEventListener("click", _setupBrowser);
|
|
@@ -1142,11 +1578,87 @@ const Settings = (() => {
|
|
|
1142
1578
|
|
|
1143
1579
|
_initLangBtns();
|
|
1144
1580
|
_initFontBtns();
|
|
1581
|
+
_initCurrencyBtns();
|
|
1145
1582
|
|
|
1146
1583
|
// Re-render model cards when language changes (dynamic HTML, not data-i18n)
|
|
1147
1584
|
document.addEventListener("langchange", () => _renderCards());
|
|
1148
1585
|
}
|
|
1149
1586
|
|
|
1587
|
+
// ── Currency ──────────────────────────────────────────────────────────
|
|
1588
|
+
const CURRENCY_STORAGE_KEY = "clacky-currency";
|
|
1589
|
+
const EXCHANGE_RATE_STORAGE_KEY = "clacky-exchange-rate";
|
|
1590
|
+
const CURRENCY_DEFAULT = "USD";
|
|
1591
|
+
const DEFAULT_EXCHANGE_RATE = 6.7944;
|
|
1592
|
+
|
|
1593
|
+
function _applyCurrency(currency) {
|
|
1594
|
+
try { localStorage.setItem(CURRENCY_STORAGE_KEY, currency); } catch (_) {}
|
|
1595
|
+
// Update active state on all currency buttons
|
|
1596
|
+
document.querySelectorAll("#currency-section .settings-lang-btn").forEach(btn => {
|
|
1597
|
+
btn.classList.toggle("active", btn.dataset.currency === currency);
|
|
1598
|
+
});
|
|
1599
|
+
// Show/hide exchange rate input based on currency
|
|
1600
|
+
const exchangeRateSection = document.getElementById("exchange-rate-section");
|
|
1601
|
+
if (exchangeRateSection) {
|
|
1602
|
+
exchangeRateSection.style.display = currency === "CNY" ? "block" : "none";
|
|
1603
|
+
}
|
|
1604
|
+
// Dispatch event for billing panel to update
|
|
1605
|
+
document.dispatchEvent(new CustomEvent("currencychange", { detail: { currency } }));
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
function _getExchangeRate() {
|
|
1609
|
+
try {
|
|
1610
|
+
const rate = localStorage.getItem(EXCHANGE_RATE_STORAGE_KEY);
|
|
1611
|
+
if (rate) {
|
|
1612
|
+
const parsed = parseFloat(rate);
|
|
1613
|
+
if (!isNaN(parsed) && parsed > 0) return parsed;
|
|
1614
|
+
}
|
|
1615
|
+
} catch (_) {}
|
|
1616
|
+
return DEFAULT_EXCHANGE_RATE;
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
function _setExchangeRate(rate) {
|
|
1620
|
+
try {
|
|
1621
|
+
if (rate && !isNaN(rate) && rate > 0) {
|
|
1622
|
+
localStorage.setItem(EXCHANGE_RATE_STORAGE_KEY, rate.toString());
|
|
1623
|
+
document.dispatchEvent(new CustomEvent("currencychange"));
|
|
1624
|
+
}
|
|
1625
|
+
} catch (_) {}
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
function _initCurrencyBtns() {
|
|
1629
|
+
// Apply saved preference (or default) on page load
|
|
1630
|
+
let saved = null;
|
|
1631
|
+
try { saved = localStorage.getItem(CURRENCY_STORAGE_KEY); } catch (_) {}
|
|
1632
|
+
const current = saved || CURRENCY_DEFAULT;
|
|
1633
|
+
|
|
1634
|
+
// Wire up button clicks
|
|
1635
|
+
document.querySelectorAll("#currency-section .settings-lang-btn").forEach(btn => {
|
|
1636
|
+
btn.classList.toggle("active", btn.dataset.currency === current);
|
|
1637
|
+
btn.addEventListener("click", () => {
|
|
1638
|
+
_applyCurrency(btn.dataset.currency);
|
|
1639
|
+
});
|
|
1640
|
+
});
|
|
1641
|
+
|
|
1642
|
+
// Initialize exchange rate input
|
|
1643
|
+
const exchangeRateInput = document.getElementById("settings-exchange-rate");
|
|
1644
|
+
const exchangeRateSection = document.getElementById("exchange-rate-section");
|
|
1645
|
+
if (exchangeRateInput && exchangeRateSection) {
|
|
1646
|
+
// Set initial value
|
|
1647
|
+
exchangeRateInput.value = _getExchangeRate();
|
|
1648
|
+
// Show/hide based on current currency
|
|
1649
|
+
exchangeRateSection.style.display = current === "CNY" ? "block" : "none";
|
|
1650
|
+
// Handle input changes
|
|
1651
|
+
exchangeRateInput.addEventListener("change", () => {
|
|
1652
|
+
const rate = parseFloat(exchangeRateInput.value);
|
|
1653
|
+
if (!isNaN(rate) && rate > 0) {
|
|
1654
|
+
_setExchangeRate(rate);
|
|
1655
|
+
} else {
|
|
1656
|
+
exchangeRateInput.value = _getExchangeRate();
|
|
1657
|
+
}
|
|
1658
|
+
});
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1150
1662
|
// ── Font Size ──────────────────────────────────────────────────────────
|
|
1151
1663
|
const FONT_STORAGE_KEY = "clacky-font-size";
|
|
1152
1664
|
const FONT_DEFAULT = "medium";
|