openclacky 1.2.8 → 1.2.9
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 +12 -0
- data/lib/clacky/agent_config.rb +91 -7
- 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/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 +82 -0
- data/lib/clacky/server/http_server.rb +210 -20
- data/lib/clacky/telemetry.rb +11 -5
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +172 -12
- data/lib/clacky/web/i18n.js +58 -0
- data/lib/clacky/web/index.html +14 -2
- data/lib/clacky/web/model-tester.js +58 -0
- data/lib/clacky/web/onboard.js +17 -30
- data/lib/clacky/web/settings.js +322 -97
- data/lib/clacky.rb +3 -0
- data/scripts/build/lib/network.sh +61 -30
- 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 +7 -2
- 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 ──────────────────────────────────────────────────────────
|
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
|
|