openclacky 1.3.3 → 1.3.5

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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -0
  3. data/docs/rich_ui_guide.md +277 -0
  4. data/docs/rich_ui_refactor_plan.md +396 -0
  5. data/lib/clacky/agent/fake_tool_call_detector.rb +52 -0
  6. data/lib/clacky/agent/llm_caller.rb +10 -4
  7. data/lib/clacky/agent/session_serializer.rb +5 -3
  8. data/lib/clacky/agent/tool_executor.rb +0 -12
  9. data/lib/clacky/agent.rb +77 -11
  10. data/lib/clacky/agent_config.rb +2 -14
  11. data/lib/clacky/api_extension.rb +343 -0
  12. data/lib/clacky/api_extension_loader.rb +168 -0
  13. data/lib/clacky/cli.rb +93 -3
  14. data/lib/clacky/client.rb +47 -25
  15. data/lib/clacky/default_agents/_panels/git/panel.js +1 -1
  16. data/lib/clacky/default_agents/_panels/time_machine/panel.js +23 -1
  17. data/lib/clacky/default_agents/base_prompt.md +1 -0
  18. data/lib/clacky/default_extensions/meeting/handler.rb +331 -0
  19. data/lib/clacky/default_extensions/meeting/meeting.js +790 -0
  20. data/lib/clacky/default_extensions/meeting/meta.yml +3 -0
  21. data/lib/clacky/default_extensions/meeting/skills/meeting-summarizer/SKILL.md +44 -0
  22. data/lib/clacky/default_skills/media-gen/SKILL.md +72 -6
  23. data/lib/clacky/default_skills/media-gen/scripts/video_seq.sh +114 -0
  24. data/lib/clacky/idle_compression_timer.rb +3 -1
  25. data/lib/clacky/json_ui_controller.rb +1 -1
  26. data/lib/clacky/locales/en.rb +26 -0
  27. data/lib/clacky/locales/i18n.rb +26 -0
  28. data/lib/clacky/locales/zh.rb +26 -0
  29. data/lib/clacky/media/base.rb +60 -0
  30. data/lib/clacky/media/dashscope.rb +385 -21
  31. data/lib/clacky/media/gemini.rb +9 -0
  32. data/lib/clacky/media/generator.rb +52 -0
  33. data/lib/clacky/media/openai_compat.rb +166 -0
  34. data/lib/clacky/null_ui_controller.rb +13 -0
  35. data/lib/clacky/plain_ui_controller.rb +1 -1
  36. data/lib/clacky/providers.rb +50 -2
  37. data/lib/clacky/rich_ui/components/base_component.rb +50 -0
  38. data/lib/clacky/rich_ui/components/dialogs/approval_dialog.rb +142 -0
  39. data/lib/clacky/rich_ui/components/dialogs/config_menu_dialog.rb +106 -0
  40. data/lib/clacky/rich_ui/components/dialogs/form_dialog.rb +128 -0
  41. data/lib/clacky/rich_ui/components/sidebar.rb +119 -0
  42. data/lib/clacky/rich_ui/components/sidebar_panels.rb +134 -0
  43. data/lib/clacky/rich_ui/components/status_view.rb +58 -0
  44. data/lib/clacky/rich_ui/components/thinking_live_view.rb +79 -0
  45. data/lib/clacky/rich_ui/entry_tracker.rb +56 -0
  46. data/lib/clacky/rich_ui/layout_adapter.rb +16 -0
  47. data/lib/clacky/rich_ui/progress_handle_adapter.rb +24 -0
  48. data/lib/clacky/rich_ui/rich_ui_controller.rb +868 -0
  49. data/lib/clacky/rich_ui/shell/rich_agent_shell.rb +184 -0
  50. data/lib/clacky/rich_ui/view_renderer.rb +291 -0
  51. data/lib/clacky/rich_ui.rb +57 -0
  52. data/lib/clacky/rich_ui_controller.rb +3 -1549
  53. data/lib/clacky/server/api_extension_dispatcher.rb +120 -0
  54. data/lib/clacky/server/channel/channel_ui_controller.rb +1 -1
  55. data/lib/clacky/server/http_server.rb +291 -109
  56. data/lib/clacky/server/session_registry.rb +5 -3
  57. data/lib/clacky/server/web_ui_controller.rb +3 -2
  58. data/lib/clacky/shell_hook_loader.rb +1 -1
  59. data/lib/clacky/skill_loader.rb +14 -2
  60. data/lib/clacky/tools/edit.rb +14 -2
  61. data/lib/clacky/tools/terminal/output_cleaner.rb +1 -3
  62. data/lib/clacky/tools/terminal.rb +0 -43
  63. data/lib/clacky/ui2/components/modal_component.rb +1 -1
  64. data/lib/clacky/ui2/ui_controller.rb +147 -31
  65. data/lib/clacky/ui_interface.rb +10 -1
  66. data/lib/clacky/utils/encoding.rb +25 -0
  67. data/lib/clacky/version.rb +1 -1
  68. data/lib/clacky/web/app.css +201 -81
  69. data/lib/clacky/web/app.js +65 -7
  70. data/lib/clacky/web/components/onboard.js +19 -16
  71. data/lib/clacky/web/core/aside.js +8 -3
  72. data/lib/clacky/web/core/ext.js +1 -1
  73. data/lib/clacky/web/features/brand/view.js +8 -5
  74. data/lib/clacky/web/features/channels/store.js +1 -20
  75. data/lib/clacky/web/features/mcp/store.js +1 -20
  76. data/lib/clacky/web/features/profile/store.js +1 -13
  77. data/lib/clacky/web/features/profile/view.js +16 -4
  78. data/lib/clacky/web/features/skills/store.js +36 -23
  79. data/lib/clacky/web/features/skills/view.js +32 -1
  80. data/lib/clacky/web/features/version/store.js +2 -0
  81. data/lib/clacky/web/features/workspace/view.js +1 -1
  82. data/lib/clacky/web/i18n.js +48 -13
  83. data/lib/clacky/web/index.html +24 -17
  84. data/lib/clacky/web/sessions.js +427 -79
  85. data/lib/clacky/web/settings.js +143 -113
  86. data/lib/clacky/web/ws-dispatcher.js +18 -6
  87. data/lib/clacky.rb +27 -5
  88. metadata +45 -2
  89. data/lib/clacky/media/output_dir.rb +0 -43
@@ -17,7 +17,6 @@ const Settings = (() => {
17
17
  function open() {
18
18
  _load();
19
19
  _loadMedia();
20
- _initMediaOutputDir();
21
20
  _loadBrand();
22
21
  _loadBrowserStatus();
23
22
  _initNetworkSettings();
@@ -118,6 +117,10 @@ const Settings = (() => {
118
117
  <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>
119
118
  <span>${I18n.t("settings.models.btn.edit")}</span>
120
119
  </button>
120
+ <button class="btn-card-grid-action" data-index="${index}" data-action="duplicate">
121
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
122
+ <span>${I18n.t("settings.models.btn.duplicate")}</span>
123
+ </button>
121
124
  ${_models.length > 1 ? `<button class="btn-card-grid-action btn-card-grid-action-danger" data-index="${index}" data-action="delete">
122
125
  <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>
123
126
  <span>${I18n.t("settings.models.btn.delete")}</span>
@@ -141,10 +144,11 @@ const Settings = (() => {
141
144
  btn.addEventListener("click", () => {
142
145
  const action = btn.dataset.action;
143
146
  switch (action) {
144
- case "edit": _openModal(index); break;
145
- case "test": _testModel(index); break;
146
- case "delete": _removeModel(index); break;
147
- case "default": _setAsDefault(index); break;
147
+ case "edit": _openModal(index); break;
148
+ case "test": _testModel(index); break;
149
+ case "delete": _removeModel(index); break;
150
+ case "default": _setAsDefault(index); break;
151
+ case "duplicate": _openModalDuplicate(index); break;
148
152
  }
149
153
  });
150
154
  });
@@ -158,6 +162,7 @@ const Settings = (() => {
158
162
  const indexInput = document.getElementById("model-modal-index");
159
163
 
160
164
  indexInput.value = index;
165
+ document.getElementById("model-modal-source-id").value = "";
161
166
 
162
167
  // Populate provider dropdown
163
168
  _populateModalProviderDropdown();
@@ -233,6 +238,30 @@ const Settings = (() => {
233
238
  document.body.style.overflow = "";
234
239
  }
235
240
 
241
+ function _openModalDuplicate(index) {
242
+ const source = _models[index];
243
+ if (!source) return;
244
+
245
+ _openModal(-1);
246
+
247
+ document.getElementById("model-modal-source-id").value = source.id || "";
248
+ document.getElementById("model-modal-title").textContent = I18n.t("settings.models.modal.duplicate");
249
+ document.getElementById("model-modal-model").value = source.model || "";
250
+ document.getElementById("model-modal-baseurl").value = source.base_url || "";
251
+ document.getElementById("model-modal-apikey").value = source.api_key_masked || "";
252
+
253
+ const matched = _findProviderByBaseUrl(source.base_url);
254
+ _modalSelectedProviderId = matched ? matched.id : (source.anthropic_format ? "anthropic" : null);
255
+ const providerValue = document.getElementById("model-modal-provider-value");
256
+ if (matched) {
257
+ providerValue.textContent = matched.name;
258
+ providerValue.classList.remove("placeholder");
259
+
260
+ const promoHint = document.getElementById("model-modal-promo-hint");
261
+ if (matched.id !== "openclacky") promoHint.classList.remove("visible");
262
+ }
263
+ }
264
+
236
265
  function _populateModalProviderDropdown() {
237
266
  const dropdown = document.getElementById("model-modal-provider-dropdown");
238
267
  dropdown.innerHTML = `
@@ -334,13 +363,14 @@ const Settings = (() => {
334
363
  const isNew = index < 0;
335
364
  const existing = isNew ? {} : (_models[index] || {});
336
365
  const existingId = existing.id || null;
366
+ const sourceId = document.getElementById("model-modal-source-id").value || null;
337
367
 
338
368
  // Step 1: Test first
339
369
  saveBtn.textContent = I18n.t("settings.models.btn.testing");
340
370
  _showModalTestResult(null, "");
341
371
 
342
372
  const result = await ModelTester.testConnection({
343
- model, base_url, api_key, index, id: existingId, anthropic_format
373
+ model, base_url, api_key, index, id: existingId || sourceId, anthropic_format
344
374
  });
345
375
 
346
376
  if (result.rewrote) {
@@ -376,10 +406,14 @@ const Settings = (() => {
376
406
  }
377
407
 
378
408
  if (!hasId && !payload.api_key) {
379
- saveBtn.textContent = I18n.t("settings.models.btn.save");
380
- saveBtn.disabled = false;
381
- _showModalTestResult(false, I18n.t("settings.models.placeholder.apikey"));
382
- return;
409
+ if (sourceId) {
410
+ payload.source_id = sourceId;
411
+ } else {
412
+ saveBtn.textContent = I18n.t("settings.models.btn.save");
413
+ saveBtn.disabled = false;
414
+ _showModalTestResult(false, I18n.t("settings.models.placeholder.apikey"));
415
+ return;
416
+ }
383
417
  }
384
418
 
385
419
  const saveResult = await ModelTester.saveModel(payload, { existingId: hasId ? existingId : null });
@@ -406,6 +440,25 @@ const Settings = (() => {
406
440
  el.className = `model-test-result ${ok ? "result-ok" : "result-fail"}`;
407
441
  }
408
442
 
443
+ function _positionDropdownFixed(dropdown, anchor) {
444
+ const rect = anchor.getBoundingClientRect();
445
+ dropdown.style.position = "fixed";
446
+ dropdown.style.top = (rect.bottom + 4) + "px";
447
+ dropdown.style.left = rect.left + "px";
448
+ dropdown.style.width = rect.width + "px";
449
+ dropdown.style.right = "auto";
450
+ dropdown.style.zIndex = "9999";
451
+ }
452
+
453
+ function _resetDropdownPosition(dropdown) {
454
+ dropdown.style.position = "";
455
+ dropdown.style.top = "";
456
+ dropdown.style.left = "";
457
+ dropdown.style.width = "";
458
+ dropdown.style.right = "";
459
+ dropdown.style.zIndex = "";
460
+ }
461
+
409
462
  function _initModal() {
410
463
  // Close button
411
464
  document.getElementById("model-modal-close").addEventListener("click", _closeModal);
@@ -459,34 +512,58 @@ const Settings = (() => {
459
512
  // Model dropdown functionality
460
513
  const modelDropdownBtn = document.getElementById("model-modal-model-dropdown-btn");
461
514
  const modelDropdown = document.getElementById("model-modal-model-dropdown");
515
+ const modelCombobox = document.getElementById("model-modal-model-combobox");
462
516
  const modelInput = document.getElementById("model-modal-model");
463
517
 
518
+ function _openModelDropdown() {
519
+ _closeBaseUrlDropdown();
520
+ _updateModalModelDropdown();
521
+ _positionDropdownFixed(modelDropdown, modelCombobox);
522
+ modelDropdown.style.display = "block";
523
+ document.body.appendChild(modelDropdown);
524
+ }
525
+
526
+ function _closeModelDropdown() {
527
+ modelDropdown.style.display = "none";
528
+ _resetDropdownPosition(modelDropdown);
529
+ modelCombobox.appendChild(modelDropdown);
530
+ }
531
+
464
532
  modelDropdownBtn.addEventListener("click", (e) => {
465
533
  e.stopPropagation();
466
- const isOpen = modelDropdown.style.display === "block";
467
- document.querySelectorAll(".model-name-dropdown, .base-url-dropdown").forEach(d => {
468
- d.style.display = "none";
469
- });
470
- if (!isOpen) {
471
- _updateModalModelDropdown();
472
- modelDropdown.style.display = "block";
534
+ if (modelDropdown.style.display === "block") {
535
+ _closeModelDropdown();
536
+ } else {
537
+ _openModelDropdown();
473
538
  }
474
539
  });
475
540
 
476
541
  // Base URL dropdown functionality
477
542
  const baseUrlDropdownBtn = document.getElementById("model-modal-baseurl-dropdown-btn");
478
543
  const baseUrlDropdown = document.getElementById("model-modal-baseurl-dropdown");
544
+ const baseUrlCombobox = document.getElementById("model-modal-baseurl-combobox");
479
545
  const baseUrlInput = document.getElementById("model-modal-baseurl");
480
546
 
547
+ function _openBaseUrlDropdown() {
548
+ _closeModelDropdown();
549
+ _updateModalBaseUrlDropdown();
550
+ _positionDropdownFixed(baseUrlDropdown, baseUrlCombobox);
551
+ baseUrlDropdown.style.display = "block";
552
+ document.body.appendChild(baseUrlDropdown);
553
+ }
554
+
555
+ function _closeBaseUrlDropdown() {
556
+ baseUrlDropdown.style.display = "none";
557
+ _resetDropdownPosition(baseUrlDropdown);
558
+ baseUrlCombobox.appendChild(baseUrlDropdown);
559
+ }
560
+
481
561
  baseUrlDropdownBtn.addEventListener("click", (e) => {
482
562
  e.stopPropagation();
483
- const isOpen = baseUrlDropdown.style.display === "block";
484
- document.querySelectorAll(".model-name-dropdown, .base-url-dropdown").forEach(d => {
485
- d.style.display = "none";
486
- });
487
- if (!isOpen) {
488
- _updateModalBaseUrlDropdown();
489
- baseUrlDropdown.style.display = "block";
563
+ if (baseUrlDropdown.style.display === "block") {
564
+ _closeBaseUrlDropdown();
565
+ } else {
566
+ _openBaseUrlDropdown();
490
567
  }
491
568
  });
492
569
 
@@ -495,10 +572,14 @@ const Settings = (() => {
495
572
  _updateModalModelDropdown();
496
573
  });
497
574
 
498
- // Close all modal dropdowns on document click
499
- document.addEventListener("click", () => {
500
- modelDropdown.style.display = "none";
501
- baseUrlDropdown.style.display = "none";
575
+ // Close dropdowns on outside click
576
+ document.addEventListener("mousedown", (e) => {
577
+ if (!modelCombobox.contains(e.target) && !modelDropdown.contains(e.target)) {
578
+ _closeModelDropdown();
579
+ }
580
+ if (!baseUrlCombobox.contains(e.target) && !baseUrlDropdown.contains(e.target)) {
581
+ _closeBaseUrlDropdown();
582
+ }
502
583
  });
503
584
  }
504
585
 
@@ -1272,89 +1353,6 @@ const Settings = (() => {
1272
1353
  }
1273
1354
  }
1274
1355
 
1275
- // ── Media output directory ────────────────────────────────────────────────────
1276
- //
1277
- // Single user-facing override for where /api/media/* writes generated files.
1278
- // Mirrors the proxy_url section above (same field-input + save/clear pair)
1279
- // because the data shape is identical (one optional string). Resolution
1280
- // priority lives server-side in Clacky::Media::OutputDir.resolve:
1281
- // per-call output_dir → media_output_dir (this setting) → default_working_dir → Dir.pwd
1282
-
1283
- async function _initMediaOutputDir() {
1284
- const input = document.getElementById("settings-media-output-dir");
1285
- const browseBtn = document.getElementById("btn-browse-media-output-dir");
1286
- const clearBtn = document.getElementById("btn-clear-media-output-dir");
1287
- const status = document.getElementById("settings-media-output-dir-status");
1288
- if (!input || !browseBtn) return;
1289
-
1290
- try {
1291
- const res = await fetch("/api/config/media-output-dir");
1292
- const data = await res.json();
1293
- if (data.ok) {
1294
- input.value = data.value || "";
1295
- // Show the system fallback as a placeholder hint so the user sees
1296
- // where files would land if they leave the field blank.
1297
- if (data.default) input.placeholder = data.default;
1298
- }
1299
- } catch (_) { /* non-critical */ }
1300
-
1301
- async function _patchMediaOutputDir(value, successKey) {
1302
- status.textContent = "";
1303
- status.className = "model-test-result";
1304
- try {
1305
- const res = await fetch("/api/config/media-output-dir", {
1306
- method: "PATCH",
1307
- headers: { "Content-Type": "application/json" },
1308
- body: JSON.stringify({ value: value })
1309
- });
1310
- const data = await res.json();
1311
- if (data.ok) {
1312
- status.textContent = I18n.t(successKey);
1313
- status.className = "model-test-result success";
1314
- // Auto-hide the toast after a short delay so it doesn't linger
1315
- // forever (looks like a stuck banner). 2s is enough to read.
1316
- clearTimeout(_patchMediaOutputDir._hideTimer);
1317
- _patchMediaOutputDir._hideTimer = setTimeout(() => {
1318
- status.textContent = "";
1319
- status.className = "model-test-result";
1320
- }, 2000);
1321
- // Server may have expanded `~` or normalized the path — reflect
1322
- // the canonical value back into the input so the user sees what
1323
- // was actually persisted.
1324
- input.value = data.value || "";
1325
- } else {
1326
- status.textContent = data.error || I18n.t("settings.media.output_dir.invalid");
1327
- status.className = "model-test-result error";
1328
- }
1329
- } catch (e) {
1330
- status.textContent = e.message || I18n.t("settings.media.output_dir.invalid");
1331
- status.className = "model-test-result error";
1332
- }
1333
- }
1334
-
1335
- if (!browseBtn.dataset.bound) {
1336
- browseBtn.dataset.bound = "1";
1337
- browseBtn.addEventListener("click", async () => {
1338
- // Reuse the global directory picker in session-less mode (browses
1339
- // the real filesystem via /api/dirs). Picker resolves to an absolute
1340
- // path on confirm, or null on cancel.
1341
- const start = (input.value || "").trim();
1342
- const picked = await window.openDirectoryPicker(start, null, I18n.t("settings.media.output_dir.picker"));
1343
- if (!picked) return;
1344
- // Persist immediately — no separate Save click needed.
1345
- await _patchMediaOutputDir(picked, "settings.media.output_dir.saved");
1346
- });
1347
- }
1348
-
1349
- if (clearBtn && !clearBtn.dataset.bound) {
1350
- clearBtn.dataset.bound = "1";
1351
- clearBtn.addEventListener("click", () => {
1352
- input.value = "";
1353
- _patchMediaOutputDir("", "settings.media.output_dir.cleared");
1354
- });
1355
- }
1356
- }
1357
-
1358
1356
  // ── Brand & License ───────────────────────────────────────────────────────────
1359
1357
 
1360
1358
  // Whether the server was started with --brand-test (relaxed key validation).
@@ -1510,6 +1508,7 @@ const Settings = (() => {
1510
1508
  _showBrandResult(true, I18n.t("settings.brand.activated", { name: data.product_name || "configured" }));
1511
1509
  // Apply brand name and logo across the entire UI immediately
1512
1510
  if (data.product_name) Brand.applyBrandName(data.product_name);
1511
+ if (data.theme_color) _applyAccentColor(data.theme_color, { persist: true });
1513
1512
  Brand.clearBrandCache();
1514
1513
  Brand.applyHeaderLogo();
1515
1514
  // Refresh brand status flags (user_licensed may have flipped from false
@@ -1685,7 +1684,7 @@ const Settings = (() => {
1685
1684
  // The state object per kind:
1686
1685
  // { source, configured, model, base_url, api_key_masked, provider, available }
1687
1686
 
1688
- const MEDIA_KINDS = ["image", "video", "audio", "ocr"];
1687
+ const MEDIA_KINDS = ["image", "video", "audio", "stt", "video_understanding", "ocr"];
1689
1688
  let _mediaState = null;
1690
1689
  let _mediaDefaults = null;
1691
1690
  const _mediaCustomDraft = {};
@@ -2175,6 +2174,8 @@ const Settings = (() => {
2175
2174
  Brand.applyBrandName("OpenClacky");
2176
2175
  Brand.clearBrandCache();
2177
2176
  Brand.applyHeaderLogo();
2177
+ const userAccent = (() => { try { return localStorage.getItem("clacky-accent-color"); } catch (_) { return null; } })();
2178
+ _applyAccentColor(userAccent || ACCENT_DEFAULT, { persist: false });
2178
2179
  // Reset Skills panel state (hide Brand Skills tab, switch to My Skills)
2179
2180
  if (typeof Skills !== "undefined" && Skills.resetAfterUnbind) {
2180
2181
  Skills.resetAfterUnbind();
@@ -2205,6 +2206,7 @@ const Settings = (() => {
2205
2206
  _initLangBtns();
2206
2207
  _initFontBtns();
2207
2208
  _initCurrencyBtns();
2209
+ _initAccentColorBtns();
2208
2210
 
2209
2211
  // Re-render model cards when language changes (dynamic HTML, not data-i18n)
2210
2212
  document.addEventListener("langchange", () => {
@@ -2366,6 +2368,34 @@ const Settings = (() => {
2366
2368
  });
2367
2369
  }
2368
2370
 
2371
+ // ── Accent Color ──────────────────────────────────────────────────────
2372
+ const ACCENT_STORAGE_KEY = "clacky-accent-color";
2373
+ const ACCENT_DEFAULT = "#4f46e5";
2374
+
2375
+ function _applyAccentColor(color, { persist = true } = {}) {
2376
+ const root = document.documentElement;
2377
+ root.style.setProperty("--color-accent-primary", color);
2378
+ root.style.setProperty("--color-accent-hover", color);
2379
+ root.style.setProperty("--color-button-primary", color);
2380
+ root.style.setProperty("--color-button-primary-hover", `color-mix(in srgb, ${color} 82%, #000)`);
2381
+ if (persist) {
2382
+ try { localStorage.setItem(ACCENT_STORAGE_KEY, color); } catch (_) {}
2383
+ }
2384
+ document.querySelectorAll("#accent-color-section .settings-accent-swatch").forEach(btn => {
2385
+ btn.classList.toggle("active", btn.dataset.color === color);
2386
+ });
2387
+ }
2388
+
2389
+ function _initAccentColorBtns() {
2390
+ let saved = null;
2391
+ try { saved = localStorage.getItem(ACCENT_STORAGE_KEY); } catch (_) {}
2392
+ _applyAccentColor(saved || ACCENT_DEFAULT, { persist: !!saved });
2393
+
2394
+ document.querySelectorAll("#accent-color-section .settings-accent-swatch").forEach(btn => {
2395
+ btn.addEventListener("click", () => _applyAccentColor(btn.dataset.color));
2396
+ });
2397
+ }
2398
+
2369
2399
  // ── QR Code Lightbox ───────────────────────────────────────────────────
2370
2400
  // Sets up click-to-enlarge behaviour for the support QR code.
2371
2401
  // Safe to call multiple times — idempotent via a data attribute guard.
@@ -2406,5 +2436,5 @@ const Settings = (() => {
2406
2436
  });
2407
2437
  }
2408
2438
 
2409
- return { open, init, loadBrand: _loadBrand };
2439
+ return { open, init, loadBrand: _loadBrand, applyAccentColor: _applyAccentColor };
2410
2440
  })();
@@ -216,8 +216,8 @@ WS.onEvent(ev => {
216
216
  // send it now — after restoreFromHash has settled — so appendMsg won't be wiped.
217
217
  const pendingMsg = Sessions.takePendingMessage();
218
218
  if (pendingMsg && pendingMsg.session_id === ev.session_id) {
219
- Sessions.appendMsg("user", escapeHtml(pendingMsg.content), { time: new Date() });
220
- WS.send({ type: "message", session_id: pendingMsg.session_id, content: pendingMsg.content });
219
+ Sessions.appendMsg("user", escapeHtml(pendingMsg.display || pendingMsg.content), { time: new Date() });
220
+ WS.send({ type: "message", session_id: pendingMsg.session_id, content: pendingMsg.content, lang: I18n.lang() });
221
221
  }
222
222
  break;
223
223
  }
@@ -298,14 +298,21 @@ WS.onEvent(ev => {
298
298
 
299
299
  // ── Chat messages ──────────────────────────────────────────────────
300
300
  case "history_user_message":
301
- // Emitted only during history replay never from live WS.
302
- // Rendered by Sessions._fetchHistory; nothing to do here.
301
+ // Emitted by show_user_message before agent.run; stamp the authoritative
302
+ // created_at onto the optimistically-rendered bubble and register it in
303
+ // the dedup set so the subsequent history fetch skips this round.
304
+ if (ev.session_id === Sessions.activeId && ev.created_at) {
305
+ Sessions.stampLastUserBubble(ev.created_at);
306
+ }
303
307
  break;
304
308
 
305
309
  case "assistant_message":
306
310
  if (ev.session_id !== Sessions.activeId) break;
307
311
  Sessions.clearProgress();
308
312
  Sessions.appendMsg("assistant", ev.content);
313
+ if (window.Clacky && Clacky.ext) {
314
+ Clacky.ext.emit("session:assistant-message", { sessionId: ev.session_id, content: ev.content });
315
+ }
309
316
  break;
310
317
 
311
318
  case "tool_call":
@@ -438,10 +445,15 @@ function renderErrorEvent(ev) {
438
445
  const action = ev.top_up_url
439
446
  ? ` <a href="${escapeHtml(ev.top_up_url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(I18n.t("error.insufficient_credit.action"))} →</a>`
440
447
  : "";
441
- Sessions.appendMsg("error", `<span>${body}${action}</span>`);
448
+ Sessions.appendMsg("error", `<span>${body}${action}</span>${_buildRawDetail(ev.raw_message)}`);
442
449
  return;
443
450
  }
444
- Sessions.appendMsg("error", escapeHtml(ev.message));
451
+ Sessions.appendMsg("error", escapeHtml(ev.message) + _buildRawDetail(ev.raw_message));
452
+ }
453
+
454
+ function _buildRawDetail(raw) {
455
+ if (!raw) return "";
456
+ return `<details class="error-raw-detail"><summary>${escapeHtml(I18n.t("error.show_detail"))}</summary><pre>${escapeHtml(raw)}</pre></details>`;
445
457
  }
446
458
 
447
459
  window.renderErrorEvent = renderErrorEvent;
data/lib/clacky.rb CHANGED
@@ -68,6 +68,7 @@ require_relative "clacky/message_format/bedrock"
68
68
  require_relative "clacky/bedrock_stream_aggregator"
69
69
  require_relative "clacky/openai_stream_aggregator"
70
70
  require_relative "clacky/anthropic_stream_aggregator"
71
+ require_relative "clacky/locales/i18n"
71
72
  require_relative "clacky/client"
72
73
  require_relative "clacky/skill"
73
74
  require_relative "clacky/skill_loader"
@@ -128,7 +129,6 @@ require_relative "clacky/mcp/registry"
128
129
  require_relative "clacky/mcp/skill_provider"
129
130
  require_relative "clacky/media/base"
130
131
  require_relative "clacky/media/openai_compat"
131
- require_relative "clacky/media/output_dir"
132
132
  require_relative "clacky/media/generator"
133
133
  require_relative "clacky/vision/resolver"
134
134
  require_relative "clacky/telemetry"
@@ -145,15 +145,37 @@ require_relative "clacky/cli"
145
145
  require_relative "clacky/patch_loader"
146
146
  Clacky::PatchLoader.load_all
147
147
 
148
+ # HTTP API extension layer: define the base class + loader + dispatcher.
149
+ # Loading of user extensions is deferred to HttpServer#start so the host
150
+ # process is fully initialized before extension handlers can resolve helpers
151
+ # like session_manager or agent_config.
152
+ require_relative "clacky/api_extension"
153
+ require_relative "clacky/api_extension_loader"
154
+ require_relative "clacky/server/api_extension_dispatcher"
155
+
148
156
  module Clacky
149
157
  class AgentInterrupted < Exception; end # Inherit from Exception to bypass rescue StandardError
150
- class AgentError < StandardError; end
151
- class BadRequestError < AgentError; end # 400 errors — our request was malformed, history should be rolled back
158
+ class AgentError < StandardError
159
+ attr_reader :raw_message
160
+
161
+ def initialize(message, raw_message: nil)
162
+ super(message)
163
+ @raw_message = raw_message
164
+ end
165
+ end
166
+ class BadRequestError < AgentError
167
+ attr_reader :display_message
168
+
169
+ def initialize(message, display_message: nil, raw_message: nil)
170
+ super(message, raw_message: raw_message)
171
+ @display_message = display_message
172
+ end
173
+ end
152
174
  class InsufficientCreditError < AgentError
153
175
  attr_reader :error_code, :provider_id
154
176
 
155
- def initialize(message, error_code: nil, provider_id: nil)
156
- super(message)
177
+ def initialize(message, error_code: nil, provider_id: nil, raw_message: nil)
178
+ super(message, raw_message: raw_message)
157
179
  @error_code = error_code
158
180
  @provider_id = provider_id
159
181
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openclacky
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.3
4
+ version: 1.3.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - windy
@@ -245,6 +245,20 @@ dependencies:
245
245
  - - "<"
246
246
  - !ruby/object:Gem::Version
247
247
  version: '5.0'
248
+ - !ruby/object:Gem::Dependency
249
+ name: ruby_rich
250
+ requirement: !ruby/object:Gem::Requirement
251
+ requirements:
252
+ - - "~>"
253
+ - !ruby/object:Gem::Version
254
+ version: 0.5.2
255
+ type: :runtime
256
+ prerelease: false
257
+ version_requirements: !ruby/object:Gem::Requirement
258
+ requirements:
259
+ - - "~>"
260
+ - !ruby/object:Gem::Version
261
+ version: 0.5.2
248
262
  - !ruby/object:Gem::Dependency
249
263
  name: chunky_png
250
264
  requirement: !ruby/object:Gem::Requirement
@@ -313,6 +327,8 @@ files:
313
327
  - docs/engineering-article.md
314
328
  - docs/mcp-architecture.md
315
329
  - docs/mcp.example.json
330
+ - docs/rich_ui_guide.md
331
+ - docs/rich_ui_refactor_plan.md
316
332
  - docs/session-skill-invocation.md
317
333
  - docs/time_machine_design.md
318
334
  - docs/ui2-architecture.md
@@ -322,6 +338,7 @@ files:
322
338
  - lib/clacky/aes_gcm.rb
323
339
  - lib/clacky/agent.rb
324
340
  - lib/clacky/agent/cost_tracker.rb
341
+ - lib/clacky/agent/fake_tool_call_detector.rb
325
342
  - lib/clacky/agent/hook_manager.rb
326
343
  - lib/clacky/agent/llm_caller.rb
327
344
  - lib/clacky/agent/memory_updater.rb
@@ -339,6 +356,8 @@ files:
339
356
  - lib/clacky/agent_config.rb
340
357
  - lib/clacky/agent_profile.rb
341
358
  - lib/clacky/anthropic_stream_aggregator.rb
359
+ - lib/clacky/api_extension.rb
360
+ - lib/clacky/api_extension_loader.rb
342
361
  - lib/clacky/banner.rb
343
362
  - lib/clacky/bedrock_stream_aggregator.rb
344
363
  - lib/clacky/billing/billing_record.rb
@@ -357,6 +376,10 @@ files:
357
376
  - lib/clacky/default_agents/coding/webui/.gitkeep
358
377
  - lib/clacky/default_agents/general/profile.yml
359
378
  - lib/clacky/default_agents/general/system_prompt.md
379
+ - lib/clacky/default_extensions/meeting/handler.rb
380
+ - lib/clacky/default_extensions/meeting/meeting.js
381
+ - lib/clacky/default_extensions/meeting/meta.yml
382
+ - lib/clacky/default_extensions/meeting/skills/meeting-summarizer/SKILL.md
360
383
  - lib/clacky/default_parsers/doc_parser.rb
361
384
  - lib/clacky/default_parsers/docx_parser.rb
362
385
  - lib/clacky/default_parsers/pdf_parser.rb
@@ -381,6 +404,7 @@ files:
381
404
  - lib/clacky/default_skills/extend-openclacky/SKILL.md
382
405
  - lib/clacky/default_skills/mcp-manager/SKILL.md
383
406
  - lib/clacky/default_skills/media-gen/SKILL.md
407
+ - lib/clacky/default_skills/media-gen/scripts/video_seq.sh
384
408
  - lib/clacky/default_skills/new/SKILL.md
385
409
  - lib/clacky/default_skills/new/scripts/create_rails_project.sh
386
410
  - lib/clacky/default_skills/onboard/SKILL.md
@@ -412,6 +436,9 @@ files:
412
436
  - lib/clacky/default_skills/skill-creator/scripts/validate_skill_frontmatter.rb
413
437
  - lib/clacky/idle_compression_timer.rb
414
438
  - lib/clacky/json_ui_controller.rb
439
+ - lib/clacky/locales/en.rb
440
+ - lib/clacky/locales/i18n.rb
441
+ - lib/clacky/locales/zh.rb
415
442
  - lib/clacky/mcp/client.rb
416
443
  - lib/clacky/mcp/http_transport.rb
417
444
  - lib/clacky/mcp/registry.rb
@@ -424,18 +451,34 @@ files:
424
451
  - lib/clacky/media/gemini.rb
425
452
  - lib/clacky/media/generator.rb
426
453
  - lib/clacky/media/openai_compat.rb
427
- - lib/clacky/media/output_dir.rb
428
454
  - lib/clacky/message_format/anthropic.rb
429
455
  - lib/clacky/message_format/bedrock.rb
430
456
  - lib/clacky/message_format/open_ai.rb
431
457
  - lib/clacky/message_history.rb
458
+ - lib/clacky/null_ui_controller.rb
432
459
  - lib/clacky/openai_stream_aggregator.rb
433
460
  - lib/clacky/patch_loader.rb
434
461
  - lib/clacky/plain_ui_controller.rb
435
462
  - lib/clacky/platform_http_client.rb
436
463
  - lib/clacky/providers.rb
437
464
  - lib/clacky/proxy_config.rb
465
+ - lib/clacky/rich_ui.rb
466
+ - lib/clacky/rich_ui/components/base_component.rb
467
+ - lib/clacky/rich_ui/components/dialogs/approval_dialog.rb
468
+ - lib/clacky/rich_ui/components/dialogs/config_menu_dialog.rb
469
+ - lib/clacky/rich_ui/components/dialogs/form_dialog.rb
470
+ - lib/clacky/rich_ui/components/sidebar.rb
471
+ - lib/clacky/rich_ui/components/sidebar_panels.rb
472
+ - lib/clacky/rich_ui/components/status_view.rb
473
+ - lib/clacky/rich_ui/components/thinking_live_view.rb
474
+ - lib/clacky/rich_ui/entry_tracker.rb
475
+ - lib/clacky/rich_ui/layout_adapter.rb
476
+ - lib/clacky/rich_ui/progress_handle_adapter.rb
477
+ - lib/clacky/rich_ui/rich_ui_controller.rb
478
+ - lib/clacky/rich_ui/shell/rich_agent_shell.rb
479
+ - lib/clacky/rich_ui/view_renderer.rb
438
480
  - lib/clacky/rich_ui_controller.rb
481
+ - lib/clacky/server/api_extension_dispatcher.rb
439
482
  - lib/clacky/server/backup_manager.rb
440
483
  - lib/clacky/server/browser_manager.rb
441
484
  - lib/clacky/server/channel.rb
@@ -1,43 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Clacky
4
- module Media
5
- # Resolves the on-disk root for generated media files (images, videos,
6
- # audio) according to a fixed precedence:
7
- #
8
- # 1. `param` — explicit `output_dir` from the API caller.
9
- # Highest priority; lets a single call land
10
- # somewhere specific (e.g. a doc's project root).
11
- # 2. `configured` — user setting from AgentConfig#media_output_dir.
12
- # Set via Settings → Models → Media Output Directory.
13
- # 3. `fallback` — process default; preserves legacy behavior for
14
- # configs that have neither key set.
15
- #
16
- # Pure function on purpose: callers (HTTP handlers) read the configured
17
- # value off AgentConfig and inject it here. Keeps this helper trivially
18
- # unit-testable and free of global state.
19
- #
20
- # The final on-disk path is `<resolved>/assets/generated/<file>` —
21
- # the `assets/generated/` suffix is appended by Media::Base#save_*
22
- # for stable relative-path semantics across markdown / slide outputs,
23
- # and is intentionally not configurable here.
24
- module OutputDir
25
- # @param param [String, nil] explicit per-call override
26
- # @param configured [String, nil] user-configured default
27
- # @param fallback [String] last-resort default (defaults to Dir.pwd)
28
- # @return [String] absolute or `~`-prefixed path; the
29
- # caller's File.join with "assets/generated/" handles `~` via the
30
- # surrounding FileUtils.mkdir_p call only when expanded — for safety
31
- # we expand `~` here so downstream sees an absolute path.
32
- def self.resolve(param:, configured:, fallback: Dir.pwd)
33
- chosen = first_present(param, configured) || fallback
34
- File.expand_path(chosen.to_s)
35
- end
36
-
37
- # @api private
38
- def self.first_present(*candidates)
39
- candidates.find { |c| c.is_a?(String) && !c.strip.empty? }
40
- end
41
- end
42
- end
43
- end