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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +28 -0
- data/Dockerfile +3 -0
- data/README.md +1 -1
- data/README_JA.md +237 -0
- data/lib/clacky/agent/session_serializer.rb +49 -5
- data/lib/clacky/agent/time_machine.rb +247 -26
- data/lib/clacky/agent.rb +12 -1
- data/lib/clacky/agent_config.rb +14 -2
- data/lib/clacky/default_agents/_panels/git/panel.js +201 -0
- data/lib/clacky/default_agents/_panels/time_machine/panel.js +640 -0
- data/lib/clacky/default_agents/coding/profile.yml +3 -0
- data/lib/clacky/default_agents/coding/webui/.gitkeep +0 -0
- data/lib/clacky/default_skills/cron-task-creator/SKILL.md +1 -1
- data/lib/clacky/default_skills/extend-openclacky/SKILL.md +6 -4
- data/lib/clacky/default_skills/media-gen/SKILL.md +30 -6
- data/lib/clacky/media/openai_compat.rb +64 -1
- data/lib/clacky/media/output_dir.rb +43 -0
- data/lib/clacky/message_history.rb +9 -0
- data/lib/clacky/server/channel/channel_manager.rb +26 -0
- data/lib/clacky/server/git_panel.rb +115 -0
- data/lib/clacky/server/http_server.rb +497 -12
- data/lib/clacky/server/server_master.rb +6 -4
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +473 -60
- data/lib/clacky/web/app.js +30 -7
- data/lib/clacky/web/components/code-editor.js +197 -0
- data/lib/clacky/web/{notify.js → components/notify.js} +1 -1
- data/lib/clacky/web/core/aside.js +112 -0
- data/lib/clacky/web/core/ext.js +387 -0
- data/lib/clacky/web/features/backup/store.js +92 -0
- data/lib/clacky/web/features/backup/view.js +94 -0
- data/lib/clacky/web/features/billing/store.js +163 -0
- data/lib/clacky/web/{billing.js → features/billing/view.js} +132 -240
- data/lib/clacky/web/features/brand/store.js +110 -0
- data/lib/clacky/web/{brand.js → features/brand/view.js} +49 -199
- data/lib/clacky/web/features/channels/store.js +103 -0
- data/lib/clacky/web/{channels.js → features/channels/view.js} +50 -127
- data/lib/clacky/web/features/creator/store.js +81 -0
- data/lib/clacky/web/{creator.js → features/creator/view.js} +53 -102
- data/lib/clacky/web/features/mcp/store.js +158 -0
- data/lib/clacky/web/{mcp.js → features/mcp/view.js} +57 -134
- data/lib/clacky/web/features/model-tester/store.js +77 -0
- data/lib/clacky/web/features/model-tester/view.js +7 -0
- data/lib/clacky/web/features/profile/store.js +170 -0
- data/lib/clacky/web/{profile.js → features/profile/view.js} +94 -144
- data/lib/clacky/web/features/share/store.js +145 -0
- data/lib/clacky/web/{share.js → features/share/view.js} +66 -202
- data/lib/clacky/web/features/skills/store.js +303 -0
- data/lib/clacky/web/features/skills/view.js +550 -0
- data/lib/clacky/web/features/tasks/store.js +135 -0
- data/lib/clacky/web/features/tasks/view.js +241 -0
- data/lib/clacky/web/features/trash/store.js +242 -0
- data/lib/clacky/web/{trash.js → features/trash/view.js} +102 -293
- data/lib/clacky/web/features/version/store.js +165 -0
- data/lib/clacky/web/features/version/view.js +323 -0
- data/lib/clacky/web/features/workspace/store.js +99 -0
- data/lib/clacky/web/features/workspace/view.js +305 -0
- data/lib/clacky/web/i18n.js +56 -6
- data/lib/clacky/web/index.html +117 -58
- data/lib/clacky/web/sessions.js +221 -25
- data/lib/clacky/web/settings.js +118 -22
- data/lib/clacky/web/skills.js +3 -863
- data/lib/clacky/web/vendor/codemirror/codemirror.min.js +29 -0
- data/lib/clacky.rb +1 -0
- metadata +45 -20
- data/lib/clacky/web/backup.js +0 -119
- data/lib/clacky/web/model-tester.js +0 -66
- data/lib/clacky/web/tasks.js +0 -373
- data/lib/clacky/web/version.js +0 -449
- data/lib/clacky/web/workspace.js +0 -316
- /data/lib/clacky/web/{notify.mp3 → assets/notify.mp3} +0 -0
- /data/lib/clacky/web/{datepicker.js → components/datepicker.js} +0 -0
- /data/lib/clacky/web/{onboard.js → components/onboard.js} +0 -0
- /data/lib/clacky/web/{sidebar.js → components/sidebar.js} +0 -0
- /data/lib/clacky/web/{marked.min.js → vendor/marked/marked.min.js} +0 -0
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
// ── Clacky.ext — WebUI extension registry ─────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// The single, controlled entry point through which user/AI-authored
|
|
4
|
+
// extensions (loaded from ~/.clacky/webui_ext/) hook into the WebUI.
|
|
5
|
+
//
|
|
6
|
+
// Three capabilities (the whole contract an extension author must learn):
|
|
7
|
+
// Clacky.ext.api.register(name, fn) — register a data source
|
|
8
|
+
// Clacky.ext.subscribe(event, handler) — listen to store events (read-only)
|
|
9
|
+
// Clacky.ext.ui.mount(slot, renderFn) — inject UI into a named slot
|
|
10
|
+
//
|
|
11
|
+
// Safety guarantees enforced here (the "constitution"):
|
|
12
|
+
// - Every extension callback is wrapped in try/catch. A throwing extension is
|
|
13
|
+
// contained: its slot degrades to a placeholder marked
|
|
14
|
+
// data-ext-status="crashed"; it never takes down the host or sibling slots.
|
|
15
|
+
// - In pure mode (?pure=true), the registry becomes a no-op: register/
|
|
16
|
+
// subscribe/mount do nothing, so no extension code can affect the page.
|
|
17
|
+
// - Extensions reach the host ONLY through this object. The enable/disable
|
|
18
|
+
// and safe-mode controls live in the host frame, never inside an extension.
|
|
19
|
+
//
|
|
20
|
+
// Depends on: nothing (loads right after utils.js).
|
|
21
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
window.Clacky = window.Clacky || {};
|
|
24
|
+
|
|
25
|
+
Clacky.ext = (() => {
|
|
26
|
+
// Pure mode: detect ?pure=true once. When on, all registration is a no-op and
|
|
27
|
+
// the host must not load any webui_ext scripts (handled server-side / in the
|
|
28
|
+
// loader). This is the ultimate escape hatch back to a clean official UI.
|
|
29
|
+
const PURE = (() => {
|
|
30
|
+
try {
|
|
31
|
+
return new URLSearchParams(window.location.search).get("pure") === "true";
|
|
32
|
+
} catch (_e) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
})();
|
|
36
|
+
|
|
37
|
+
const _dataSources = {}; // name => fn
|
|
38
|
+
const _subscribers = {}; // event => [handler]
|
|
39
|
+
const _slotRenderers = {}; // slot => [{ fn, extId, agents, panel, order, tab }]
|
|
40
|
+
let _currentExtId = null; // set while an extension file is loading
|
|
41
|
+
|
|
42
|
+
// Slots that render as a tabbed container instead of a vertical stack: each
|
|
43
|
+
// renderer becomes one tab (chrome drawn by the host), and only the active
|
|
44
|
+
// tab's body is shown. Renderers in these slots must carry opts.tab.
|
|
45
|
+
const TABBED_SLOTS = { "session.aside": true };
|
|
46
|
+
// slot => active tab id (remembered across re-renders within a page load).
|
|
47
|
+
const _activeTab = {};
|
|
48
|
+
|
|
49
|
+
// Per-extension/panel agent scoping declared at load time:
|
|
50
|
+
// _extAgents[extId] = ["coding", ...] (from <script data-agent=...>, may be multiple)
|
|
51
|
+
// _panelAgents[panel] = ["coding", ...] (agents whose profile.yml references the panel)
|
|
52
|
+
// Resolved by the host before the extension scripts run (see loader markers).
|
|
53
|
+
const _extAgents = {};
|
|
54
|
+
const _panelAgents = {};
|
|
55
|
+
let _currentPanel = null; // set while an official panel file is loading
|
|
56
|
+
|
|
57
|
+
// Current session context, kept in sync by the host on every session switch.
|
|
58
|
+
// Slots are (re)rendered against this so an extension/panel only appears for
|
|
59
|
+
// the agent profiles it was scoped to.
|
|
60
|
+
const context = { agentProfile: null };
|
|
61
|
+
|
|
62
|
+
// Wrap any extension-provided callback so a throw is contained, logged, and
|
|
63
|
+
// attributed to the extension that registered it.
|
|
64
|
+
function _guard(fn, label, extId) {
|
|
65
|
+
return (...args) => {
|
|
66
|
+
try {
|
|
67
|
+
return fn(...args);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
console.error(`[Clacky.ext] extension "${extId || "?"}" failed in ${label}:`, err);
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Bracket an extension's synchronous evaluation so registrations made during
|
|
76
|
+
// it are attributed to `extId`. The host emits _extBegin before the
|
|
77
|
+
// extension's <script src> and _extEnd right after. In pure mode these are
|
|
78
|
+
// no-ops (and the host does not emit extension scripts at all).
|
|
79
|
+
//
|
|
80
|
+
// `agents` scopes a single-agent extension (from agents/<name>/webui/): a list
|
|
81
|
+
// of agent profile names it should appear for. `panel` marks an official
|
|
82
|
+
// panel (from _panels/<id>/) whose agent scope is resolved separately via
|
|
83
|
+
// registerPanelAgents. Either may be omitted for a global extension.
|
|
84
|
+
function _extBegin(extId, agents, panel) {
|
|
85
|
+
if (PURE) return;
|
|
86
|
+
_currentExtId = extId;
|
|
87
|
+
if (Array.isArray(agents) && agents.length) _extAgents[extId] = agents;
|
|
88
|
+
if (panel) _currentPanel = panel;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function _extEnd() {
|
|
92
|
+
_currentExtId = null;
|
|
93
|
+
_currentPanel = null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Record which agent profiles reference an official panel (host computes this
|
|
97
|
+
// from each agent's profile.yml `panels:` declaration). Called once at load.
|
|
98
|
+
function registerPanelAgents(map) {
|
|
99
|
+
if (PURE || !map) return;
|
|
100
|
+
Object.assign(_panelAgents, map);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const api = {
|
|
104
|
+
// Register a named data source. Host/extensions can later resolve it.
|
|
105
|
+
register(name, fn) {
|
|
106
|
+
if (PURE || typeof fn !== "function") return;
|
|
107
|
+
_dataSources[name] = _guard(fn, `api.register(${name})`, _currentExtId);
|
|
108
|
+
},
|
|
109
|
+
// Resolve a registered data source by name; undefined if absent.
|
|
110
|
+
resolve(name) {
|
|
111
|
+
return _dataSources[name];
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// Subscribe to a store event. Read-only: handlers can observe, never mutate
|
|
116
|
+
// core logic. Returns an unsubscribe function.
|
|
117
|
+
function subscribe(event, handler) {
|
|
118
|
+
if (PURE || typeof handler !== "function") return () => {};
|
|
119
|
+
const wrapped = _guard(handler, `subscribe(${event})`, _currentExtId);
|
|
120
|
+
(_subscribers[event] ||= []).push(wrapped);
|
|
121
|
+
return () => {
|
|
122
|
+
const list = _subscribers[event];
|
|
123
|
+
if (!list) return;
|
|
124
|
+
const i = list.indexOf(wrapped);
|
|
125
|
+
if (i >= 0) list.splice(i, 1);
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Emit a store event to all subscribers. Called by store-layer code (host),
|
|
130
|
+
// never by extensions. A throwing subscriber is already guarded above.
|
|
131
|
+
function emit(event, payload) {
|
|
132
|
+
const list = _subscribers[event];
|
|
133
|
+
if (!list) return;
|
|
134
|
+
list.forEach((h) => h(payload));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const ui = {
|
|
138
|
+
// Register a renderer for a named slot. renderFn(ctx) -> Node | string | null.
|
|
139
|
+
//
|
|
140
|
+
// opts.agents — restrict to these agent profile names (default: all agents).
|
|
141
|
+
// For panels, scope is taken from the panel's registerPanelAgents map and
|
|
142
|
+
// merged with any explicit opts.agents.
|
|
143
|
+
// opts.order — vertical sort weight when several renderers share one slot
|
|
144
|
+
// (lower renders first). Default 100; ties keep registration order.
|
|
145
|
+
// opts.tab — { id, label, badge? } required for tabbed slots: marks this
|
|
146
|
+
// renderer as one tab in the slot's tab bar.
|
|
147
|
+
mount(slot, renderFn, opts) {
|
|
148
|
+
if (PURE || typeof renderFn !== "function") return;
|
|
149
|
+
const explicit = opts && Array.isArray(opts.agents) ? opts.agents : null;
|
|
150
|
+
const panel = (opts && opts.panel) || _currentPanel || null;
|
|
151
|
+
const scoped = explicit || _extAgents[_currentExtId] || null;
|
|
152
|
+
const order = opts && Number.isFinite(opts.order) ? opts.order : 100;
|
|
153
|
+
const tab = (opts && opts.tab) || null;
|
|
154
|
+
(_slotRenderers[slot] ||= []).push({
|
|
155
|
+
fn: _guard(renderFn, `ui.mount(${slot})`, _currentExtId),
|
|
156
|
+
extId: _currentExtId,
|
|
157
|
+
agents: scoped, // explicit/per-extension agent list, or null = global
|
|
158
|
+
panel, // official-panel id, or null
|
|
159
|
+
order,
|
|
160
|
+
tab, // { id, label, badge? } for tabbed slots, else null
|
|
161
|
+
});
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
// Register a host-owned renderer (not attributed to any extension). Used for
|
|
165
|
+
// built-in tabs that must appear for every session regardless of agent
|
|
166
|
+
// scope (e.g. the Files tab). Bypasses PURE so the official UI keeps working
|
|
167
|
+
// in safe mode. agents=null => visible everywhere.
|
|
168
|
+
mountBuiltin(slot, renderFn, opts) {
|
|
169
|
+
if (typeof renderFn !== "function") return;
|
|
170
|
+
const order = opts && Number.isFinite(opts.order) ? opts.order : 100;
|
|
171
|
+
const tab = (opts && opts.tab) || null;
|
|
172
|
+
(_slotRenderers[slot] ||= []).push({
|
|
173
|
+
fn: _guard(renderFn, `ui.mountBuiltin(${slot})`, "host"),
|
|
174
|
+
extId: "host",
|
|
175
|
+
agents: null,
|
|
176
|
+
panel: null,
|
|
177
|
+
order,
|
|
178
|
+
tab,
|
|
179
|
+
});
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
// Decide whether a renderer is visible under the current agent profile.
|
|
184
|
+
// null agents AND null panel => global (always visible). Otherwise the
|
|
185
|
+
// current profile must be in the renderer's agent list, or in the set of
|
|
186
|
+
// agents that reference its panel.
|
|
187
|
+
function _visibleFor(entry, profile) {
|
|
188
|
+
if (!entry.agents && !entry.panel) return true;
|
|
189
|
+
if (entry.agents && entry.agents.includes(profile)) return true;
|
|
190
|
+
if (entry.panel && (_panelAgents[entry.panel] || []).includes(profile)) return true;
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Render every extension registered for `slot` into `container`, scoped to the
|
|
195
|
+
// current agent profile. Called by the host's view layer wherever it exposes a
|
|
196
|
+
// slot, and re-called on session switch (the container is cleared first so a
|
|
197
|
+
// previous agent's panels don't linger). Each renderer is isolated: if one
|
|
198
|
+
// throws (guarded -> returns undefined) or yields nothing, a degraded
|
|
199
|
+
// placeholder marked data-ext-status="crashed" is shown for it, and sibling
|
|
200
|
+
// renderers / the rest of the page are unaffected.
|
|
201
|
+
function renderSlot(slot, container, ctx) {
|
|
202
|
+
if (!container) return;
|
|
203
|
+
if (TABBED_SLOTS[slot]) {
|
|
204
|
+
renderTabbedSlot(slot, container, ctx);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
container.replaceChildren(); // clear stale render from a previous agent
|
|
208
|
+
const renderers = _slotRenderers[slot];
|
|
209
|
+
if (!renderers || renderers.length === 0) return;
|
|
210
|
+
|
|
211
|
+
const profile = (ctx && ctx.agentProfile) || context.agentProfile;
|
|
212
|
+
const renderCtx = Object.assign({}, context, ctx || {});
|
|
213
|
+
// Lower order renders first; sort is stable so equal orders keep
|
|
214
|
+
// registration order. slice() avoids mutating the registry.
|
|
215
|
+
const ordered = renderers.slice().sort((a, b) => a.order - b.order);
|
|
216
|
+
ordered.forEach((entry) => {
|
|
217
|
+
if (!_visibleFor(entry, profile)) return;
|
|
218
|
+
const { fn, extId } = entry;
|
|
219
|
+
let node;
|
|
220
|
+
let crashed = false;
|
|
221
|
+
try {
|
|
222
|
+
node = fn(renderCtx);
|
|
223
|
+
if (node === undefined) crashed = true; // guard swallowed a throw
|
|
224
|
+
} catch (_e) {
|
|
225
|
+
crashed = true; // defensive: should already be guarded
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (crashed) {
|
|
229
|
+
const ph = document.createElement("div");
|
|
230
|
+
ph.setAttribute("data-ext-status", "crashed");
|
|
231
|
+
ph.setAttribute("data-ext-id", extId || "");
|
|
232
|
+
ph.className = "ext-slot-crashed";
|
|
233
|
+
ph.textContent = "Extension failed to render.";
|
|
234
|
+
container.appendChild(ph);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (node == null) return; // nothing to render is valid
|
|
239
|
+
if (typeof node === "string") {
|
|
240
|
+
const wrap = document.createElement("div");
|
|
241
|
+
wrap.setAttribute("data-ext-id", extId || "");
|
|
242
|
+
wrap.innerHTML = node;
|
|
243
|
+
container.appendChild(wrap);
|
|
244
|
+
} else {
|
|
245
|
+
container.appendChild(node);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Render a tabbed slot: a tab bar across the top, one body shown at a time.
|
|
251
|
+
// Each visible renderer (carrying entry.tab) becomes a tab; its body is
|
|
252
|
+
// rendered lazily on first activation and cached for the lifetime of this
|
|
253
|
+
// render pass. The host chrome (resize / collapse) lives in the surrounding
|
|
254
|
+
// DOM and is not touched here. Re-rendered wholesale on every session switch.
|
|
255
|
+
function renderTabbedSlot(slot, container, ctx) {
|
|
256
|
+
container.replaceChildren();
|
|
257
|
+
const profile = (ctx && ctx.agentProfile) || context.agentProfile;
|
|
258
|
+
const renderCtx = Object.assign({}, context, ctx || {});
|
|
259
|
+
|
|
260
|
+
const renderers = (_slotRenderers[slot] || [])
|
|
261
|
+
.slice()
|
|
262
|
+
.sort((a, b) => a.order - b.order)
|
|
263
|
+
.filter((e) => e.tab && _visibleFor(e, profile));
|
|
264
|
+
|
|
265
|
+
if (renderers.length === 0) return; // empty slot → host CSS collapses it
|
|
266
|
+
|
|
267
|
+
const tabBar = document.createElement("div");
|
|
268
|
+
tabBar.className = "aside-tabs";
|
|
269
|
+
const bodies = document.createElement("div");
|
|
270
|
+
bodies.className = "aside-bodies";
|
|
271
|
+
|
|
272
|
+
// Restore previously active tab if still present, else first tab.
|
|
273
|
+
const ids = renderers.map((e) => e.tab.id);
|
|
274
|
+
let active = _activeTab[slot];
|
|
275
|
+
if (!active || !ids.includes(active)) active = ids[0];
|
|
276
|
+
|
|
277
|
+
const tabBtns = {};
|
|
278
|
+
const bodyEls = {};
|
|
279
|
+
const rendered = {};
|
|
280
|
+
|
|
281
|
+
function activate(id) {
|
|
282
|
+
_activeTab[slot] = id;
|
|
283
|
+
Object.keys(tabBtns).forEach((k) => tabBtns[k].classList.toggle("active", k === id));
|
|
284
|
+
Object.keys(bodyEls).forEach((k) => bodyEls[k].classList.toggle("active", k === id));
|
|
285
|
+
if (!rendered[id]) {
|
|
286
|
+
rendered[id] = true;
|
|
287
|
+
const entry = renderers.find((e) => e.tab.id === id);
|
|
288
|
+
const body = bodyEls[id];
|
|
289
|
+
const localCtx = Object.assign({}, renderCtx, {
|
|
290
|
+
setBadge: (n) => _setTabBadge(tabBtns[id], n),
|
|
291
|
+
});
|
|
292
|
+
let node, crashed = false;
|
|
293
|
+
try {
|
|
294
|
+
node = entry.fn(localCtx);
|
|
295
|
+
if (node === undefined) crashed = true;
|
|
296
|
+
} catch (_e) { crashed = true; }
|
|
297
|
+
if (crashed) {
|
|
298
|
+
const ph = document.createElement("div");
|
|
299
|
+
ph.className = "ext-slot-crashed";
|
|
300
|
+
ph.textContent = "Extension failed to render.";
|
|
301
|
+
body.appendChild(ph);
|
|
302
|
+
} else if (node != null) {
|
|
303
|
+
if (typeof node === "string") body.innerHTML = node;
|
|
304
|
+
else body.appendChild(node);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
renderers.forEach((entry) => {
|
|
310
|
+
const id = entry.tab.id;
|
|
311
|
+
const btn = document.createElement("button");
|
|
312
|
+
btn.className = "aside-tab";
|
|
313
|
+
btn.type = "button";
|
|
314
|
+
btn.setAttribute("data-tab", id);
|
|
315
|
+
const label = document.createElement("span");
|
|
316
|
+
label.textContent = entry.tab.label || id;
|
|
317
|
+
btn.appendChild(label);
|
|
318
|
+
if (entry.tab.badge != null) _setTabBadge(btn, entry.tab.badge);
|
|
319
|
+
btn.addEventListener("click", () => activate(id));
|
|
320
|
+
tabBtns[id] = btn;
|
|
321
|
+
tabBar.appendChild(btn);
|
|
322
|
+
|
|
323
|
+
const body = document.createElement("div");
|
|
324
|
+
body.className = "aside-panel";
|
|
325
|
+
body.setAttribute("data-panel", id);
|
|
326
|
+
bodyEls[id] = body;
|
|
327
|
+
bodies.appendChild(body);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
container.appendChild(tabBar);
|
|
331
|
+
container.appendChild(bodies);
|
|
332
|
+
activate(active);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Set or clear a tab's badge pill. n == null/0 removes it.
|
|
336
|
+
function _setTabBadge(btn, n) {
|
|
337
|
+
if (!btn) return;
|
|
338
|
+
let badge = btn.querySelector(".aside-tab-badge");
|
|
339
|
+
if (n == null || n === 0 || n === "") {
|
|
340
|
+
if (badge) badge.remove();
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
if (!badge) {
|
|
344
|
+
badge = document.createElement("span");
|
|
345
|
+
badge.className = "aside-tab-badge";
|
|
346
|
+
btn.appendChild(badge);
|
|
347
|
+
}
|
|
348
|
+
badge.textContent = String(n);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// List slot names that currently have at least one renderer (host/debug use).
|
|
352
|
+
function slots() {
|
|
353
|
+
return Object.keys(_slotRenderers).filter((s) => _slotRenderers[s].length > 0);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Update the current session context (host calls this on every session
|
|
357
|
+
// switch) and re-render all slots so panels match the new agent profile.
|
|
358
|
+
function setContext(next) {
|
|
359
|
+
Object.assign(context, next || {});
|
|
360
|
+
refreshSlots();
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Re-render every named slot present in the DOM against the current context.
|
|
364
|
+
// Idempotent: each slot's container is cleared before re-rendering.
|
|
365
|
+
function refreshSlots() {
|
|
366
|
+
if (PURE) return;
|
|
367
|
+
document.querySelectorAll("[data-slot]").forEach((el) => {
|
|
368
|
+
renderSlot(el.getAttribute("data-slot"), el);
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return {
|
|
373
|
+
get pure() { return PURE; },
|
|
374
|
+
context,
|
|
375
|
+
setContext,
|
|
376
|
+
refreshSlots,
|
|
377
|
+
registerPanelAgents,
|
|
378
|
+
api,
|
|
379
|
+
ui,
|
|
380
|
+
subscribe,
|
|
381
|
+
emit,
|
|
382
|
+
renderSlot,
|
|
383
|
+
slots,
|
|
384
|
+
_extBegin, // used by the loader; not part of the public extension API
|
|
385
|
+
_extEnd, // used by the loader; not part of the public extension API
|
|
386
|
+
};
|
|
387
|
+
})();
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// ── Backup · store — backup status/config + network ───────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Owns the backup status/config state and the network calls to
|
|
4
|
+
// /api/backup/{status,config,download}. It never renders.
|
|
5
|
+
//
|
|
6
|
+
// `Backup` stays the single public facade.
|
|
7
|
+
//
|
|
8
|
+
// Depends on: Clacky.ext.
|
|
9
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
const BackupStore = (() => {
|
|
12
|
+
let _status = null;
|
|
13
|
+
let _saving = false;
|
|
14
|
+
|
|
15
|
+
const _listeners = {};
|
|
16
|
+
|
|
17
|
+
function _on(event, handler) {
|
|
18
|
+
(_listeners[event] ||= []).push(handler);
|
|
19
|
+
return () => {
|
|
20
|
+
const list = _listeners[event];
|
|
21
|
+
const i = list ? list.indexOf(handler) : -1;
|
|
22
|
+
if (i >= 0) list.splice(i, 1);
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function _emit(event, payload) {
|
|
27
|
+
(_listeners[event] || []).forEach((h) => h(payload));
|
|
28
|
+
if (window.Clacky && Clacky.ext) Clacky.ext.emit(event, payload);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const state = {
|
|
32
|
+
get status() { return _status; },
|
|
33
|
+
get config() { return (_status && _status.config) || {}; },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const Backup = {
|
|
37
|
+
on: _on,
|
|
38
|
+
state,
|
|
39
|
+
|
|
40
|
+
async load() {
|
|
41
|
+
try {
|
|
42
|
+
const res = await fetch("/api/backup/status");
|
|
43
|
+
_status = await res.json();
|
|
44
|
+
_emit("backup:changed");
|
|
45
|
+
} catch (e) {
|
|
46
|
+
// Backup section is non-critical; fail quietly.
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
async saveConfig(patch) {
|
|
51
|
+
if (_saving) return { ok: false };
|
|
52
|
+
_saving = true;
|
|
53
|
+
try {
|
|
54
|
+
const res = await fetch("/api/backup/config", {
|
|
55
|
+
method: "PATCH",
|
|
56
|
+
headers: { "Content-Type": "application/json" },
|
|
57
|
+
body: JSON.stringify(patch)
|
|
58
|
+
});
|
|
59
|
+
const data = await res.json();
|
|
60
|
+
if (data.ok) { _status = data.status; _emit("backup:changed"); }
|
|
61
|
+
return data;
|
|
62
|
+
} catch (e) {
|
|
63
|
+
return { ok: false, error: e.message };
|
|
64
|
+
} finally {
|
|
65
|
+
_saving = false;
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
/** Fetch a one-off archive. Returns { ok, blob, filename, error }. */
|
|
70
|
+
async fetchArchive() {
|
|
71
|
+
try {
|
|
72
|
+
const res = await fetch("/api/backup/download");
|
|
73
|
+
if (!res.ok) {
|
|
74
|
+
let msg = "failed";
|
|
75
|
+
try { msg = (await res.json()).error || msg; } catch (e) {}
|
|
76
|
+
throw new Error(msg);
|
|
77
|
+
}
|
|
78
|
+
const blob = await res.blob();
|
|
79
|
+
const cd = res.headers.get("Content-Disposition") || "";
|
|
80
|
+
const m = cd.match(/filename="?([^"]+)"?/);
|
|
81
|
+
const filename = (m && m[1]) || "clacky-backup.tar.gz";
|
|
82
|
+
return { ok: true, blob, filename };
|
|
83
|
+
} catch (e) {
|
|
84
|
+
return { ok: false, error: e.message };
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
return Backup;
|
|
90
|
+
})();
|
|
91
|
+
|
|
92
|
+
const Backup = BackupStore;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// ── Backup · view — settings rendering, toggles, download UI ──────────────
|
|
2
|
+
//
|
|
3
|
+
// Renders the backup settings panel, wires the toggles + download button, and
|
|
4
|
+
// drives the file download from a blob. Reads through BackupStore.state; all
|
|
5
|
+
// I/O goes through store actions. Re-renders on store change events.
|
|
6
|
+
//
|
|
7
|
+
// Augments the `Backup` facade with load (re-exposing the store action so the
|
|
8
|
+
// existing Settings caller keeps working).
|
|
9
|
+
//
|
|
10
|
+
// Depends on: BackupStore, I18n.
|
|
11
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
const BackupView = (() => {
|
|
14
|
+
const $ = (id) => document.getElementById(id);
|
|
15
|
+
|
|
16
|
+
function _fmtDate(iso) {
|
|
17
|
+
if (!iso) return "";
|
|
18
|
+
try { return new Date(iso).toLocaleString(); } catch (e) { return iso; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function _render() {
|
|
22
|
+
const cfg = Backup.state.config;
|
|
23
|
+
if (!Backup.state.status) return;
|
|
24
|
+
|
|
25
|
+
const incl = $("backup-include-sessions");
|
|
26
|
+
if (incl) {
|
|
27
|
+
incl.checked = cfg.include_sessions !== false;
|
|
28
|
+
incl.disabled = !cfg.enabled;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const autoToggle = $("backup-auto-toggle");
|
|
32
|
+
if (autoToggle) autoToggle.checked = !!cfg.enabled;
|
|
33
|
+
|
|
34
|
+
_renderLastRun(cfg);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function _renderLastRun(cfg) {
|
|
38
|
+
const el = $("backup-status");
|
|
39
|
+
if (!el) return;
|
|
40
|
+
if (!cfg.last_run_at) { el.textContent = ""; el.className = "model-test-result"; return; }
|
|
41
|
+
if (cfg.last_status === "error") {
|
|
42
|
+
el.textContent = I18n.t("settings.backup.lastError", { msg: cfg.last_error || "" });
|
|
43
|
+
el.className = "model-test-result error";
|
|
44
|
+
} else {
|
|
45
|
+
el.textContent = I18n.t("settings.backup.lastOk", { time: _fmtDate(cfg.last_run_at) });
|
|
46
|
+
el.className = "model-test-result success";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function _downloadNow() {
|
|
51
|
+
const btn = $("btn-backup-now");
|
|
52
|
+
const el = $("backup-status");
|
|
53
|
+
if (btn) btn.disabled = true;
|
|
54
|
+
if (el) { el.textContent = I18n.t("settings.backup.running"); el.className = "model-test-result"; }
|
|
55
|
+
|
|
56
|
+
const res = await Backup.fetchArchive();
|
|
57
|
+
if (res.ok) {
|
|
58
|
+
const url = URL.createObjectURL(res.blob);
|
|
59
|
+
const a = document.createElement("a");
|
|
60
|
+
a.href = url;
|
|
61
|
+
a.download = res.filename;
|
|
62
|
+
document.body.appendChild(a);
|
|
63
|
+
a.click();
|
|
64
|
+
a.remove();
|
|
65
|
+
URL.revokeObjectURL(url);
|
|
66
|
+
if (el) { el.textContent = I18n.t("settings.backup.downloaded"); el.className = "model-test-result success"; }
|
|
67
|
+
} else if (el) {
|
|
68
|
+
el.textContent = I18n.t("settings.backup.lastError", { msg: res.error });
|
|
69
|
+
el.className = "model-test-result error";
|
|
70
|
+
}
|
|
71
|
+
if (btn) btn.disabled = false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function _bind() {
|
|
75
|
+
const btn = $("btn-backup-now");
|
|
76
|
+
if (btn) btn.addEventListener("click", _downloadNow);
|
|
77
|
+
|
|
78
|
+
const autoToggle = $("backup-auto-toggle");
|
|
79
|
+
if (autoToggle) autoToggle.addEventListener("change", () => Backup.saveConfig({ enabled: autoToggle.checked }));
|
|
80
|
+
|
|
81
|
+
const incl = $("backup-include-sessions");
|
|
82
|
+
if (incl) incl.addEventListener("change", () => Backup.saveConfig({ include_sessions: incl.checked }));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function _subscribe() {
|
|
86
|
+
Backup.on("backup:changed", _render);
|
|
87
|
+
document.addEventListener("DOMContentLoaded", _bind);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { init: _subscribe };
|
|
91
|
+
})();
|
|
92
|
+
|
|
93
|
+
BackupView.init();
|
|
94
|
+
window.Backup = Backup;
|