openclacky 1.5.11 → 1.5.13
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 +82 -0
- data/lib/clacky/access_key.rb +59 -0
- data/lib/clacky/agent/cost_tracker.rb +2 -0
- data/lib/clacky/agent/message_compressor_helper.rb +53 -2
- data/lib/clacky/agent/session_serializer.rb +72 -19
- data/lib/clacky/agent/time_machine.rb +10 -1
- data/lib/clacky/agent.rb +107 -26
- data/lib/clacky/agent_config.rb +40 -0
- data/lib/clacky/agent_profile.rb +5 -5
- data/lib/clacky/billing/billing_record.rb +3 -0
- data/lib/clacky/billing/billing_store.rb +31 -5
- data/lib/clacky/billing/platform_billing.rb +231 -0
- data/lib/clacky/brand_config.rb +4 -3
- data/lib/clacky/cancel_flag.rb +23 -0
- data/lib/clacky/cli.rb +4 -2
- data/lib/clacky/client.rb +107 -0
- data/lib/clacky/default_extensions/advisor/advisors/general.md +23 -17
- data/lib/clacky/default_extensions/advisor/hooks/advisor.rb +104 -5
- data/lib/clacky/default_extensions/advisor/panels/advisor/view.js +92 -75
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +31 -0
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +74 -24
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +16 -37
- data/lib/clacky/default_extensions/git/ext.yml +1 -1
- data/lib/clacky/default_extensions/git/panels/git/view.js +483 -67
- data/lib/clacky/default_extensions/meeting/ext.yml +2 -1
- data/lib/clacky/default_extensions/time_machine/ext.yml +1 -1
- data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +186 -208
- data/lib/clacky/extension/packager.rb +4 -2
- data/lib/clacky/extension/scaffold/templates/full/panels/dashboard/view.js.erb +2 -3
- data/lib/clacky/extension/verifier.rb +14 -1
- data/lib/clacky/fanout.rb +49 -2
- data/lib/clacky/json_ui_controller.rb +3 -2
- data/lib/clacky/message_format/open_ai.rb +5 -1
- data/lib/clacky/message_format/open_ai_responses.rb +409 -0
- data/lib/clacky/message_history.rb +43 -1
- data/lib/clacky/openai_responses_stream_aggregator.rb +294 -0
- data/lib/clacky/plain_ui_controller.rb +15 -5
- data/lib/clacky/providers.rb +24 -7
- data/lib/clacky/rich_ui/rich_ui_controller.rb +2 -2
- data/lib/clacky/server/channel/channel_manager.rb +93 -18
- data/lib/clacky/server/channel/channel_ui_controller.rb +16 -3
- data/lib/clacky/server/dir_picker.rb +154 -0
- data/lib/clacky/server/git_panel.rb +61 -9
- data/lib/clacky/server/http_server.rb +366 -42
- data/lib/clacky/server/web_ui_controller.rb +7 -5
- data/lib/clacky/session_manager.rb +74 -17
- data/lib/clacky/tools/ask_user.rb +3 -2
- data/lib/clacky/tools/terminal.rb +30 -3
- data/lib/clacky/ui2/markdown_renderer.rb +15 -12
- data/lib/clacky/ui2/strings_cjk_patch.rb +132 -0
- data/lib/clacky/ui2/themes/hacker_theme.rb +1 -1
- data/lib/clacky/ui2/themes/minimal_theme.rb +1 -1
- data/lib/clacky/ui2/ui_controller.rb +2 -2
- data/lib/clacky/ui_interface.rb +2 -2
- data/lib/clacky/utils/model_pricing.rb +92 -4
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +915 -142
- data/lib/clacky/web/components/code-editor.js +33 -1
- data/lib/clacky/web/components/composer.js +315 -0
- data/lib/clacky/web/components/custom-select.js +136 -0
- data/lib/clacky/web/components/mentions.js +655 -0
- data/lib/clacky/web/components/model-picker.js +527 -0
- data/lib/clacky/web/components/onboard.js +29 -55
- data/lib/clacky/web/core/aside.js +3 -3
- data/lib/clacky/web/core/ext.js +38 -0
- data/lib/clacky/web/features/billing/store.js +2 -6
- data/lib/clacky/web/features/billing/view.js +32 -16
- data/lib/clacky/web/features/extensions/view.js +22 -10
- data/lib/clacky/web/features/new-session/store.js +15 -1
- data/lib/clacky/web/features/new-session/view.js +80 -34
- data/lib/clacky/web/features/trash/view.js +31 -19
- data/lib/clacky/web/features/workspace/store.js +1 -0
- data/lib/clacky/web/features/workspace/view.js +766 -80
- data/lib/clacky/web/i18n.js +107 -17
- data/lib/clacky/web/index.html +125 -45
- data/lib/clacky/web/sessions.js +704 -635
- data/lib/clacky/web/settings.js +164 -190
- data/lib/clacky/web/skills.js +11 -17
- data/lib/clacky/web/ws-dispatcher.js +11 -33
- data/lib/clacky.rb +4 -0
- metadata +12 -1
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
// ── Advisor recommendations bar + settings toggle ─────────────────────
|
|
2
2
|
//
|
|
3
|
-
// session.composer slot: recommendation card above the input box.
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
3
|
+
// session.composer slot: recommendation card above the input box. Card
|
|
4
|
+
// state is keyed by session id - each session's recommendations live in
|
|
5
|
+
// their own bucket, so switching sessions never leaks one session's card
|
|
6
|
+
// into another (and switching back restores it). The ✕ button dismisses
|
|
7
|
+
// the card for the rest of the current round only - the next round's
|
|
8
|
+
// pending event resets the flag, so recommendations return without a
|
|
9
|
+
// page refresh. The ⏻ button turns the advisor off globally (persisted to
|
|
10
|
+
// advisor.yml); a toast reminds the user it can be re-enabled in Settings.
|
|
11
|
+
// Clicking an option fills the input box so the user can review (or edit)
|
|
12
|
+
// before sending. Nothing is executed automatically.
|
|
10
13
|
//
|
|
11
14
|
// settings.tabs + settings.body: an "Advisor" tab with a master on/off
|
|
12
15
|
// switch, persisted to ~/.clacky/advisor.yml via /api/ext/advisor/*.
|
|
@@ -85,7 +88,6 @@
|
|
|
85
88
|
.advisor-option-send { position: absolute; right: 6px; top: 50%; transform: translateY(-50%); width: 22px; height: 22px; display: inline-flex; align-items: center; justify-content: center; border: none; border-radius: var(--radius-sm); background: transparent; color: var(--color-text-secondary); cursor: pointer; opacity: 0; transition: opacity 0.12s; padding: 0; }
|
|
86
89
|
.advisor-option:hover .advisor-option-send { opacity: 1; }
|
|
87
90
|
.advisor-option-send:hover { background: var(--color-accent-primary); color: #fff; }
|
|
88
|
-
.advisor-card-text { font-size: 12px; color: var(--color-text-secondary); white-space: pre-wrap; }
|
|
89
91
|
.advisor-error { font-size: 12px; color: var(--color-error, #c0392b); background: var(--color-error-bg, #fef2f2); padding: 6px 8px; border-radius: var(--radius-sm); white-space: pre-wrap; }
|
|
90
92
|
.advisor-pending { font-size: 12px; color: var(--color-text-tertiary); display: flex; align-items: center; gap: 8px; }
|
|
91
93
|
.advisor-spinner { width: 12px; height: 12px; flex: none; border: 2px solid var(--color-border-strong, #c9c9c2); border-top-color: transparent; border-radius: 50%; animation: advisor-spin 0.8s linear infinite; }
|
|
@@ -95,31 +97,17 @@
|
|
|
95
97
|
document.head.appendChild(style);
|
|
96
98
|
}
|
|
97
99
|
|
|
98
|
-
function
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
options.push({ action: m[1].trim(), reason: m[2].trim() });
|
|
104
|
-
} else if (line.trim()) {
|
|
105
|
-
// Tolerate models that skip the [action] wrapper — the whole line
|
|
106
|
-
// still becomes a clickable option.
|
|
107
|
-
options.push({ action: line.trim(), reason: "" });
|
|
108
|
-
}
|
|
109
|
-
});
|
|
110
|
-
return options;
|
|
100
|
+
function normalizeOptions(list) {
|
|
101
|
+
if (!Array.isArray(list)) return [];
|
|
102
|
+
return list
|
|
103
|
+
.filter((o) => o && typeof o.action === "string" && o.action.trim())
|
|
104
|
+
.map((o) => ({ action: o.action.trim(), reason: (o.reason || "").trim() }));
|
|
111
105
|
}
|
|
112
106
|
|
|
113
107
|
function fillInput(text) {
|
|
114
108
|
const input = document.getElementById("user-input");
|
|
115
|
-
if (!input) return;
|
|
116
|
-
input
|
|
117
|
-
// Match the host's auto-grow behaviour (sessions.js input handler): a raw
|
|
118
|
-
// value assignment never fires the input event, so multi-line fills would
|
|
119
|
-
// otherwise stay at one-line height with the rest clipped.
|
|
120
|
-
input.style.height = "auto";
|
|
121
|
-
input.style.height = Math.min(input.scrollHeight, 200) + "px";
|
|
122
|
-
input.focus();
|
|
109
|
+
if (!input || !Clacky.Composer) return;
|
|
110
|
+
Clacky.Composer.setText(input, text);
|
|
123
111
|
}
|
|
124
112
|
|
|
125
113
|
// Fill the composer and send immediately. _sendMessage clears the input
|
|
@@ -127,9 +115,8 @@
|
|
|
127
115
|
function sendNow(text) {
|
|
128
116
|
const input = document.getElementById("user-input");
|
|
129
117
|
const btn = document.getElementById("btn-send");
|
|
130
|
-
if (!input || !btn) return;
|
|
131
|
-
input
|
|
132
|
-
input.style.height = "auto";
|
|
118
|
+
if (!input || !btn || !Clacky.Composer) return;
|
|
119
|
+
Clacky.Composer.setText(input, text);
|
|
133
120
|
btn.click();
|
|
134
121
|
}
|
|
135
122
|
|
|
@@ -179,26 +166,46 @@
|
|
|
179
166
|
});
|
|
180
167
|
}
|
|
181
168
|
|
|
182
|
-
// Module-level state
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
|
|
186
|
-
|
|
169
|
+
// Module-level state for the global advisor switch. advisor.yml is a
|
|
170
|
+
// global config, so `off` applies to every session for the rest of this
|
|
171
|
+
// page lifetime (⏻ button or settings switch).
|
|
172
|
+
const state = { off: false };
|
|
173
|
+
|
|
174
|
+
// Per-session card state, keyed by sessionId. The bus mirrors every ext.*
|
|
175
|
+
// event regardless of the active session, so each session's card state must
|
|
176
|
+
// live in its own bucket: session A's recommendations stay in A while you
|
|
177
|
+
// view B, and come back when you return to A. roundDismissed: the user hit ✕
|
|
178
|
+
// this round - hide the card and ignore events until the next round's
|
|
179
|
+
// pending event resets the flag.
|
|
180
|
+
//
|
|
181
|
+
// The host's per-session `create(ctx)` runtime can't hold this - it is only
|
|
182
|
+
// allocated during a render pass, and background sessions receive events
|
|
183
|
+
// while they have no render context.
|
|
184
|
+
const sessionStates = new Map();
|
|
185
|
+
|
|
186
|
+
function stateFor(sessionId) {
|
|
187
|
+
let s = sessionStates.get(sessionId);
|
|
188
|
+
if (!s) {
|
|
189
|
+
s = { mode: "hidden", options: [], errorMessage: "", roundDismissed: false };
|
|
190
|
+
sessionStates.set(sessionId, s);
|
|
191
|
+
}
|
|
192
|
+
return s;
|
|
193
|
+
}
|
|
187
194
|
|
|
188
195
|
function dismiss() {
|
|
189
|
-
|
|
190
|
-
|
|
196
|
+
const s = sessionStates.get(Clacky.ext.context.sessionId);
|
|
197
|
+
if (!s) return;
|
|
198
|
+
s.roundDismissed = true;
|
|
199
|
+
s.mode = "hidden";
|
|
191
200
|
redraw();
|
|
192
201
|
}
|
|
193
|
-
|
|
194
202
|
// Global off: POST the disable endpoint (persisted in advisor.yml) and
|
|
195
203
|
// hide the card; the toast tells the user where to re-enable.
|
|
196
204
|
function turnOff() {
|
|
197
205
|
fetch("/api/ext/advisor/disable", { method: "POST" })
|
|
198
206
|
.then((r) => { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
|
|
199
207
|
.then((d) => {
|
|
200
|
-
state.
|
|
201
|
-
state.mode = "hidden";
|
|
208
|
+
state.off = true;
|
|
202
209
|
redraw();
|
|
203
210
|
if (window.Clacky && Clacky.Modal && Clacky.Modal.toast) {
|
|
204
211
|
Clacky.Modal.toast(t("advisor.offHint", "已关闭工作建议,可在设置页重新打开"), {
|
|
@@ -224,10 +231,15 @@
|
|
|
224
231
|
});
|
|
225
232
|
}
|
|
226
233
|
|
|
227
|
-
// Build the current card
|
|
228
|
-
// treats a null result as "render nothing", which
|
|
229
|
-
|
|
230
|
-
|
|
234
|
+
// Build the current card for the render context's session. Returns null
|
|
235
|
+
// when hidden - the host treats a null result as "render nothing", which
|
|
236
|
+
// clears the slot.
|
|
237
|
+
function renderContent(ctx) {
|
|
238
|
+
if (!ctx || !ctx.sessionId || state.off) return null;
|
|
239
|
+
// Read-only lookup: a session with no advisor event yet has no bucket and
|
|
240
|
+
// renders nothing, so rendering never allocates state.
|
|
241
|
+
const s = sessionStates.get(ctx.sessionId);
|
|
242
|
+
if (!s || s.mode === "hidden") return null;
|
|
231
243
|
|
|
232
244
|
const slot = document.createElement("div");
|
|
233
245
|
slot.className = "advisor-card";
|
|
@@ -264,7 +276,7 @@
|
|
|
264
276
|
head.appendChild(actions);
|
|
265
277
|
slot.appendChild(head);
|
|
266
278
|
|
|
267
|
-
if (
|
|
279
|
+
if (s.mode === "pending") {
|
|
268
280
|
const row = document.createElement("div");
|
|
269
281
|
row.className = "advisor-pending";
|
|
270
282
|
|
|
@@ -277,21 +289,16 @@
|
|
|
277
289
|
return slot;
|
|
278
290
|
}
|
|
279
291
|
|
|
280
|
-
if (
|
|
292
|
+
if (s.mode === "error") {
|
|
281
293
|
const row = document.createElement("div");
|
|
282
294
|
row.className = "advisor-error";
|
|
283
|
-
row.textContent =
|
|
295
|
+
row.textContent = s.errorMessage;
|
|
284
296
|
slot.appendChild(row);
|
|
285
297
|
return slot;
|
|
286
298
|
}
|
|
287
299
|
|
|
288
|
-
if (
|
|
289
|
-
renderOptions(slot,
|
|
290
|
-
} else {
|
|
291
|
-
const text = document.createElement("div");
|
|
292
|
-
text.className = "advisor-card-text";
|
|
293
|
-
text.textContent = state.fallback;
|
|
294
|
-
slot.appendChild(text);
|
|
300
|
+
if (s.options.length > 0) {
|
|
301
|
+
renderOptions(slot, s.options);
|
|
295
302
|
}
|
|
296
303
|
return slot;
|
|
297
304
|
}
|
|
@@ -307,7 +314,9 @@
|
|
|
307
314
|
}
|
|
308
315
|
// The card pushes the message stream up; scroll to the bottom so the
|
|
309
316
|
// last message stays visible instead of being hidden under it.
|
|
310
|
-
|
|
317
|
+
const active = Clacky.ext.context.sessionId;
|
|
318
|
+
const s = active ? sessionStates.get(active) : null;
|
|
319
|
+
if (!state.off && s && s.mode !== "hidden") {
|
|
311
320
|
requestAnimationFrame(() => {
|
|
312
321
|
const m = document.getElementById("messages");
|
|
313
322
|
if (m) m.scrollTop = m.scrollHeight;
|
|
@@ -315,35 +324,43 @@
|
|
|
315
324
|
}
|
|
316
325
|
}
|
|
317
326
|
|
|
318
|
-
Clacky.ext.subscribe("ext.advisor.pending", () => {
|
|
319
|
-
|
|
320
|
-
state.
|
|
321
|
-
|
|
327
|
+
Clacky.ext.subscribe("ext.advisor.pending", (payload) => {
|
|
328
|
+
const sid = payload && payload.sessionId;
|
|
329
|
+
if (!sid || state.off) return;
|
|
330
|
+
const s = stateFor(sid);
|
|
331
|
+
s.roundDismissed = false;
|
|
332
|
+
s.mode = "pending";
|
|
333
|
+
if (sid === Clacky.ext.context.sessionId) redraw();
|
|
322
334
|
});
|
|
323
335
|
Clacky.ext.subscribe("ext.advisor.recommendations", (payload) => {
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
336
|
+
const sid = payload && payload.sessionId;
|
|
337
|
+
if (!sid || state.off) return;
|
|
338
|
+
const s = stateFor(sid);
|
|
339
|
+
if (s.roundDismissed) return;
|
|
340
|
+
const opts = normalizeOptions(payload && payload.options);
|
|
341
|
+
if (opts.length === 0) return;
|
|
342
|
+
s.mode = "options";
|
|
343
|
+
s.options = opts;
|
|
344
|
+
if (sid === Clacky.ext.context.sessionId) redraw();
|
|
331
345
|
});
|
|
332
346
|
Clacky.ext.subscribe("ext.advisor.done", (payload) => {
|
|
333
|
-
|
|
347
|
+
const sid = payload && payload.sessionId;
|
|
348
|
+
if (!sid || state.off) return;
|
|
349
|
+
const s = stateFor(sid);
|
|
350
|
+
if (s.roundDismissed) return;
|
|
334
351
|
const reason = payload && payload.reason;
|
|
335
352
|
if (reason === "error" || reason === "empty") {
|
|
336
|
-
|
|
337
|
-
|
|
353
|
+
s.mode = "error";
|
|
354
|
+
s.errorMessage = (reason === "error" && payload && payload.message)
|
|
338
355
|
? t("advisor.error", "建议生成失败") + ":" + payload.message
|
|
339
356
|
: t("advisor.empty", "未能生成建议,请稍后重试");
|
|
340
357
|
} else {
|
|
341
|
-
|
|
358
|
+
s.mode = "hidden";
|
|
342
359
|
}
|
|
343
|
-
redraw();
|
|
360
|
+
if (sid === Clacky.ext.context.sessionId) redraw();
|
|
344
361
|
});
|
|
345
362
|
|
|
346
|
-
Clacky.ext.ui.mount("session.composer", () => renderContent(), { order: 200 });
|
|
363
|
+
Clacky.ext.ui.mount("session.composer", (container, ctx) => renderContent(ctx), { order: 200 });
|
|
347
364
|
|
|
348
365
|
// ── Settings tab: master on/off switch ─────────────────────────────────
|
|
349
366
|
|
|
@@ -419,7 +436,7 @@
|
|
|
419
436
|
.then((d) => {
|
|
420
437
|
checkbox.checked = !!d.enabled;
|
|
421
438
|
// Reflect the change on the open composer card immediately.
|
|
422
|
-
state.
|
|
439
|
+
state.off = !d.enabled;
|
|
423
440
|
redraw();
|
|
424
441
|
})
|
|
425
442
|
.catch((err) => {
|
|
@@ -189,6 +189,33 @@ class ExtStudioExt < Clacky::ApiExtension
|
|
|
189
189
|
json(ok: true, ext_id: ext_id)
|
|
190
190
|
end
|
|
191
191
|
|
|
192
|
+
# POST /api/ext/ext-studio/set_disabled
|
|
193
|
+
# body: { ext_id, disabled }
|
|
194
|
+
# Disables/enables a local extension. A disabled extension stays discoverable
|
|
195
|
+
# but stops contributing units (panels, api, skills, agents, ...) until it is
|
|
196
|
+
# re-enabled.
|
|
197
|
+
post "/set_disabled" do
|
|
198
|
+
ext_id = require_ext_id!
|
|
199
|
+
disabled = json_body["disabled"] == true
|
|
200
|
+
|
|
201
|
+
result = Clacky::ExtensionLoader.load_all(force: false)
|
|
202
|
+
container = Array(result.containers).find { |id, _| id == ext_id }&.last
|
|
203
|
+
error!("extension not found: #{ext_id}", status: 404) unless container
|
|
204
|
+
error!("not a local extension", status: 422) unless container[:layer] == :local
|
|
205
|
+
|
|
206
|
+
if disabled
|
|
207
|
+
Clacky::ExtensionLoader.disable!(ext_id)
|
|
208
|
+
else
|
|
209
|
+
Clacky::ExtensionLoader.enable!(ext_id)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Reload the API registry so a disabled extension's routes unmount (and an
|
|
213
|
+
# enabled one remounts) without restarting the server.
|
|
214
|
+
Clacky::ApiExtensionLoader.load_all
|
|
215
|
+
|
|
216
|
+
json(ok: true, ext_id: ext_id, disabled: disabled)
|
|
217
|
+
end
|
|
218
|
+
|
|
192
219
|
# POST /api/ext/ext-studio/unpublish
|
|
193
220
|
# body: { ext_id }
|
|
194
221
|
post "/unpublish" do
|
|
@@ -317,6 +344,9 @@ class ExtStudioExt < Clacky::ApiExtension
|
|
|
317
344
|
ext_id = require_ext_id!
|
|
318
345
|
version = presence(json_body["version"])
|
|
319
346
|
error!("version required", status: 422) unless version
|
|
347
|
+
unless Clacky::ExtensionVerifier.valid_version?(version)
|
|
348
|
+
error!("version must use three numeric segments (for example, 1.0.0)", status: 422)
|
|
349
|
+
end
|
|
320
350
|
|
|
321
351
|
result = Clacky::ExtensionLoader.load_all(force: false)
|
|
322
352
|
container = Array(result.containers).find { |id, _| id == ext_id }&.last
|
|
@@ -470,6 +500,7 @@ class ExtStudioExt < Clacky::ApiExtension
|
|
|
470
500
|
version: raw["version"],
|
|
471
501
|
origin: container[:origin],
|
|
472
502
|
layer: container[:layer].to_s,
|
|
503
|
+
disabled: container[:disabled] == true,
|
|
473
504
|
dir: dir,
|
|
474
505
|
mtime: File.mtime(File.join(dir, "ext.yml")).to_i,
|
|
475
506
|
units: ext_units.map { |u| serialize_unit(u) },
|
|
@@ -144,7 +144,10 @@
|
|
|
144
144
|
"extlist.btn.packing": "Packing…",
|
|
145
145
|
"extlist.btn.unpublish": "Unpublish",
|
|
146
146
|
"extlist.btn.iterate": "Iterate",
|
|
147
|
+
"extlist.btn.disable": "Disable",
|
|
148
|
+
"extlist.btn.enable": "Enable",
|
|
147
149
|
"extlist.btn.delete": "Delete",
|
|
150
|
+
"extlist.badge.disabled": "Disabled",
|
|
148
151
|
"extlist.delete.confirm": "Delete local extension \"{{id}}\"? This cannot be undone.",
|
|
149
152
|
"extlist.iterate.seed": "Iterate on extension {{id}}",
|
|
150
153
|
"extlist.changelog.prompt": "Changelog (optional):",
|
|
@@ -339,7 +342,10 @@
|
|
|
339
342
|
"extlist.btn.packing": "打包中…",
|
|
340
343
|
"extlist.btn.unpublish": "下架",
|
|
341
344
|
"extlist.btn.iterate": "迭代",
|
|
345
|
+
"extlist.btn.disable": "禁止",
|
|
346
|
+
"extlist.btn.enable": "启用",
|
|
342
347
|
"extlist.btn.delete": "删除",
|
|
348
|
+
"extlist.badge.disabled": "已禁用",
|
|
343
349
|
"extlist.delete.confirm": "确定要删除本地扩展「{{id}}」吗?此操作不可恢复。",
|
|
344
350
|
"extlist.iterate.seed": "迭代扩展 {{id}}",
|
|
345
351
|
"extlist.changelog.prompt": "更新说明(可选):",
|
|
@@ -621,8 +627,12 @@
|
|
|
621
627
|
}
|
|
622
628
|
}
|
|
623
629
|
|
|
630
|
+
function normalizeVersion(v) {
|
|
631
|
+
return String(v || "").replace(/[^0-9.]/g, "").split(".").slice(0, 3).join(".");
|
|
632
|
+
}
|
|
633
|
+
|
|
624
634
|
function isSemver(v) {
|
|
625
|
-
return
|
|
635
|
+
return /^[0-9]+\.[0-9]+\.[0-9]+$/.test(v);
|
|
626
636
|
}
|
|
627
637
|
|
|
628
638
|
// Unified publish flow: one modal that walks the creator from a release form
|
|
@@ -636,23 +646,14 @@
|
|
|
636
646
|
const titleNew = isBrand ? t("pub.brand.title.new") : t("pub.title.new");
|
|
637
647
|
const titleUpdate = isBrand ? t("pub.brand.title.update") : t("pub.title.update");
|
|
638
648
|
const introText = isBrand ? t("pub.brand.intro", { name: ext.name }) : t("pub.intro", { name: ext.name });
|
|
639
|
-
let currentVersion = ext.version
|
|
649
|
+
let currentVersion = normalizeVersion(ext.version);
|
|
640
650
|
let isUpdate = false;
|
|
641
651
|
|
|
642
652
|
const verField = el("div", { class: "studio-field" });
|
|
643
653
|
verField.appendChild(el("label", { class: "studio-label", text: t("pub.version.label") }));
|
|
644
|
-
const verInput = el("input", {
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
const valid = isSemver(currentVersion);
|
|
648
|
-
verInput.classList.toggle("studio-input-error", !!currentVersion && !valid);
|
|
649
|
-
publishBtn.disabled = !valid;
|
|
650
|
-
publishBtn.textContent = valid ? t("pub.btn.publish", { ver: currentVersion }) : t("pub.btn.publish", { ver: currentVersion || "?" });
|
|
651
|
-
if (currentVersion && !valid) {
|
|
652
|
-
setProgress(t("err.invalid_version"), true);
|
|
653
|
-
} else {
|
|
654
|
-
status.style.display = "none";
|
|
655
|
-
}
|
|
654
|
+
const verInput = el("input", {
|
|
655
|
+
class: "studio-input", type: "text", value: currentVersion,
|
|
656
|
+
placeholder: "1.0.0", inputmode: "decimal", autocomplete: "off", spellcheck: "false",
|
|
656
657
|
});
|
|
657
658
|
verField.appendChild(verInput);
|
|
658
659
|
|
|
@@ -688,7 +689,8 @@
|
|
|
688
689
|
|
|
689
690
|
const publishBtn = modal.footer.querySelector(".btn-primary");
|
|
690
691
|
const cancelBtn = modal.footer.querySelector(".btn-secondary");
|
|
691
|
-
|
|
692
|
+
verInput.addEventListener("input", () => syncVersionState({ normalize: true }));
|
|
693
|
+
syncVersionState();
|
|
692
694
|
|
|
693
695
|
Promise.resolve(prevVersionOrPromise).then((prevVersion) => {
|
|
694
696
|
if (done || !prevVersion) return;
|
|
@@ -696,10 +698,8 @@
|
|
|
696
698
|
const titleEl = modal.overlay.querySelector(".modal-title");
|
|
697
699
|
if (titleEl) titleEl.textContent = titleUpdate;
|
|
698
700
|
if (prevVersion !== currentVersion) {
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
publishBtn.disabled = false;
|
|
702
|
-
publishBtn.textContent = t("pub.btn.publish", { ver: currentVersion });
|
|
701
|
+
verInput.value = normalizeVersion(prevVersion);
|
|
702
|
+
syncVersionState();
|
|
703
703
|
}
|
|
704
704
|
});
|
|
705
705
|
|
|
@@ -709,9 +709,34 @@
|
|
|
709
709
|
status.className = "studio-modal-status" + (isError ? " studio-modal-status-error" : "");
|
|
710
710
|
}
|
|
711
711
|
|
|
712
|
+
function syncVersionState({ normalize = false, updateFeedback = true } = {}) {
|
|
713
|
+
if (normalize) {
|
|
714
|
+
const raw = verInput.value;
|
|
715
|
+
const cursor = verInput.selectionStart;
|
|
716
|
+
const normalized = normalizeVersion(raw);
|
|
717
|
+
if (normalized !== raw) {
|
|
718
|
+
verInput.value = normalized;
|
|
719
|
+
if (cursor != null) {
|
|
720
|
+
const normalizedCursor = normalizeVersion(raw.slice(0, cursor)).length;
|
|
721
|
+
verInput.setSelectionRange(normalizedCursor, normalizedCursor);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
currentVersion = verInput.value.trim();
|
|
727
|
+
const valid = isSemver(currentVersion);
|
|
728
|
+
publishBtn.disabled = !valid;
|
|
729
|
+
publishBtn.textContent = t("pub.btn.publish", { ver: valid ? currentVersion : (currentVersion || "?") });
|
|
730
|
+
|
|
731
|
+
if (updateFeedback) {
|
|
732
|
+
verInput.classList.toggle("studio-input-error", !!currentVersion && !valid);
|
|
733
|
+
if (currentVersion && !valid) setProgress(t("err.invalid_version"), true);
|
|
734
|
+
else status.style.display = "none";
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
712
738
|
function resetButtons() {
|
|
713
|
-
|
|
714
|
-
publishBtn.textContent = currentVersion ? t("pub.btn.publish", { ver: currentVersion }) : t("pub.btn.publish", { ver: "?" });
|
|
739
|
+
syncVersionState({ updateFeedback: false });
|
|
715
740
|
cancelBtn.disabled = false;
|
|
716
741
|
notes.disabled = false;
|
|
717
742
|
verInput.disabled = false;
|
|
@@ -1582,9 +1607,15 @@
|
|
|
1582
1607
|
const head = el("div", { class: "studio-skill-head" });
|
|
1583
1608
|
head.appendChild(el("span", { class: "studio-skill-name", text: `${ext.name} (${ext.id})` }));
|
|
1584
1609
|
head.appendChild(badge(published ? t("extlist.badge.published") : t("extlist.badge.local"), published ? "published" : "local"));
|
|
1585
|
-
if (ext.
|
|
1586
|
-
|
|
1587
|
-
else
|
|
1610
|
+
if (ext.disabled) {
|
|
1611
|
+
head.appendChild(badge(t("extlist.badge.disabled"), "changed"));
|
|
1612
|
+
} else if (ext.error_count) {
|
|
1613
|
+
head.appendChild(badge("✕ " + t("extlist.verify.errors", { n: ext.error_count }), "changed"));
|
|
1614
|
+
} else if (ext.warning_count) {
|
|
1615
|
+
head.appendChild(badge("● " + t("extlist.verify.warnings", { n: ext.warning_count }), "changed"));
|
|
1616
|
+
} else {
|
|
1617
|
+
head.appendChild(badge("✓ " + t("extlist.verify.ok"), "published"));
|
|
1618
|
+
}
|
|
1588
1619
|
card.appendChild(head);
|
|
1589
1620
|
if (ext.description) card.appendChild(el("p", { class: "studio-skill-desc", text: ext.description }));
|
|
1590
1621
|
|
|
@@ -1616,6 +1647,13 @@
|
|
|
1616
1647
|
const packBtn = el("button", { class: "studio-btn studio-btn-ghost", text: t("extlist.btn.pack") });
|
|
1617
1648
|
packBtn.addEventListener("click", () => doPack(ext, packBtn));
|
|
1618
1649
|
actions.appendChild(packBtn);
|
|
1650
|
+
|
|
1651
|
+
const toggleBtn = el("button", {
|
|
1652
|
+
class: "studio-btn studio-btn-ghost",
|
|
1653
|
+
text: ext.disabled ? t("extlist.btn.enable") : t("extlist.btn.disable"),
|
|
1654
|
+
});
|
|
1655
|
+
toggleBtn.addEventListener("click", () => doToggleDisabled(ext, toggleBtn));
|
|
1656
|
+
actions.appendChild(toggleBtn);
|
|
1619
1657
|
card.appendChild(actions);
|
|
1620
1658
|
|
|
1621
1659
|
if (!published) {
|
|
@@ -1638,6 +1676,18 @@
|
|
|
1638
1676
|
}
|
|
1639
1677
|
}
|
|
1640
1678
|
|
|
1679
|
+
async function doToggleDisabled(ext, btn) {
|
|
1680
|
+
const next = !ext.disabled;
|
|
1681
|
+
btn.disabled = true;
|
|
1682
|
+
try {
|
|
1683
|
+
await postJson("/set_disabled", { ext_id: ext.id, disabled: next });
|
|
1684
|
+
location.reload();
|
|
1685
|
+
} catch (e) {
|
|
1686
|
+
btn.disabled = false;
|
|
1687
|
+
alert(t("err.generic", { msg: e.message }));
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1641
1691
|
async function doDelete(ext, btn) {
|
|
1642
1692
|
if (!await Clacky.Modal.confirm(t("extlist.delete.confirm", { id: ext.id }))) return;
|
|
1643
1693
|
btn.disabled = true;
|
|
@@ -104,9 +104,9 @@ Clacky.ext.api.register(name, fn) // expose a named data source; api.res
|
|
|
104
104
|
- gets a host-owned `container` DOM element — append into it, **or** return a Node / HTML
|
|
105
105
|
string and the host appends for you;
|
|
106
106
|
- returning a **function** registers it as a teardown callback;
|
|
107
|
-
- returning **`null`/`undefined` renders nothing** —
|
|
108
|
-
|
|
109
|
-
|
|
107
|
+
- returning **`null`/`undefined` renders nothing** (safe) — a red "crashed" placeholder
|
|
108
|
+
means the render **threw**; the #1 cause is a wrong signature shifting every argument
|
|
109
|
+
(e.g. `(ctx) => …` instead of `(container, ctx) => …`). Match the signature exactly.
|
|
110
110
|
|
|
111
111
|
`ctx` carries `{ sessionId, agentProfile }`. `opts`: `order` (lower renders first,
|
|
112
112
|
default 100), `tab: { id, label, badge? }` (**required** for tabbed slots — `session.aside`
|
|
@@ -289,7 +289,7 @@ Rules:
|
|
|
289
289
|
|
|
290
290
|
## Scaffold
|
|
291
291
|
|
|
292
|
-
Turn a plain-language idea into a working skeleton
|
|
292
|
+
Turn a plain-language idea into a working skeleton.
|
|
293
293
|
|
|
294
294
|
### 1 — Understand the idea
|
|
295
295
|
|
|
@@ -333,20 +333,10 @@ This creates `~/.clacky/ext/local/<id>/` with a working hello panel + handler:
|
|
|
333
333
|
Use `--full` only when the user needs the kitchen-sink reference exercising all eight
|
|
334
334
|
contributes types — it's a lot to read, so prefer the plain scaffold otherwise.
|
|
335
335
|
|
|
336
|
-
### 3 —
|
|
336
|
+
### 3 — Reshape to the idea
|
|
337
337
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
### 4 — Reshape to the idea
|
|
342
|
-
|
|
343
|
-
Before editing, re-read the contract for whatever the idea needs (panel / API / patch /
|
|
344
|
-
hook) in **Reference: the contracts** above — don't guess field names, slot names, hook
|
|
345
|
-
events, or the `Clacky.ext` surface.
|
|
346
|
-
|
|
347
|
-
The scaffold ships a working "hello" panel that pings its backend. Turn it into the real
|
|
348
|
-
feature by editing those three files. Below is a concrete before → after for a tiny
|
|
349
|
-
"add a note" panel — use it as the shape to copy, not the literal content.
|
|
338
|
+
Below is a concrete before → after for a tiny "add a note" panel — use it as
|
|
339
|
+
the shape to copy, not the literal content.
|
|
350
340
|
|
|
351
341
|
**`ext.yml`** — rename the panel id/view to the feature; add `skills:`/`agents:` only if needed:
|
|
352
342
|
|
|
@@ -385,8 +375,7 @@ Clacky.ext.ui.mount("session.aside", function (container, ctx) {
|
|
|
385
375
|
```
|
|
386
376
|
|
|
387
377
|
**`api/handler.rb`** — stay a `Clacky::ApiExtension` subclass; add the route the panel
|
|
388
|
-
calls. Persist user data with `data_path
|
|
389
|
-
reloads, `gem update`, and uninstall/reinstall), never into the code dir:
|
|
378
|
+
calls. Persist user data with `data_path`, never into the code dir:
|
|
390
379
|
|
|
391
380
|
```ruby
|
|
392
381
|
class <Prefix>Ext < Clacky::ApiExtension
|
|
@@ -403,21 +392,11 @@ Rules while reshaping:
|
|
|
403
392
|
are the #1 cause of a `loader.error`.
|
|
404
393
|
- Routes in `handler.rb` are **relative** to `/api/ext/<id>/`; the `view.js` `fetch` must
|
|
405
394
|
match. A mismatch is a silent 404, not a verify error.
|
|
406
|
-
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
- **UI styling: default to host classes; anything the host has no class for,
|
|
412
|
-
build freely.** The host covers `btn-*` buttons, `form-*` inputs, `modal-*`
|
|
413
|
-
dialogs, `Clacky.Modal.toast/confirm` feedback, and `var(--color-*)` colors
|
|
414
|
-
(raw hex breaks the dark theme). For exact definitions grep the host
|
|
415
|
-
stylesheet `lib/clacky/web/app.css`; the scaffold templates show real usage.
|
|
416
|
-
- A skill is a `SKILL.md` under `skills/<id>/`; an agent is a `system_prompt.md` that can
|
|
417
|
-
reference `panels: [id]` and `skills: [id]`. Add those blocks to `ext.yml` only if the
|
|
418
|
-
idea needs them.
|
|
419
|
-
|
|
420
|
-
### 5 — Write tests
|
|
395
|
+
- **UI styling: default to host classes; anything the host has no class for, build
|
|
396
|
+
freely.** Colors: only `var(--color-*)` — raw hex breaks the dark theme. For the
|
|
397
|
+
full class/variable list grep the host stylesheet `lib/clacky/web/app.css`.
|
|
398
|
+
|
|
399
|
+
### 4 — Write tests
|
|
421
400
|
|
|
422
401
|
Test the backend before you call it done. Rewrite the scaffolded `test/handler_test.rb`
|
|
423
402
|
to exercise your real routes (parse the JSON response, assert its fields), then run it
|
|
@@ -432,7 +411,7 @@ ruby test/handler_test.rb
|
|
|
432
411
|
- A handler that only returns a static string may keep the scaffold's hello test;
|
|
433
412
|
anything that fetches, transforms, caches, or computes MUST have a real test.
|
|
434
413
|
|
|
435
|
-
###
|
|
414
|
+
### 5 — Confirm it loads
|
|
436
415
|
|
|
437
416
|
Run `clacky ext verify` and confirm the new units resolve with no errors, then have the
|
|
438
417
|
user reload the WebUI page. If verify reports problems, go to **Debug & verify**.
|
|
@@ -459,7 +438,7 @@ is structured with a `code`, `message`, the offending `file`, and a `hint`.
|
|
|
459
438
|
|
|
460
439
|
| Symptom | Almost always | Fix |
|
|
461
440
|
|---|---|---|
|
|
462
|
-
| Red error box where the panel should be | `ui.mount` render
|
|
441
|
+
| Red error box where the panel should be | `ui.mount` render threw — usually a wrong signature | signature is `(container, ctx, runtime)` — not `(ctx)` |
|
|
463
442
|
| Panel doesn't appear at all | `slot` name typo (silent) or no `attach:` | use a valid slot; set `attach: ["*"]` or an agent id |
|
|
464
443
|
| Frontend `fetch` gets 404 | route in `handler.rb` ≠ path in `view.js` fetch | routes are relative to `/api/ext/<id>/` |
|
|
465
444
|
| `loader.error` on verify | `ext.yml` `view:` path ≠ the on-disk `view.js` path | make the two match exactly |
|
|
@@ -530,7 +509,7 @@ If verify is clean but a change isn't visible:
|
|
|
530
509
|
console for a "unknown slot" warning); (2) check the panel's `attach:` (or the agent
|
|
531
510
|
that references it via `panels: [id]`) — a panel with no `attach` and no referencing
|
|
532
511
|
agent has nothing to mount onto; (3) a red error box means the render function threw
|
|
533
|
-
|
|
512
|
+
(usually a wrong signature) — open the console for the stack.
|
|
534
513
|
- **API 404?** Routes are relative to `/api/ext/<ext_id>/`. Confirm the handler subclasses
|
|
535
514
|
`Clacky::ApiExtension` and the route pattern matches what `view.js` fetches.
|
|
536
515
|
- **Skill not triggering?** The AI selects skills by their `description`. Make the
|