openclacky 1.2.8 → 1.2.10
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 +35 -0
- data/lib/clacky/agent/llm_caller.rb +3 -0
- data/lib/clacky/agent/message_compressor_helper.rb +6 -5
- data/lib/clacky/agent/session_serializer.rb +4 -0
- data/lib/clacky/agent.rb +9 -0
- data/lib/clacky/agent_config.rb +111 -8
- data/lib/clacky/brand_config.rb +1 -0
- data/lib/clacky/cli.rb +49 -22
- data/lib/clacky/client.rb +6 -2
- data/lib/clacky/default_skills/channel-manager/SKILL.md +33 -110
- data/lib/clacky/default_skills/media-gen/SKILL.md +128 -0
- data/lib/clacky/idle_compression_timer.rb +38 -15
- data/lib/clacky/media/base.rb +68 -0
- data/lib/clacky/media/gemini.rb +36 -0
- data/lib/clacky/media/generator.rb +78 -0
- data/lib/clacky/media/openai_compat.rb +168 -0
- data/lib/clacky/providers.rb +89 -2
- data/lib/clacky/rich_ui_controller.rb +1549 -0
- data/lib/clacky/server/channel/adapters/weixin/adapter.rb +24 -2
- data/lib/clacky/server/channel/channel_manager.rb +89 -2
- data/lib/clacky/server/http_server.rb +334 -29
- data/lib/clacky/session_manager.rb +9 -8
- data/lib/clacky/telemetry.rb +26 -6
- data/lib/clacky/ui2/layout_manager.rb +11 -7
- data/lib/clacky/ui2/ui_controller.rb +2 -2
- data/lib/clacky/ui_interface.rb +1 -1
- data/lib/clacky/utils/model_pricing.rb +75 -53
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +393 -14
- data/lib/clacky/web/billing.js +1 -1
- data/lib/clacky/web/i18n.js +86 -4
- data/lib/clacky/web/index.html +23 -3
- data/lib/clacky/web/model-tester.js +58 -0
- data/lib/clacky/web/onboard.js +17 -30
- data/lib/clacky/web/sessions.js +443 -2
- data/lib/clacky/web/settings.js +372 -97
- data/lib/clacky/web/workspace.js +9 -1
- data/lib/clacky.rb +3 -0
- data/scripts/build/lib/network.sh +61 -30
- data/scripts/install.ps1 +16 -4
- data/scripts/install.sh +61 -30
- data/scripts/install_browser.sh +61 -30
- data/scripts/install_full.sh +61 -30
- data/scripts/install_rails_deps.sh +61 -30
- data/scripts/install_system_deps.sh +61 -30
- metadata +12 -3
- data/lib/clacky/default_skills/channel-manager/feishu_setup.rb +0 -574
data/lib/clacky/web/settings.js
CHANGED
|
@@ -16,6 +16,7 @@ const Settings = (() => {
|
|
|
16
16
|
|
|
17
17
|
function open() {
|
|
18
18
|
_load();
|
|
19
|
+
_loadMedia();
|
|
19
20
|
_loadBrand();
|
|
20
21
|
_loadBrowserStatus();
|
|
21
22
|
_applyAboutTabVisibility();
|
|
@@ -101,22 +102,24 @@ const Settings = (() => {
|
|
|
101
102
|
</div>
|
|
102
103
|
</div>
|
|
103
104
|
<div class="model-card-grid-actions">
|
|
104
|
-
${!isDefault ? `<button class="btn-card-grid-action" data-index="${index}" data-action="default"
|
|
105
|
-
<svg width="
|
|
105
|
+
${!isDefault ? `<button class="btn-card-grid-action btn-card-grid-action-primary" data-index="${index}" data-action="default">
|
|
106
|
+
<svg width="13" height="13" 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>
|
|
106
107
|
<span>${I18n.t("settings.models.btn.setDefault")}</span>
|
|
107
108
|
</button>` : ""}
|
|
108
|
-
<
|
|
109
|
-
<
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
<
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
109
|
+
<div class="model-card-grid-toolbar">
|
|
110
|
+
<button class="btn-card-grid-action" data-index="${index}" data-action="test">
|
|
111
|
+
<svg width="13" height="13" 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>
|
|
112
|
+
<span>${I18n.t("settings.models.btn.test")}</span>
|
|
113
|
+
</button>
|
|
114
|
+
<button class="btn-card-grid-action" data-index="${index}" data-action="edit">
|
|
115
|
+
<svg width="13" height="13" 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>
|
|
116
|
+
<span>${I18n.t("settings.models.btn.edit")}</span>
|
|
117
|
+
</button>
|
|
118
|
+
${_models.length > 1 ? `<button class="btn-card-grid-action btn-card-grid-action-danger" data-index="${index}" data-action="delete">
|
|
119
|
+
<svg width="13" height="13" 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>
|
|
120
|
+
<span>${I18n.t("settings.models.btn.delete")}</span>
|
|
121
|
+
</button>` : ""}
|
|
122
|
+
</div>
|
|
120
123
|
</div>
|
|
121
124
|
${websiteUrl ? `<div class="model-card-grid-footer">
|
|
122
125
|
<a class="model-card-grid-link" href="${_esc(websiteUrl)}" target="_blank" rel="noopener noreferrer">
|
|
@@ -131,8 +134,7 @@ const Settings = (() => {
|
|
|
131
134
|
}
|
|
132
135
|
|
|
133
136
|
function _bindCompactCardEvents(card, index) {
|
|
134
|
-
|
|
135
|
-
card.querySelectorAll(".btn-card-grid-action").forEach(btn => {
|
|
137
|
+
card.querySelectorAll("[data-action]").forEach(btn => {
|
|
136
138
|
btn.addEventListener("click", () => {
|
|
137
139
|
const action = btn.dataset.action;
|
|
138
140
|
switch (action) {
|
|
@@ -164,7 +166,8 @@ const Settings = (() => {
|
|
|
164
166
|
document.getElementById("model-modal-model").value = model.model || "";
|
|
165
167
|
document.getElementById("model-modal-baseurl").value = model.base_url || "";
|
|
166
168
|
document.getElementById("model-modal-apikey").value = model.api_key_masked || "";
|
|
167
|
-
document.getElementById("model-modal-default-field").style.display = "
|
|
169
|
+
document.getElementById("model-modal-default-field").style.display = "";
|
|
170
|
+
document.getElementById("model-modal-set-default").checked = (model.type === "default");
|
|
168
171
|
|
|
169
172
|
// Set provider dropdown value
|
|
170
173
|
const matched = _findProviderByBaseUrl(model.base_url);
|
|
@@ -182,6 +185,8 @@ const Settings = (() => {
|
|
|
182
185
|
document.getElementById("model-modal-baseurl").value = "";
|
|
183
186
|
document.getElementById("model-modal-apikey").value = "";
|
|
184
187
|
document.getElementById("model-modal-default-field").style.display = "";
|
|
188
|
+
// Default to checked for new models — most users want their first/new
|
|
189
|
+
// model to take over as the default.
|
|
185
190
|
document.getElementById("model-modal-set-default").checked = true;
|
|
186
191
|
|
|
187
192
|
// Reset provider dropdown
|
|
@@ -282,23 +287,14 @@ const Settings = (() => {
|
|
|
282
287
|
_showTestResult(index, null, "");
|
|
283
288
|
|
|
284
289
|
try {
|
|
285
|
-
const
|
|
286
|
-
model:
|
|
290
|
+
const result = await ModelTester.testConnection({
|
|
291
|
+
model: model.model,
|
|
287
292
|
base_url: model.base_url,
|
|
288
|
-
api_key:
|
|
289
|
-
index
|
|
290
|
-
|
|
291
|
-
if (model.anthropic_format) body.anthropic_format = true;
|
|
292
|
-
|
|
293
|
-
const testRes = await fetch("/api/config/test", {
|
|
294
|
-
method: "POST",
|
|
295
|
-
headers: { "Content-Type": "application/json" },
|
|
296
|
-
body: JSON.stringify(body)
|
|
293
|
+
api_key: model.api_key_masked,
|
|
294
|
+
index,
|
|
295
|
+
anthropic_format: model.anthropic_format
|
|
297
296
|
});
|
|
298
|
-
|
|
299
|
-
_showTestResult(index, testData.ok, testData.message);
|
|
300
|
-
} catch (e) {
|
|
301
|
-
_showTestResult(index, false, e.message);
|
|
297
|
+
_showTestResult(index, result.ok, result.message);
|
|
302
298
|
} finally {
|
|
303
299
|
if (testBtn) testBtn.disabled = false;
|
|
304
300
|
}
|
|
@@ -309,7 +305,7 @@ const Settings = (() => {
|
|
|
309
305
|
const index = parseInt(document.getElementById("model-modal-index").value, 10);
|
|
310
306
|
|
|
311
307
|
const model = document.getElementById("model-modal-model").value.trim();
|
|
312
|
-
|
|
308
|
+
let base_url = document.getElementById("model-modal-baseurl").value.trim();
|
|
313
309
|
const api_key = document.getElementById("model-modal-apikey").value.trim();
|
|
314
310
|
|
|
315
311
|
saveBtn.disabled = true;
|
|
@@ -325,25 +321,19 @@ const Settings = (() => {
|
|
|
325
321
|
saveBtn.textContent = I18n.t("settings.models.btn.testing");
|
|
326
322
|
_showModalTestResult(null, "");
|
|
327
323
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
324
|
+
const result = await ModelTester.testConnection({
|
|
325
|
+
model, base_url, api_key, index, anthropic_format
|
|
326
|
+
});
|
|
331
327
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
const testData = await testRes.json();
|
|
338
|
-
_showModalTestResult(testData.ok, testData.message);
|
|
328
|
+
if (result.rewrote) {
|
|
329
|
+
base_url = result.base_url;
|
|
330
|
+
const baseInput = document.getElementById("model-modal-baseurl");
|
|
331
|
+
if (baseInput) baseInput.value = base_url;
|
|
332
|
+
}
|
|
339
333
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
return;
|
|
344
|
-
}
|
|
345
|
-
} catch (e) {
|
|
346
|
-
_showModalTestResult(false, e.message);
|
|
334
|
+
_showModalTestResult(result.ok, result.message);
|
|
335
|
+
|
|
336
|
+
if (!result.ok) {
|
|
347
337
|
saveBtn.textContent = I18n.t("settings.models.btn.save");
|
|
348
338
|
saveBtn.disabled = false;
|
|
349
339
|
return;
|
|
@@ -357,60 +347,38 @@ const Settings = (() => {
|
|
|
357
347
|
const hasId = !!existing.id;
|
|
358
348
|
|
|
359
349
|
const payload = { model, base_url, anthropic_format };
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
}
|
|
367
|
-
} else {
|
|
368
|
-
payload.type = existing.type;
|
|
350
|
+
const setDefault = document.getElementById("model-modal-set-default").checked;
|
|
351
|
+
payload.type = setDefault ? "default" : null;
|
|
352
|
+
if (setDefault) {
|
|
353
|
+
_models.forEach((m, i) => {
|
|
354
|
+
if (i !== index && m.type === "default") m.type = null;
|
|
355
|
+
});
|
|
369
356
|
}
|
|
370
357
|
|
|
371
358
|
if (api_key && !api_key.includes("****")) {
|
|
372
359
|
payload.api_key = api_key;
|
|
373
360
|
}
|
|
374
361
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
body: JSON.stringify(payload)
|
|
382
|
-
});
|
|
383
|
-
data = await res.json();
|
|
384
|
-
} else {
|
|
385
|
-
if (!payload.api_key) {
|
|
386
|
-
saveBtn.textContent = I18n.t("settings.models.btn.save");
|
|
387
|
-
saveBtn.disabled = false;
|
|
388
|
-
_showModalTestResult(false, I18n.t("settings.models.placeholder.apikey"));
|
|
389
|
-
return;
|
|
390
|
-
}
|
|
391
|
-
res = await fetch(`/api/config/models`, {
|
|
392
|
-
method: "POST",
|
|
393
|
-
headers: { "Content-Type": "application/json" },
|
|
394
|
-
body: JSON.stringify(payload)
|
|
395
|
-
});
|
|
396
|
-
data = await res.json();
|
|
397
|
-
}
|
|
362
|
+
if (!hasId && !payload.api_key) {
|
|
363
|
+
saveBtn.textContent = I18n.t("settings.models.btn.save");
|
|
364
|
+
saveBtn.disabled = false;
|
|
365
|
+
_showModalTestResult(false, I18n.t("settings.models.placeholder.apikey"));
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
398
368
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
}
|
|
410
|
-
} catch (e) {
|
|
369
|
+
const saveResult = await ModelTester.saveModel(payload, { existingId: hasId ? existing.id : null });
|
|
370
|
+
|
|
371
|
+
if (saveResult.ok) {
|
|
372
|
+
saveBtn.textContent = I18n.t("settings.models.btn.saved");
|
|
373
|
+
setTimeout(() => {
|
|
374
|
+
_closeModal();
|
|
375
|
+
_load();
|
|
376
|
+
_loadMedia();
|
|
377
|
+
}, 800);
|
|
378
|
+
} else {
|
|
411
379
|
saveBtn.textContent = I18n.t("settings.models.btn.save");
|
|
412
380
|
saveBtn.disabled = false;
|
|
413
|
-
_showModalTestResult(false,
|
|
381
|
+
_showModalTestResult(false, saveResult.error || I18n.t("settings.models.saveFailed"));
|
|
414
382
|
}
|
|
415
383
|
}
|
|
416
384
|
|
|
@@ -1077,7 +1045,7 @@ const Settings = (() => {
|
|
|
1077
1045
|
const span = btn.querySelector("span");
|
|
1078
1046
|
if (span) span.textContent = I18n.t("settings.models.btn.done");
|
|
1079
1047
|
}
|
|
1080
|
-
setTimeout(_load, 800);
|
|
1048
|
+
setTimeout(() => { _load(); _loadMedia(); }, 800);
|
|
1081
1049
|
} else {
|
|
1082
1050
|
if (btn) {
|
|
1083
1051
|
btn.disabled = false;
|
|
@@ -1132,6 +1100,7 @@ const Settings = (() => {
|
|
|
1132
1100
|
|
|
1133
1101
|
// Reload fresh state
|
|
1134
1102
|
_load();
|
|
1103
|
+
_loadMedia();
|
|
1135
1104
|
}
|
|
1136
1105
|
|
|
1137
1106
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
@@ -1550,6 +1519,259 @@ const Settings = (() => {
|
|
|
1550
1519
|
}
|
|
1551
1520
|
}
|
|
1552
1521
|
|
|
1522
|
+
|
|
1523
|
+
// ── Media generation (Settings → Models tab, below the model cards) ───
|
|
1524
|
+
// Per-kind tri-state: off / auto (derive from default) / custom (user-set).
|
|
1525
|
+
// Backend API:
|
|
1526
|
+
// GET /api/config/media → { media: { image: {...}, ... }, default_provider: {...} }
|
|
1527
|
+
// PATCH /api/config/media/:kind → body { source: "off"|"auto"|"custom", model?, base_url?, api_key?, anthropic_format? }
|
|
1528
|
+
// The state object per kind:
|
|
1529
|
+
// { source, configured, model, base_url, api_key_masked, provider, available }
|
|
1530
|
+
|
|
1531
|
+
const MEDIA_KINDS = ["image", "video", "audio"];
|
|
1532
|
+
let _mediaState = null;
|
|
1533
|
+
let _mediaDefaults = null;
|
|
1534
|
+
const _mediaCustomDraft = {};
|
|
1535
|
+
|
|
1536
|
+
async function _loadMedia() {
|
|
1537
|
+
const container = document.getElementById("media-rows");
|
|
1538
|
+
if (!container) return;
|
|
1539
|
+
container.innerHTML = `<div class="settings-loading">${I18n.t("settings.media.loading")}</div>`;
|
|
1540
|
+
try {
|
|
1541
|
+
const res = await fetch("/api/config/media");
|
|
1542
|
+
const data = await res.json();
|
|
1543
|
+
_mediaState = data.media || {};
|
|
1544
|
+
_mediaDefaults = data.default_provider || {};
|
|
1545
|
+
_renderMediaRows();
|
|
1546
|
+
} catch (e) {
|
|
1547
|
+
container.innerHTML = `<div class="settings-error">${I18n.t("settings.media.error", { msg: e.message })}</div>`;
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
function _renderMediaRows() {
|
|
1552
|
+
const container = document.getElementById("media-rows");
|
|
1553
|
+
if (!container) return;
|
|
1554
|
+
container.innerHTML = "";
|
|
1555
|
+
MEDIA_KINDS.forEach(kind => {
|
|
1556
|
+
container.appendChild(_renderMediaRow(kind));
|
|
1557
|
+
});
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
function _renderMediaRow(kind) {
|
|
1561
|
+
const state = (_mediaState && _mediaState[kind]) || { source: "off", available: [] };
|
|
1562
|
+
const def = (_mediaDefaults && _mediaDefaults[kind]) || { available: [] };
|
|
1563
|
+
const autoAvailable = !!(def && def.model);
|
|
1564
|
+
const isCustomEditing = state.source === "custom" && (!state.configured || _mediaCustomDraft[kind]);
|
|
1565
|
+
|
|
1566
|
+
const row = document.createElement("div");
|
|
1567
|
+
row.className = "media-row";
|
|
1568
|
+
if (isCustomEditing || (state.source === "auto" && state.configured) || (state.source === "custom" && state.configured)) {
|
|
1569
|
+
row.classList.add("is-expanded");
|
|
1570
|
+
}
|
|
1571
|
+
row.dataset.kind = kind;
|
|
1572
|
+
|
|
1573
|
+
// Compact head: title · segmented · status
|
|
1574
|
+
const head = document.createElement("div");
|
|
1575
|
+
head.className = "media-row-head";
|
|
1576
|
+
|
|
1577
|
+
const title = document.createElement("span");
|
|
1578
|
+
title.className = "media-row-title";
|
|
1579
|
+
title.textContent = I18n.t(`settings.media.kind.${kind}`);
|
|
1580
|
+
head.appendChild(title);
|
|
1581
|
+
|
|
1582
|
+
const seg = document.createElement("div");
|
|
1583
|
+
seg.className = "media-row-segmented";
|
|
1584
|
+
["off", "auto", "custom"].forEach(src => {
|
|
1585
|
+
const btn = document.createElement("button");
|
|
1586
|
+
btn.type = "button";
|
|
1587
|
+
btn.dataset.source = src;
|
|
1588
|
+
btn.textContent = I18n.t(`settings.media.source.${src}`);
|
|
1589
|
+
if (state.source === src) btn.classList.add("is-active");
|
|
1590
|
+
if (src === "auto" && !autoAvailable) {
|
|
1591
|
+
btn.disabled = true;
|
|
1592
|
+
btn.title = I18n.t("settings.media.auto.disabledTitle");
|
|
1593
|
+
} else {
|
|
1594
|
+
btn.addEventListener("click", () => _onMediaSourceClick(kind, src));
|
|
1595
|
+
}
|
|
1596
|
+
seg.appendChild(btn);
|
|
1597
|
+
});
|
|
1598
|
+
head.appendChild(seg);
|
|
1599
|
+
|
|
1600
|
+
const status = document.createElement("span");
|
|
1601
|
+
status.className = "media-row-status";
|
|
1602
|
+
status.textContent = _mediaStatusText(kind, state, def);
|
|
1603
|
+
head.appendChild(status);
|
|
1604
|
+
|
|
1605
|
+
row.appendChild(head);
|
|
1606
|
+
|
|
1607
|
+
const detail = _renderMediaDetail(kind, state, def);
|
|
1608
|
+
if (detail) row.appendChild(detail);
|
|
1609
|
+
|
|
1610
|
+
return row;
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
function _mediaStatusText(kind, state, def) {
|
|
1614
|
+
if (state.source === "off") return "";
|
|
1615
|
+
if (state.source === "auto") {
|
|
1616
|
+
if (state.configured && state.model) return state.model;
|
|
1617
|
+
return "";
|
|
1618
|
+
}
|
|
1619
|
+
if (state.source === "custom" && state.configured && !_mediaCustomDraft[kind]) {
|
|
1620
|
+
return state.model || "";
|
|
1621
|
+
}
|
|
1622
|
+
return "";
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
function _renderMediaDetail(kind, state, def) {
|
|
1626
|
+
if (state.source === "off") return null;
|
|
1627
|
+
|
|
1628
|
+
if (state.source === "auto") {
|
|
1629
|
+
if (state.configured && state.model) {
|
|
1630
|
+
const wrap = document.createElement("div");
|
|
1631
|
+
wrap.className = "media-row-detail";
|
|
1632
|
+
wrap.innerHTML = `
|
|
1633
|
+
<div class="media-kv">
|
|
1634
|
+
<span class="media-kv-key">${I18n.t("settings.media.field.provider")}</span>
|
|
1635
|
+
<span class="media-kv-val">${_esc(state.provider || "—")}</span>
|
|
1636
|
+
<span class="media-kv-key">${I18n.t("settings.media.field.model")}</span>
|
|
1637
|
+
<span class="media-kv-val">${_esc(state.model)}</span>
|
|
1638
|
+
</div>
|
|
1639
|
+
<div class="media-row-hint">${I18n.t("settings.media.auto.followsDefault")}</div>
|
|
1640
|
+
`;
|
|
1641
|
+
return wrap;
|
|
1642
|
+
}
|
|
1643
|
+
const wrap = document.createElement("div");
|
|
1644
|
+
wrap.className = "media-row-detail is-warning";
|
|
1645
|
+
const hasDefault = def && def.provider;
|
|
1646
|
+
wrap.innerHTML = `<div>${hasDefault ? I18n.t("settings.media.auto.unsupported") : I18n.t("settings.media.auto.noDefaultModel")}</div>`;
|
|
1647
|
+
return wrap;
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
// custom
|
|
1651
|
+
if (state.configured && !_mediaCustomDraft[kind]) {
|
|
1652
|
+
const wrap = document.createElement("div");
|
|
1653
|
+
wrap.className = "media-row-detail";
|
|
1654
|
+
wrap.innerHTML = `
|
|
1655
|
+
<div class="media-kv">
|
|
1656
|
+
<span class="media-kv-key">${I18n.t("settings.media.field.model")}</span>
|
|
1657
|
+
<span class="media-kv-val">${_esc(state.model || "—")}</span>
|
|
1658
|
+
<span class="media-kv-key">${I18n.t("settings.media.field.baseUrl")}</span>
|
|
1659
|
+
<span class="media-kv-val">${_esc(state.base_url || "—")}</span>
|
|
1660
|
+
<span class="media-kv-key">${I18n.t("settings.media.field.apiKey")}</span>
|
|
1661
|
+
<span class="media-kv-val">${_esc(state.api_key_masked || "—")}</span>
|
|
1662
|
+
</div>
|
|
1663
|
+
`;
|
|
1664
|
+
const actions = document.createElement("div");
|
|
1665
|
+
actions.className = "media-row-actions";
|
|
1666
|
+
const editBtn = document.createElement("button");
|
|
1667
|
+
editBtn.type = "button";
|
|
1668
|
+
editBtn.className = "media-row-btn";
|
|
1669
|
+
editBtn.textContent = I18n.t("settings.media.action.edit");
|
|
1670
|
+
editBtn.addEventListener("click", () => {
|
|
1671
|
+
_mediaCustomDraft[kind] = {
|
|
1672
|
+
model: state.model || "",
|
|
1673
|
+
base_url: state.base_url || "",
|
|
1674
|
+
api_key: ""
|
|
1675
|
+
};
|
|
1676
|
+
_renderMediaRows();
|
|
1677
|
+
});
|
|
1678
|
+
actions.appendChild(editBtn);
|
|
1679
|
+
wrap.appendChild(actions);
|
|
1680
|
+
return wrap;
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
// edit form
|
|
1684
|
+
const draft = _mediaCustomDraft[kind] || { model: "", base_url: "", api_key: "" };
|
|
1685
|
+
const wrap = document.createElement("div");
|
|
1686
|
+
wrap.className = "media-row-detail";
|
|
1687
|
+
const form = document.createElement("div");
|
|
1688
|
+
form.className = "media-custom-form";
|
|
1689
|
+
form.innerHTML = `
|
|
1690
|
+
<label>${I18n.t("settings.media.field.model")}</label>
|
|
1691
|
+
<input type="text" data-field="model" value="${_esc(draft.model)}" placeholder="gpt-image-1">
|
|
1692
|
+
<label>${I18n.t("settings.media.field.baseUrl")}</label>
|
|
1693
|
+
<input type="text" data-field="base_url" value="${_esc(draft.base_url)}" placeholder="https://api.openai.com/v1">
|
|
1694
|
+
<label>${I18n.t("settings.media.field.apiKey")}</label>
|
|
1695
|
+
<input type="password" data-field="api_key" value="${_esc(draft.api_key)}" placeholder="${I18n.t("settings.media.apiKey.placeholder")}">
|
|
1696
|
+
<div class="media-form-actions">
|
|
1697
|
+
<button type="button" class="media-row-btn" data-act="cancel">${I18n.t("settings.media.action.cancel")}</button>
|
|
1698
|
+
<button type="button" class="media-row-btn is-primary" data-act="save">${I18n.t("settings.media.action.save")}</button>
|
|
1699
|
+
</div>
|
|
1700
|
+
`;
|
|
1701
|
+
form.querySelectorAll("input").forEach(inp => {
|
|
1702
|
+
inp.addEventListener("input", () => {
|
|
1703
|
+
_mediaCustomDraft[kind] = _mediaCustomDraft[kind] || {};
|
|
1704
|
+
_mediaCustomDraft[kind][inp.dataset.field] = inp.value;
|
|
1705
|
+
});
|
|
1706
|
+
});
|
|
1707
|
+
form.querySelector('[data-act="cancel"]').addEventListener("click", () => {
|
|
1708
|
+
delete _mediaCustomDraft[kind];
|
|
1709
|
+
// If there's no saved custom, fall back to whatever the saved source is (or off)
|
|
1710
|
+
if (!state.configured) {
|
|
1711
|
+
const fallback = (_mediaDefaults && _mediaDefaults[kind] && _mediaDefaults[kind].model) ? "auto" : "off";
|
|
1712
|
+
_mediaState[kind] = { ..._mediaState[kind], source: fallback };
|
|
1713
|
+
}
|
|
1714
|
+
_renderMediaRows();
|
|
1715
|
+
});
|
|
1716
|
+
form.querySelector('[data-act="save"]').addEventListener("click", async () => {
|
|
1717
|
+
const d = _mediaCustomDraft[kind] || {};
|
|
1718
|
+
try {
|
|
1719
|
+
await _saveMediaConfig(kind, {
|
|
1720
|
+
source: "custom",
|
|
1721
|
+
model: (d.model || "").trim(),
|
|
1722
|
+
base_url: (d.base_url || "").trim(),
|
|
1723
|
+
api_key: d.api_key || ""
|
|
1724
|
+
});
|
|
1725
|
+
delete _mediaCustomDraft[kind];
|
|
1726
|
+
await _loadMedia();
|
|
1727
|
+
} catch (e) {
|
|
1728
|
+
alert(e.message);
|
|
1729
|
+
}
|
|
1730
|
+
});
|
|
1731
|
+
wrap.appendChild(form);
|
|
1732
|
+
return wrap;
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
async function _onMediaSourceClick(kind, source) {
|
|
1736
|
+
const cur = (_mediaState && _mediaState[kind]) || {};
|
|
1737
|
+
if (cur.source === source && source !== "custom") return;
|
|
1738
|
+
|
|
1739
|
+
if (source === "custom") {
|
|
1740
|
+
if (cur.source !== "custom" && !_mediaCustomDraft[kind]) {
|
|
1741
|
+
_mediaCustomDraft[kind] = {
|
|
1742
|
+
model: cur.model || "",
|
|
1743
|
+
base_url: cur.base_url || "",
|
|
1744
|
+
api_key: ""
|
|
1745
|
+
};
|
|
1746
|
+
}
|
|
1747
|
+
_mediaState[kind] = { ...cur, source: "custom" };
|
|
1748
|
+
_renderMediaRows();
|
|
1749
|
+
return;
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
try {
|
|
1753
|
+
await _saveMediaConfig(kind, { source });
|
|
1754
|
+
delete _mediaCustomDraft[kind];
|
|
1755
|
+
await _loadMedia();
|
|
1756
|
+
} catch (e) {
|
|
1757
|
+
alert(e.message);
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
async function _saveMediaConfig(kind, body) {
|
|
1762
|
+
const res = await fetch(`/api/config/media/${kind}`, {
|
|
1763
|
+
method: "PATCH",
|
|
1764
|
+
headers: { "Content-Type": "application/json" },
|
|
1765
|
+
body: JSON.stringify(body)
|
|
1766
|
+
});
|
|
1767
|
+
const data = await res.json().catch(() => ({}));
|
|
1768
|
+
if (!res.ok || data.error) {
|
|
1769
|
+
throw new Error(data.error || `HTTP ${res.status}`);
|
|
1770
|
+
}
|
|
1771
|
+
return data;
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
|
|
1553
1775
|
function init() {
|
|
1554
1776
|
_initTabs();
|
|
1555
1777
|
_initModal();
|
|
@@ -1628,7 +1850,10 @@ const Settings = (() => {
|
|
|
1628
1850
|
_initCurrencyBtns();
|
|
1629
1851
|
|
|
1630
1852
|
// Re-render model cards when language changes (dynamic HTML, not data-i18n)
|
|
1631
|
-
document.addEventListener("langchange", () =>
|
|
1853
|
+
document.addEventListener("langchange", () => {
|
|
1854
|
+
_renderCards();
|
|
1855
|
+
_renderMediaRows();
|
|
1856
|
+
});
|
|
1632
1857
|
}
|
|
1633
1858
|
|
|
1634
1859
|
// ── Currency ──────────────────────────────────────────────────────────
|
|
@@ -1689,6 +1914,7 @@ const Settings = (() => {
|
|
|
1689
1914
|
// Initialize exchange rate input
|
|
1690
1915
|
const exchangeRateInput = document.getElementById("settings-exchange-rate");
|
|
1691
1916
|
const exchangeRateSection = document.getElementById("exchange-rate-section");
|
|
1917
|
+
const updateRateBtn = document.getElementById("btn-update-exchange-rate");
|
|
1692
1918
|
if (exchangeRateInput && exchangeRateSection) {
|
|
1693
1919
|
// Set initial value
|
|
1694
1920
|
exchangeRateInput.value = _getExchangeRate();
|
|
@@ -1703,9 +1929,58 @@ const Settings = (() => {
|
|
|
1703
1929
|
exchangeRateInput.value = _getExchangeRate();
|
|
1704
1930
|
}
|
|
1705
1931
|
});
|
|
1932
|
+
|
|
1933
|
+
if (updateRateBtn && !updateRateBtn.dataset.bound) {
|
|
1934
|
+
updateRateBtn.dataset.bound = "1";
|
|
1935
|
+
updateRateBtn.addEventListener("click", () => _updateLatestExchangeRate());
|
|
1936
|
+
}
|
|
1706
1937
|
}
|
|
1707
1938
|
}
|
|
1708
1939
|
|
|
1940
|
+
async function _updateLatestExchangeRate() {
|
|
1941
|
+
const input = document.getElementById("settings-exchange-rate");
|
|
1942
|
+
const btn = document.getElementById("btn-update-exchange-rate");
|
|
1943
|
+
if (!input || !btn) return;
|
|
1944
|
+
|
|
1945
|
+
const label = btn.querySelector("span");
|
|
1946
|
+
const originalText = label ? label.textContent : btn.textContent;
|
|
1947
|
+
btn.disabled = true;
|
|
1948
|
+
if (label) label.textContent = I18n.t("settings.currency.updating");
|
|
1949
|
+
else btn.textContent = I18n.t("settings.currency.updating");
|
|
1950
|
+
_setExchangeRateStatus("", "");
|
|
1951
|
+
|
|
1952
|
+
try {
|
|
1953
|
+
const res = await fetch("/api/exchange-rate?from=USD&to=CNY");
|
|
1954
|
+
const data = await res.json().catch(() => ({}));
|
|
1955
|
+
if (!res.ok) throw new Error(I18n.t("settings.currency.updateFailed"));
|
|
1956
|
+
|
|
1957
|
+
const rate = parseFloat(data.rate);
|
|
1958
|
+
if (isNaN(rate) || rate <= 0) throw new Error(I18n.t("settings.currency.updateFailed"));
|
|
1959
|
+
|
|
1960
|
+
input.value = rate.toString();
|
|
1961
|
+
_setExchangeRate(rate);
|
|
1962
|
+
_setExchangeRateStatus(
|
|
1963
|
+
I18n.t("settings.currency.updated", { source: data.source || "", date: data.date || "" }),
|
|
1964
|
+
"success"
|
|
1965
|
+
);
|
|
1966
|
+
} catch (e) {
|
|
1967
|
+
_setExchangeRateStatus(e.message || I18n.t("settings.currency.updateFailed"), "error");
|
|
1968
|
+
} finally {
|
|
1969
|
+
btn.disabled = false;
|
|
1970
|
+
if (label) label.textContent = originalText || I18n.t("settings.currency.updateLatest");
|
|
1971
|
+
else btn.textContent = originalText || I18n.t("settings.currency.updateLatest");
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
function _setExchangeRateStatus(message, type) {
|
|
1976
|
+
const status = document.getElementById("settings-exchange-rate-status");
|
|
1977
|
+
if (!status) return;
|
|
1978
|
+
|
|
1979
|
+
status.textContent = message || "";
|
|
1980
|
+
status.classList.toggle("success", type === "success");
|
|
1981
|
+
status.classList.toggle("error", type === "error");
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1709
1984
|
// ── Font Size ──────────────────────────────────────────────────────────
|
|
1710
1985
|
const FONT_STORAGE_KEY = "clacky-font-size";
|
|
1711
1986
|
const FONT_DEFAULT = "medium";
|
data/lib/clacky/web/workspace.js
CHANGED
|
@@ -188,14 +188,22 @@ const Workspace = (() => {
|
|
|
188
188
|
},
|
|
189
189
|
|
|
190
190
|
// Called from Sessions.updateInfoBar whenever the active session changes.
|
|
191
|
+
// On a real session switch (from one session to another) we always collapse
|
|
192
|
+
// the panel: the file list is only ever loaded when the user explicitly
|
|
193
|
+
// expands it (which triggers a single refresh via setOpen), so the list is
|
|
194
|
+
// never shown stale across sessions. The first attach (no previous session)
|
|
195
|
+
// is not a switch and keeps the restored open state.
|
|
191
196
|
onSession(session) {
|
|
192
197
|
const newId = session ? session.id : null;
|
|
193
198
|
const newDir = session ? session.working_dir : null;
|
|
199
|
+
const hadSession = _sessionId != null;
|
|
194
200
|
const changed = newId !== _sessionId || newDir !== _workingDir;
|
|
195
201
|
_sessionId = newId;
|
|
196
202
|
_workingDir = newDir;
|
|
203
|
+
if (changed && hadSession && _open) setOpen(false);
|
|
197
204
|
applyOpenState();
|
|
198
|
-
|
|
205
|
+
// First attach with the panel restored open: load once.
|
|
206
|
+
if (!hadSession && _open && _sessionId) loadRoot();
|
|
199
207
|
}
|
|
200
208
|
};
|
|
201
209
|
})();
|
data/lib/clacky.rb
CHANGED
|
@@ -125,6 +125,9 @@ require_relative "clacky/mcp/client"
|
|
|
125
125
|
require_relative "clacky/mcp/virtual_skill"
|
|
126
126
|
require_relative "clacky/mcp/registry"
|
|
127
127
|
require_relative "clacky/mcp/skill_provider"
|
|
128
|
+
require_relative "clacky/media/base"
|
|
129
|
+
require_relative "clacky/media/openai_compat"
|
|
130
|
+
require_relative "clacky/media/generator"
|
|
128
131
|
require_relative "clacky/telemetry"
|
|
129
132
|
require_relative "clacky/agent"
|
|
130
133
|
|