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.
@@ -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" title="${I18n.t("settings.models.btn.setDefault")}">
105
- <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>
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
- <button class="btn-card-grid-action" data-index="${index}" data-action="test" title="${I18n.t("settings.models.btn.test")}">
109
- <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>
110
- <span>${I18n.t("settings.models.btn.test")}</span>
111
- </button>
112
- <button class="btn-card-grid-action" data-index="${index}" data-action="edit" title="${I18n.t("settings.models.btn.edit")}">
113
- <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>
114
- <span>${I18n.t("settings.models.btn.edit")}</span>
115
- </button>
116
- ${_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")}">
117
- <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>
118
- <span>${I18n.t("settings.models.btn.delete")}</span>
119
- </button>` : ""}
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
- // Bind all action buttons using data-action attribute
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 = "none";
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 body = {
286
- model: model.model,
290
+ const result = await ModelTester.testConnection({
291
+ model: model.model,
287
292
  base_url: model.base_url,
288
- api_key: model.api_key_masked,
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
- const testData = await testRes.json();
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
- const base_url = document.getElementById("model-modal-baseurl").value.trim();
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
- try {
329
- const testBody = { model, base_url, api_key, index };
330
- if (anthropic_format) testBody.anthropic_format = true;
324
+ const result = await ModelTester.testConnection({
325
+ model, base_url, api_key, index, anthropic_format
326
+ });
331
327
 
332
- const testRes = await fetch("/api/config/test", {
333
- method: "POST",
334
- headers: { "Content-Type": "application/json" },
335
- body: JSON.stringify(testBody)
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
- if (!testData.ok) {
341
- saveBtn.textContent = I18n.t("settings.models.btn.save");
342
- saveBtn.disabled = false;
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
- if (isNew) {
361
- if (document.getElementById("model-modal-set-default").checked) {
362
- payload.type = "default";
363
- _models.forEach(m => { if (m.type === "default") m.type = null; });
364
- } else {
365
- payload.type = null;
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
- try {
376
- let res, data;
377
- if (hasId) {
378
- res = await fetch(`/api/config/models/${encodeURIComponent(existing.id)}`, {
379
- method: "PATCH",
380
- headers: { "Content-Type": "application/json" },
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
- if (data.ok) {
400
- saveBtn.textContent = I18n.t("settings.models.btn.saved");
401
- setTimeout(() => {
402
- _closeModal();
403
- _load();
404
- }, 800);
405
- } else {
406
- saveBtn.textContent = I18n.t("settings.models.btn.save");
407
- saveBtn.disabled = false;
408
- _showModalTestResult(false, data.error || I18n.t("settings.models.saveFailed"));
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, e.message);
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", () => _renderCards());
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