openclacky 1.3.2 → 1.3.3

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.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -0
  3. data/Dockerfile +3 -0
  4. data/README.md +1 -1
  5. data/README_JA.md +237 -0
  6. data/lib/clacky/agent/session_serializer.rb +49 -5
  7. data/lib/clacky/agent/time_machine.rb +247 -26
  8. data/lib/clacky/agent.rb +12 -1
  9. data/lib/clacky/agent_config.rb +14 -2
  10. data/lib/clacky/default_agents/_panels/git/panel.js +201 -0
  11. data/lib/clacky/default_agents/_panels/time_machine/panel.js +640 -0
  12. data/lib/clacky/default_agents/coding/profile.yml +3 -0
  13. data/lib/clacky/default_agents/coding/webui/.gitkeep +0 -0
  14. data/lib/clacky/default_skills/cron-task-creator/SKILL.md +1 -1
  15. data/lib/clacky/default_skills/extend-openclacky/SKILL.md +6 -4
  16. data/lib/clacky/default_skills/media-gen/SKILL.md +30 -6
  17. data/lib/clacky/media/openai_compat.rb +64 -1
  18. data/lib/clacky/media/output_dir.rb +43 -0
  19. data/lib/clacky/message_history.rb +9 -0
  20. data/lib/clacky/server/channel/channel_manager.rb +26 -0
  21. data/lib/clacky/server/git_panel.rb +115 -0
  22. data/lib/clacky/server/http_server.rb +497 -12
  23. data/lib/clacky/server/server_master.rb +6 -4
  24. data/lib/clacky/version.rb +1 -1
  25. data/lib/clacky/web/app.css +473 -60
  26. data/lib/clacky/web/app.js +30 -7
  27. data/lib/clacky/web/components/code-editor.js +197 -0
  28. data/lib/clacky/web/{notify.js → components/notify.js} +1 -1
  29. data/lib/clacky/web/core/aside.js +112 -0
  30. data/lib/clacky/web/core/ext.js +387 -0
  31. data/lib/clacky/web/features/backup/store.js +92 -0
  32. data/lib/clacky/web/features/backup/view.js +94 -0
  33. data/lib/clacky/web/features/billing/store.js +163 -0
  34. data/lib/clacky/web/{billing.js → features/billing/view.js} +132 -240
  35. data/lib/clacky/web/features/brand/store.js +110 -0
  36. data/lib/clacky/web/{brand.js → features/brand/view.js} +49 -199
  37. data/lib/clacky/web/features/channels/store.js +103 -0
  38. data/lib/clacky/web/{channels.js → features/channels/view.js} +50 -127
  39. data/lib/clacky/web/features/creator/store.js +81 -0
  40. data/lib/clacky/web/{creator.js → features/creator/view.js} +53 -102
  41. data/lib/clacky/web/features/mcp/store.js +158 -0
  42. data/lib/clacky/web/{mcp.js → features/mcp/view.js} +57 -134
  43. data/lib/clacky/web/features/model-tester/store.js +77 -0
  44. data/lib/clacky/web/features/model-tester/view.js +7 -0
  45. data/lib/clacky/web/features/profile/store.js +170 -0
  46. data/lib/clacky/web/{profile.js → features/profile/view.js} +94 -144
  47. data/lib/clacky/web/features/share/store.js +145 -0
  48. data/lib/clacky/web/{share.js → features/share/view.js} +66 -202
  49. data/lib/clacky/web/features/skills/store.js +303 -0
  50. data/lib/clacky/web/features/skills/view.js +550 -0
  51. data/lib/clacky/web/features/tasks/store.js +135 -0
  52. data/lib/clacky/web/features/tasks/view.js +241 -0
  53. data/lib/clacky/web/features/trash/store.js +242 -0
  54. data/lib/clacky/web/{trash.js → features/trash/view.js} +102 -293
  55. data/lib/clacky/web/features/version/store.js +165 -0
  56. data/lib/clacky/web/features/version/view.js +323 -0
  57. data/lib/clacky/web/features/workspace/store.js +99 -0
  58. data/lib/clacky/web/features/workspace/view.js +305 -0
  59. data/lib/clacky/web/i18n.js +56 -6
  60. data/lib/clacky/web/index.html +117 -58
  61. data/lib/clacky/web/sessions.js +221 -25
  62. data/lib/clacky/web/settings.js +118 -22
  63. data/lib/clacky/web/skills.js +3 -863
  64. data/lib/clacky/web/vendor/codemirror/codemirror.min.js +29 -0
  65. data/lib/clacky.rb +1 -0
  66. metadata +45 -20
  67. data/lib/clacky/web/backup.js +0 -119
  68. data/lib/clacky/web/model-tester.js +0 -66
  69. data/lib/clacky/web/tasks.js +0 -373
  70. data/lib/clacky/web/version.js +0 -449
  71. data/lib/clacky/web/workspace.js +0 -316
  72. /data/lib/clacky/web/{notify.mp3 → assets/notify.mp3} +0 -0
  73. /data/lib/clacky/web/{datepicker.js → components/datepicker.js} +0 -0
  74. /data/lib/clacky/web/{onboard.js → components/onboard.js} +0 -0
  75. /data/lib/clacky/web/{sidebar.js → components/sidebar.js} +0 -0
  76. /data/lib/clacky/web/{marked.min.js → vendor/marked/marked.min.js} +0 -0
@@ -0,0 +1,158 @@
1
+ // ── MCP · store — server catalog data + Agent-driven actions ───────────────
2
+ //
3
+ // MCP is a read-only, Agent-First panel. Configuration lives in mcp.json; this
4
+ // store fetches the catalog, probes servers for their tool list, toggles the
5
+ // enabled flag, and removes entries. It never renders.
6
+ //
7
+ // Holds catalog data, expand state, and a tools cache. Emits store events the
8
+ // view reacts to; mirrors them to the extension bus via Clacky.ext.emit.
9
+ //
10
+ // `Mcp` stays the single public facade.
11
+ //
12
+ // Depends on: Sessions, Clacky.ext.
13
+ // ───────────────────────────────────────────────────────────────────────────
14
+
15
+ const McpStore = (() => {
16
+ let _data = null;
17
+ const _expanded = new Set();
18
+ const _toolsCache = new Map();
19
+
20
+ const _listeners = {};
21
+
22
+ function _on(event, handler) {
23
+ (_listeners[event] ||= []).push(handler);
24
+ return () => {
25
+ const list = _listeners[event];
26
+ const i = list ? list.indexOf(handler) : -1;
27
+ if (i >= 0) list.splice(i, 1);
28
+ };
29
+ }
30
+
31
+ function _emit(event, payload) {
32
+ (_listeners[event] || []).forEach((h) => h(payload));
33
+ if (window.Clacky && Clacky.ext) Clacky.ext.emit(event, payload);
34
+ }
35
+
36
+ const state = {
37
+ get data() { return _data; },
38
+ get expanded() { return _expanded; },
39
+ isExpanded(name) { return _expanded.has(name); },
40
+ cachedTools(name) { return _toolsCache.get(name); },
41
+ hasCachedTools(name) { return _toolsCache.has(name); },
42
+ };
43
+
44
+ async function _sendToAgent(command, sessionName) {
45
+ try {
46
+ const maxN = Sessions.all.reduce((max, s) => {
47
+ const m = s.name.match(/^Session (\d+)$/);
48
+ return m ? Math.max(max, parseInt(m[1], 10)) : max;
49
+ }, 0);
50
+ const name = sessionName || ("Session " + (maxN + 1));
51
+
52
+ const res = await fetch("/api/sessions", {
53
+ method: "POST",
54
+ headers: { "Content-Type": "application/json" },
55
+ body: JSON.stringify({ name, source: "mcp" }),
56
+ });
57
+ const data = await res.json();
58
+ if (!res.ok) throw new Error(data.error || "failed to create session");
59
+ const session = data.session;
60
+ if (!session) throw new Error("no session returned");
61
+
62
+ Sessions.add(session);
63
+ Sessions.renderList();
64
+ Sessions.setPendingMessage(session.id, command);
65
+ Sessions.select(session.id);
66
+ } catch (e) {
67
+ alert("Error: " + e.message);
68
+ }
69
+ }
70
+
71
+ const Mcp = {
72
+ on: _on,
73
+ state,
74
+
75
+ async load() {
76
+ _emit("mcp:loading");
77
+ try {
78
+ const res = await fetch("/api/mcp");
79
+ _data = await res.json();
80
+ _emit("mcp:changed", { data: _data });
81
+ } catch (e) {
82
+ _emit("mcp:error", { message: e.message });
83
+ }
84
+ },
85
+
86
+ /** Fetch a server's tool catalog (cached). Returns { ok, tools, error }. */
87
+ async probe(name) {
88
+ if (_toolsCache.has(name)) return { ok: true, tools: _toolsCache.get(name) };
89
+ try {
90
+ const res = await fetch(`/api/mcp/${encodeURIComponent(name)}/probe`, { method: "POST" });
91
+ const data = await res.json();
92
+ if (!res.ok || !data.ok) return { ok: false, error: data.error || "unknown" };
93
+ const tools = data.tools || [];
94
+ _toolsCache.set(name, tools);
95
+ return { ok: true, tools };
96
+ } catch (e) {
97
+ return { ok: false, error: e.message };
98
+ }
99
+ },
100
+
101
+ toggleExpand(name) {
102
+ if (_expanded.has(name)) _expanded.delete(name);
103
+ else _expanded.add(name);
104
+ _emit("mcp:changed", { data: _data });
105
+ },
106
+
107
+ async toggle(name, enabled) {
108
+ try {
109
+ const res = await fetch(`/api/mcp/${encodeURIComponent(name)}/enabled`, {
110
+ method: "PATCH",
111
+ headers: { "Content-Type": "application/json" },
112
+ body: JSON.stringify({ enabled }),
113
+ });
114
+ const data = await res.json();
115
+ if (!res.ok || !data.ok) {
116
+ _emit("mcp:actionError", { kind: "toggle", message: data.error || `HTTP ${res.status}` });
117
+ return;
118
+ }
119
+ if (!enabled) {
120
+ _toolsCache.delete(name);
121
+ _expanded.delete(name);
122
+ }
123
+ await Mcp.load();
124
+ } catch (e) {
125
+ _emit("mcp:actionError", { kind: "toggle", message: e.message });
126
+ }
127
+ },
128
+
129
+ async remove(name) {
130
+ try {
131
+ const res = await fetch(`/api/mcp/${encodeURIComponent(name)}`, { method: "DELETE" });
132
+ const data = await res.json();
133
+ if (!res.ok || !data.ok) {
134
+ _emit("mcp:actionError", { kind: "remove", message: data.error || `HTTP ${res.status}` });
135
+ return;
136
+ }
137
+ _toolsCache.delete(name);
138
+ _expanded.delete(name);
139
+ await Mcp.load();
140
+ } catch (e) {
141
+ _emit("mcp:actionError", { kind: "remove", message: e.message });
142
+ }
143
+ },
144
+
145
+ resetCaches() {
146
+ _toolsCache.clear();
147
+ _expanded.clear();
148
+ },
149
+
150
+ askAdd() { return _sendToAgent("/mcp-manager add", "MCP Setup"); },
151
+ askFix(name) { return _sendToAgent(`/mcp-manager reconfigure ${name}`, `MCP Fix — ${name}`); },
152
+ sendToAgent: _sendToAgent,
153
+ };
154
+
155
+ return Mcp;
156
+ })();
157
+
158
+ const Mcp = McpStore;
@@ -1,48 +1,38 @@
1
- // mcp.js — MCP servers panel (read-only, Agent-First).
1
+ // ── MCP · view rendering + DOM wiring for the MCP servers panel ──────────
2
2
  //
3
- // This page lists MCP servers configured in ~/.clacky/mcp.json (or project-level
4
- // override). Configuration itself stays in the JSON file same format as
5
- // Claude Desktop and Cursor so existing configs work as-is.
3
+ // Owns all card/status/tools rendering and event wiring. Reads through
4
+ // McpStore.state and reacts to store events. Probe / toggle / remove go through
5
+ // store actions; confirm dialogs and error alerts (UI concerns) live here.
6
6
  //
7
- // Per-server "Show tools" probes the server briefly to fetch its tool catalog.
8
- // Nothing here keeps a process running; agent runs do their own lazy spawn.
9
-
10
- const Mcp = (() => {
11
-
12
- let _data = null;
13
- const _expanded = new Set();
14
- const _toolsCache = new Map();
7
+ // Augments the `Mcp` facade with onPanelShow.
8
+ //
9
+ // Depends on: McpStore, I18n, global $ helper.
10
+ // ───────────────────────────────────────────────────────────────────────────
15
11
 
16
- async function onPanelShow() {
17
- await _load();
18
- }
12
+ const McpView = (() => {
19
13
 
20
- async function _load() {
14
+ function _renderLoading() {
21
15
  const list = $("mcp-list");
22
16
  const status = $("mcp-status");
23
- if (!list) return;
24
- list.innerHTML = `<div class="channel-loading">${I18n.t("mcp.loading")}</div>`;
25
17
  if (status) status.innerHTML = "";
18
+ if (list) list.innerHTML = `<div class="channel-loading">${I18n.t("mcp.loading")}</div>`;
19
+ }
26
20
 
27
- try {
28
- const res = await fetch("/api/mcp");
29
- const data = await res.json();
30
- _data = data;
31
- _render();
32
- } catch (e) {
33
- list.innerHTML = `<div class="channel-error">${I18n.t("mcp.loadError", { msg: _esc(e.message) })}</div>`;
34
- }
21
+ function _renderError(payload) {
22
+ const list = $("mcp-list");
23
+ if (list) list.innerHTML = `<div class="channel-error">${I18n.t("mcp.loadError", { msg: _esc(payload.message) })}</div>`;
35
24
  }
36
25
 
37
26
  function _render() {
38
27
  const list = $("mcp-list");
39
28
  const status = $("mcp-status");
40
- if (!list || !_data) return;
29
+ const data = McpStore.state.data;
30
+ if (!list || !data) return;
41
31
 
42
32
  if (status) {
43
- const pathLabel = _data.config_exists
44
- ? _esc(_data.config_path)
45
- : `${_esc(_data.config_path)} <em>${I18n.t("mcp.config.missing")}</em>`;
33
+ const pathLabel = data.config_exists
34
+ ? _esc(data.config_path)
35
+ : `${_esc(data.config_path)} <em>${I18n.t("mcp.config.missing")}</em>`;
46
36
  status.innerHTML = `
47
37
  <div class="mcp-cta">
48
38
  <div class="mcp-cta-text">
@@ -70,16 +60,15 @@ const Mcp = (() => {
70
60
  </div>
71
61
  `;
72
62
  $("btn-mcp-refresh")?.addEventListener("click", () => {
73
- _toolsCache.clear();
74
- _expanded.clear();
75
- _load();
63
+ Mcp.resetCaches();
64
+ Mcp.load();
76
65
  });
77
- $("btn-mcp-cta")?.addEventListener("click", () => _askClackyAdd());
66
+ $("btn-mcp-cta")?.addEventListener("click", () => Mcp.askAdd());
78
67
  }
79
68
 
80
69
  list.innerHTML = "";
81
70
 
82
- if (!_data.configured || !_data.servers || _data.servers.length === 0) {
71
+ if (!data.configured || !data.servers || data.servers.length === 0) {
83
72
  list.innerHTML = `
84
73
  <div class="mcp-empty">
85
74
  <h3>${I18n.t("mcp.empty.title")}</h3>
@@ -89,11 +78,11 @@ const Mcp = (() => {
89
78
  </button>
90
79
  </div>
91
80
  `;
92
- $("btn-mcp-empty-cta")?.addEventListener("click", () => _askClackyAdd());
81
+ $("btn-mcp-empty-cta")?.addEventListener("click", () => Mcp.askAdd());
93
82
  return;
94
83
  }
95
84
 
96
- _data.servers.forEach(server => {
85
+ data.servers.forEach(server => {
97
86
  list.appendChild(_renderCard(server));
98
87
  });
99
88
  }
@@ -109,7 +98,7 @@ const Mcp = (() => {
109
98
  ? (server.url || "")
110
99
  : [server.command, ...(server.args || [])].filter(Boolean).join(" ");
111
100
  const cmdLabel = isHttp ? I18n.t("mcp.url") : I18n.t("mcp.command");
112
- const isExpanded = _expanded.has(server.name);
101
+ const isExpanded = McpStore.state.isExpanded(server.name);
113
102
  const toggleAria = server.disabled
114
103
  ? I18n.t("mcp.toggle.off")
115
104
  : I18n.t("mcp.toggle.on");
@@ -174,7 +163,7 @@ const Mcp = (() => {
174
163
  card.querySelector(`#btn-mcp-probe-${CSS.escape(server.name)}`)
175
164
  ?.addEventListener("click", () => {
176
165
  if (server.disabled) return;
177
- _toggleProbe(server.name);
166
+ Mcp.toggleExpand(server.name);
178
167
  });
179
168
  card.querySelector(`#btn-mcp-remove-${CSS.escape(server.name)}`)
180
169
  ?.addEventListener("click", () => _remove(server.name));
@@ -186,39 +175,23 @@ const Mcp = (() => {
186
175
  return card;
187
176
  }
188
177
 
189
- async function _toggleProbe(name) {
190
- if (_expanded.has(name)) {
191
- _expanded.delete(name);
192
- } else {
193
- _expanded.add(name);
194
- }
195
- _render();
196
- if (_expanded.has(name)) await _renderTools(name);
197
- }
198
-
199
178
  async function _renderTools(name) {
200
179
  const region = document.getElementById(`mcp-tools-${name}`);
201
180
  if (!region) return;
202
181
 
203
- if (_toolsCache.has(name)) {
204
- region.innerHTML = _toolsHtml(_toolsCache.get(name));
182
+ if (McpStore.state.hasCachedTools(name)) {
183
+ region.innerHTML = _toolsHtml(McpStore.state.cachedTools(name));
205
184
  return;
206
185
  }
207
186
 
208
187
  region.innerHTML = `<div class="mcp-tools-loading">${I18n.t("mcp.toolsLoading")}</div>`;
209
188
 
210
- try {
211
- const res = await fetch(`/api/mcp/${encodeURIComponent(name)}/probe`, { method: "POST" });
212
- const data = await res.json();
213
- if (!res.ok || !data.ok) {
214
- region.innerHTML = `<div class="mcp-tools-error">${I18n.t("mcp.toolsLoadError", { msg: _esc(data.error || "unknown") })}</div>`;
215
- return;
216
- }
217
- _toolsCache.set(name, data.tools || []);
218
- region.innerHTML = _toolsHtml(data.tools || []);
219
- } catch (e) {
220
- region.innerHTML = `<div class="mcp-tools-error">${I18n.t("mcp.toolsLoadError", { msg: _esc(e.message) })}</div>`;
189
+ const result = await Mcp.probe(name);
190
+ if (!result.ok) {
191
+ region.innerHTML = `<div class="mcp-tools-error">${I18n.t("mcp.toolsLoadError", { msg: _esc(result.error) })}</div>`;
192
+ return;
221
193
  }
194
+ region.innerHTML = _toolsHtml(result.tools);
222
195
  }
223
196
 
224
197
  function _toolsHtml(tools) {
@@ -237,80 +210,14 @@ const Mcp = (() => {
237
210
  `;
238
211
  }
239
212
 
240
- function _askClackyAdd() {
241
- _sendToAgent("/mcp-manager add", "MCP Setup");
242
- }
243
-
244
- function _askClackyFix(name) {
245
- _sendToAgent(`/mcp-manager reconfigure ${name}`, `MCP Fix — ${name}`);
246
- }
247
-
248
- async function _sendToAgent(command, sessionName) {
249
- try {
250
- const maxN = Sessions.all.reduce((max, s) => {
251
- const m = s.name.match(/^Session (\d+)$/);
252
- return m ? Math.max(max, parseInt(m[1], 10)) : max;
253
- }, 0);
254
- const name = sessionName || ("Session " + (maxN + 1));
255
-
256
- const res = await fetch("/api/sessions", {
257
- method: "POST",
258
- headers: { "Content-Type": "application/json" },
259
- body: JSON.stringify({ name, source: "mcp" }),
260
- });
261
- const data = await res.json();
262
- if (!res.ok) throw new Error(data.error || "failed to create session");
263
- const session = data.session;
264
- if (!session) throw new Error("no session returned");
265
-
266
- Sessions.add(session);
267
- Sessions.renderList();
268
- Sessions.setPendingMessage(session.id, command);
269
- Sessions.select(session.id);
270
- } catch (e) {
271
- alert("Error: " + e.message);
272
- }
273
- }
274
-
275
213
  async function _toggle(name, enabled) {
276
- try {
277
- const res = await fetch(`/api/mcp/${encodeURIComponent(name)}/enabled`, {
278
- method: "PATCH",
279
- headers: { "Content-Type": "application/json" },
280
- body: JSON.stringify({ enabled }),
281
- });
282
- const data = await res.json();
283
- if (!res.ok || !data.ok) {
284
- alert(I18n.t("mcp.toggle.error", { msg: data.error || `HTTP ${res.status}` }));
285
- return;
286
- }
287
- if (!enabled) {
288
- _toolsCache.delete(name);
289
- _expanded.delete(name);
290
- }
291
- await _load();
292
- } catch (e) {
293
- alert(I18n.t("mcp.toggle.error", { msg: e.message }));
294
- }
214
+ await Mcp.toggle(name, enabled);
295
215
  }
296
216
 
297
- async function _remove(name) {
217
+ function _remove(name) {
298
218
  const msg = I18n.t("mcp.remove.confirm", { name });
299
219
  if (!window.confirm(msg)) return;
300
-
301
- try {
302
- const res = await fetch(`/api/mcp/${encodeURIComponent(name)}`, { method: "DELETE" });
303
- const data = await res.json();
304
- if (!res.ok || !data.ok) {
305
- alert(I18n.t("mcp.remove.error", { msg: data.error || `HTTP ${res.status}` }));
306
- return;
307
- }
308
- _toolsCache.delete(name);
309
- _expanded.delete(name);
310
- await _load();
311
- } catch (e) {
312
- alert(I18n.t("mcp.remove.error", { msg: e.message }));
313
- }
220
+ Mcp.remove(name);
314
221
  }
315
222
 
316
223
  function _esc(str) {
@@ -321,8 +228,24 @@ const Mcp = (() => {
321
228
  .replace(/"/g, "&quot;");
322
229
  }
323
230
 
324
- return {
325
- onPanelShow,
326
- init() {},
231
+ function _onActionError(payload) {
232
+ const key = payload.kind === "remove" ? "mcp.remove.error" : "mcp.toggle.error";
233
+ alert(I18n.t(key, { msg: payload.message }));
234
+ }
235
+
236
+ function _subscribe() {
237
+ Mcp.on("mcp:loading", _renderLoading);
238
+ Mcp.on("mcp:changed", _render);
239
+ Mcp.on("mcp:error", _renderError);
240
+ Mcp.on("mcp:actionError", _onActionError);
241
+ }
242
+
243
+ const viewApi = {
244
+ onPanelShow() { return Mcp.load(); },
327
245
  };
246
+
247
+ return { init: _subscribe, api: viewApi };
328
248
  })();
249
+
250
+ Object.assign(Mcp, McpView.api);
251
+ McpView.init();
@@ -0,0 +1,77 @@
1
+ // ── ModelTester · store — model connection test + save (shared helper) ────
2
+ //
3
+ // Network helpers shared by the onboarding wizard and the settings model modal:
4
+ // test a model connection and persist a model config. No own panel, no state to
5
+ // hold — it mirrors test/save outcomes onto the extension bus so extensions can
6
+ // observe model-config changes.
7
+ //
8
+ // `ModelTester` stays the single public facade.
9
+ //
10
+ // Depends on: I18n, Clacky.ext.
11
+ // ───────────────────────────────────────────────────────────────────────────
12
+
13
+ window.ModelTester = (function () {
14
+ function _emit(event, payload) {
15
+ if (window.Clacky && Clacky.ext) Clacky.ext.emit(event, payload);
16
+ }
17
+
18
+ async function testConnection({ model, base_url, api_key, anthropic_format, index, id } = {}) {
19
+ const body = { model, base_url, api_key };
20
+ if (typeof id === "string" && id) body.id = id;
21
+ if (typeof index === "number") body.index = index;
22
+ if (anthropic_format) body.anthropic_format = true;
23
+
24
+ let data;
25
+ try {
26
+ const res = await fetch("/api/config/test", {
27
+ method: "POST",
28
+ headers: { "Content-Type": "application/json" },
29
+ body: JSON.stringify(body)
30
+ });
31
+ data = await res.json();
32
+ } catch (e) {
33
+ return { ok: false, message: e.message };
34
+ }
35
+
36
+ let result;
37
+ if (!data.ok) {
38
+ const msg = data.message || "";
39
+ const code = data.error_code || "";
40
+ result = code === "insufficient_credit"
41
+ ? { ok: false, message: I18n.t("error.insufficient_credit"), error_code: code }
42
+ : { ok: false, message: msg, error_code: code };
43
+ } else if (data.effective_base_url && data.effective_base_url !== base_url) {
44
+ result = { ok: true, base_url: data.effective_base_url, message: data.message || "", rewrote: true };
45
+ } else {
46
+ result = { ok: true, base_url, message: data.message || "" };
47
+ }
48
+
49
+ _emit("modeltester:tested", { model, ok: result.ok });
50
+ return result;
51
+ }
52
+
53
+ async function saveModel(payload, { existingId } = {}) {
54
+ const url = existingId
55
+ ? `/api/config/models/${encodeURIComponent(existingId)}`
56
+ : "/api/config/models";
57
+ const method = existingId ? "PATCH" : "POST";
58
+
59
+ try {
60
+ const res = await fetch(url, {
61
+ method,
62
+ headers: { "Content-Type": "application/json" },
63
+ body: JSON.stringify(payload)
64
+ });
65
+ const data = await res.json();
66
+ const result = data.ok ? { ok: true } : { ok: false, error: data.error || "" };
67
+ _emit("modeltester:saved", { existingId: existingId || null, ok: result.ok });
68
+ return result;
69
+ } catch (e) {
70
+ return { ok: false, error: e.message };
71
+ }
72
+ }
73
+
74
+ return { testConnection, saveModel };
75
+ })();
76
+
77
+ const ModelTester = window.ModelTester;
@@ -0,0 +1,7 @@
1
+ // ── ModelTester · view — render-free feature ──────────────────────────────
2
+ //
3
+ // ModelTester is a shared network helper with no panel of its own: the
4
+ // onboarding wizard and the settings model modal own the UI and call
5
+ // ModelTester.testConnection / saveModel directly. There is nothing to render
6
+ // here. This file exists to satisfy the store/view layering convention.
7
+ // ───────────────────────────────────────────────────────────────────────────