openclacky 1.3.4 → 1.3.6
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 +39 -0
- data/lib/clacky/agent/fake_tool_call_detector.rb +52 -0
- data/lib/clacky/agent/message_compressor.rb +32 -8
- data/lib/clacky/agent/message_compressor_helper.rb +113 -12
- data/lib/clacky/agent/session_serializer.rb +3 -2
- data/lib/clacky/agent/tool_executor.rb +0 -12
- data/lib/clacky/agent.rb +74 -12
- data/lib/clacky/api_extension.rb +81 -0
- data/lib/clacky/api_extension_loader.rb +13 -1
- data/lib/clacky/cli.rb +0 -1
- data/lib/clacky/client.rb +14 -17
- data/lib/clacky/default_agents/_panels/time_machine/panel.js +22 -0
- data/lib/clacky/default_agents/base_prompt.md +1 -0
- data/lib/clacky/default_extensions/meeting/handler.rb +331 -0
- data/lib/clacky/default_extensions/meeting/meeting.js +790 -0
- data/lib/clacky/default_extensions/meeting/meta.yml +3 -0
- data/lib/clacky/default_extensions/meeting/skills/meeting-summarizer/SKILL.md +44 -0
- data/lib/clacky/default_skills/media-gen/SKILL.md +63 -0
- data/lib/clacky/default_skills/media-gen/scripts/video_seq.sh +114 -0
- data/lib/clacky/json_ui_controller.rb +1 -1
- data/lib/clacky/media/base.rb +60 -0
- data/lib/clacky/media/dashscope.rb +385 -21
- data/lib/clacky/media/gemini.rb +9 -0
- data/lib/clacky/media/generator.rb +52 -0
- data/lib/clacky/media/openai_compat.rb +166 -0
- data/lib/clacky/null_ui_controller.rb +13 -0
- data/lib/clacky/plain_ui_controller.rb +1 -1
- data/lib/clacky/providers.rb +50 -2
- data/lib/clacky/rich_ui/rich_ui_controller.rb +1 -1
- data/lib/clacky/server/channel/channel_ui_controller.rb +1 -1
- data/lib/clacky/server/http_server.rb +260 -122
- data/lib/clacky/server/session_registry.rb +54 -3
- data/lib/clacky/server/web_ui_controller.rb +3 -2
- data/lib/clacky/session_manager.rb +35 -4
- data/lib/clacky/skill_loader.rb +14 -2
- data/lib/clacky/tools/terminal/output_cleaner.rb +1 -3
- data/lib/clacky/tools/terminal.rb +0 -43
- data/lib/clacky/ui2/components/modal_component.rb +1 -1
- data/lib/clacky/ui2/layout_manager.rb +0 -5
- data/lib/clacky/ui2/progress_handle.rb +0 -3
- data/lib/clacky/ui2/ui_controller.rb +140 -40
- data/lib/clacky/ui_interface.rb +10 -1
- data/lib/clacky/utils/encoding.rb +25 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +199 -23
- data/lib/clacky/web/components/onboard.js +1 -14
- data/lib/clacky/web/components/sidebar.js +1 -3
- data/lib/clacky/web/features/backup/store.js +23 -0
- data/lib/clacky/web/features/backup/view.js +49 -22
- data/lib/clacky/web/features/brand/view.js +8 -5
- data/lib/clacky/web/features/channels/store.js +1 -20
- data/lib/clacky/web/features/mcp/store.js +1 -20
- data/lib/clacky/web/features/profile/store.js +1 -13
- data/lib/clacky/web/features/profile/view.js +16 -4
- data/lib/clacky/web/features/skills/store.js +6 -21
- data/lib/clacky/web/features/tasks/view.js +77 -28
- data/lib/clacky/web/features/version/store.js +2 -0
- data/lib/clacky/web/i18n.js +46 -3
- data/lib/clacky/web/index.html +67 -26
- data/lib/clacky/web/sessions.js +177 -87
- data/lib/clacky/web/settings.js +34 -2
- data/lib/clacky/web/ws-dispatcher.js +12 -4
- data/lib/clacky.rb +12 -5
- metadata +8 -1
data/lib/clacky/web/sessions.js
CHANGED
|
@@ -41,6 +41,7 @@ const Sessions = (() => {
|
|
|
41
41
|
let _searchToken = 0; // monotonic counter; in-flight requests check against this
|
|
42
42
|
let _cronView = false; // are we in the cron sub-view?
|
|
43
43
|
let _cronCount = 0; // total cron sessions from server
|
|
44
|
+
let _latestCronUpdatedAt = null; // updated_at of the newest cron session (for virtual entry sort position)
|
|
44
45
|
// ── Cron sub-view independent pagination (commit 2) ──────────────────────
|
|
45
46
|
// The folded cron sub-view paginates *independently* of the outer list so
|
|
46
47
|
// that "Load more" inside it never advances the outer list's cursor, and so
|
|
@@ -411,42 +412,27 @@ const Sessions = (() => {
|
|
|
411
412
|
_hideNewMessageBanner();
|
|
412
413
|
});
|
|
413
414
|
|
|
414
|
-
//
|
|
415
|
-
//
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
messages.addEventListener("keydown", detectUserScroll);
|
|
430
|
-
|
|
431
|
-
// For touch devices: touchmove doesn't tell us direction, so check in scroll event
|
|
432
|
-
let touchStartY = 0;
|
|
433
|
-
messages.addEventListener("touchstart", (e) => {
|
|
434
|
-
touchStartY = e.touches[0].clientY;
|
|
435
|
-
}, { passive: true });
|
|
436
|
-
|
|
437
|
-
messages.addEventListener("touchmove", (e) => {
|
|
438
|
-
const touchDeltaY = e.touches[0].clientY - touchStartY;
|
|
439
|
-
// touchDeltaY > 0 means finger moved down = content scrolls up
|
|
440
|
-
if (touchDeltaY > 5) {
|
|
441
|
-
_userScrolledUp = true;
|
|
442
|
-
}
|
|
443
|
-
}, { passive: true });
|
|
444
|
-
|
|
445
|
-
// Monitor scroll position: clear flag when user reaches bottom
|
|
415
|
+
// Single source of truth for "is the user browsing history?": the scroll
|
|
416
|
+
// position itself. Every scrolling method — mouse wheel, dragging the
|
|
417
|
+
// scrollbar, keyboard (Up/PageUp/Home/Space), touch swipe and momentum
|
|
418
|
+
// scrolling — funnels through the `scroll` event, so reading the position
|
|
419
|
+
// here covers them all with no blind spots.
|
|
420
|
+
//
|
|
421
|
+
// The previous approach instead listened to specific input events
|
|
422
|
+
// (wheel/keydown/touchmove) to *infer* intent. Dragging the scrollbar
|
|
423
|
+
// fires `scroll` but none of those, so it was never detected and AI
|
|
424
|
+
// messages kept yanking the view back to the bottom — see C-5629.
|
|
425
|
+
//
|
|
426
|
+
// Streaming-append safety: appending content grows scrollHeight but leaves
|
|
427
|
+
// scrollTop untouched (verified in-browser, even with overflow-anchor:auto),
|
|
428
|
+
// so a content update never moves us "away from bottom" and never trips a
|
|
429
|
+
// false positive here. The 150px threshold in _isAtBottom is extra slack.
|
|
446
430
|
messages.addEventListener("scroll", () => {
|
|
447
431
|
if (_isAtBottom(messages)) {
|
|
448
432
|
_userScrolledUp = false;
|
|
449
433
|
_hideNewMessageBanner();
|
|
434
|
+
} else {
|
|
435
|
+
_userScrolledUp = true;
|
|
450
436
|
}
|
|
451
437
|
});
|
|
452
438
|
}
|
|
@@ -468,30 +454,26 @@ const Sessions = (() => {
|
|
|
468
454
|
document.getElementById("btn-new-session-inline")
|
|
469
455
|
.addEventListener("click", () => Sessions.create("general"));
|
|
470
456
|
|
|
471
|
-
// Split button: arrow
|
|
472
|
-
document.getElementById("btn-new-session-arrow")
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
457
|
+
// Split button: arrow — show dropdown on hover, hide when leaving the whole wrap
|
|
458
|
+
const arrow = document.getElementById("btn-new-session-arrow");
|
|
459
|
+
const wrap = arrow.closest(".btn-split-wrap");
|
|
460
|
+
const dd = document.getElementById("new-session-dropdown");
|
|
461
|
+
|
|
462
|
+
arrow.addEventListener("mouseenter", () => { dd.hidden = false; });
|
|
463
|
+
dd.addEventListener("mouseenter", () => { dd.hidden = false; });
|
|
464
|
+
wrap.addEventListener("mouseleave", () => { dd.hidden = true; });
|
|
465
|
+
dd.addEventListener("mouseleave", () => { dd.hidden = true; });
|
|
478
466
|
|
|
479
|
-
// Dropdown item "Advanced Options…"
|
|
480
|
-
// panel may be re-rendered; this keeps the binding stable.
|
|
467
|
+
// Dropdown item "Advanced Options…"
|
|
481
468
|
document.addEventListener("click", (e) => {
|
|
482
469
|
if (e.target && e.target.id === "btn-new-session-modal") {
|
|
483
|
-
|
|
484
|
-
document.getElementById("new-session-dropdown").hidden = true;
|
|
470
|
+
dd.hidden = true;
|
|
485
471
|
Sessions.openNewSessionModal();
|
|
472
|
+
} else if (!wrap.contains(e.target)) {
|
|
473
|
+
dd.hidden = true;
|
|
486
474
|
}
|
|
487
475
|
});
|
|
488
476
|
|
|
489
|
-
// Close dropdown when clicking anywhere else
|
|
490
|
-
document.addEventListener("click", () => {
|
|
491
|
-
const dd = document.getElementById("new-session-dropdown");
|
|
492
|
-
if (dd && !dd.hidden) dd.hidden = true;
|
|
493
|
-
});
|
|
494
|
-
|
|
495
477
|
// Welcome screen "+ New Session" button
|
|
496
478
|
document.getElementById("btn-welcome-new")
|
|
497
479
|
.addEventListener("click", () => Sessions.create("general"));
|
|
@@ -1404,6 +1386,7 @@ const Sessions = (() => {
|
|
|
1404
1386
|
el.dataset.raw = ev.content || "";
|
|
1405
1387
|
el.innerHTML = _renderMarkdown(ev.content || "");
|
|
1406
1388
|
_appendCopyButton(el);
|
|
1389
|
+
_enhanceTaskItems(el, ev.content || "");
|
|
1407
1390
|
container.appendChild(el);
|
|
1408
1391
|
break;
|
|
1409
1392
|
}
|
|
@@ -1667,6 +1650,7 @@ const Sessions = (() => {
|
|
|
1667
1650
|
code: session.error_code,
|
|
1668
1651
|
message: session.error,
|
|
1669
1652
|
top_up_url: session.top_up_url,
|
|
1653
|
+
raw_message: session.raw_message,
|
|
1670
1654
|
});
|
|
1671
1655
|
} else {
|
|
1672
1656
|
Sessions.appendMsg("error", session.error);
|
|
@@ -1925,6 +1909,78 @@ const Sessions = (() => {
|
|
|
1925
1909
|
_ensureCopyDelegation();
|
|
1926
1910
|
}
|
|
1927
1911
|
|
|
1912
|
+
// System feature: any assistant message containing GFM task-list items
|
|
1913
|
+
// (`- [ ] ...`) gets a "spawn" button on each UNCHECKED item, turning a
|
|
1914
|
+
// todo into its own session. The new session's first prompt is the item
|
|
1915
|
+
// text plus the full message for context.
|
|
1916
|
+
function _stripThink(text) {
|
|
1917
|
+
if (!text) return "";
|
|
1918
|
+
return text.replace(/<think>[\s\S]*?<\/think>/g, "").replace(/^\s+/, "");
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
function _enhanceTaskItems(el, rawText) {
|
|
1922
|
+
const seen = new Set();
|
|
1923
|
+
const context = _stripThink(rawText);
|
|
1924
|
+
el.querySelectorAll("li").forEach((li) => {
|
|
1925
|
+
const box = li.querySelector('input[type="checkbox"]');
|
|
1926
|
+
if (!box || box.checked) return; // only unchecked todos
|
|
1927
|
+
if (seen.has(li)) return;
|
|
1928
|
+
seen.add(li);
|
|
1929
|
+
|
|
1930
|
+
const text = li.textContent.trim();
|
|
1931
|
+
if (!text) return;
|
|
1932
|
+
|
|
1933
|
+
const btn = document.createElement("button");
|
|
1934
|
+
btn.type = "button";
|
|
1935
|
+
btn.className = "msg-todo-spawn";
|
|
1936
|
+
btn.title = I18n.t("chat.todo.spawn");
|
|
1937
|
+
btn.textContent = I18n.t("chat.todo.spawn");
|
|
1938
|
+
btn.dataset.todoText = text;
|
|
1939
|
+
btn.dataset.todoContext = context;
|
|
1940
|
+
li.appendChild(btn);
|
|
1941
|
+
});
|
|
1942
|
+
_ensureTodoSpawnDelegation();
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
let _todoSpawnDelegationInstalled = false;
|
|
1946
|
+
function _ensureTodoSpawnDelegation() {
|
|
1947
|
+
if (_todoSpawnDelegationInstalled) return;
|
|
1948
|
+
const messages = RenderTarget.outer();
|
|
1949
|
+
if (!messages) return;
|
|
1950
|
+
_todoSpawnDelegationInstalled = true;
|
|
1951
|
+
messages.addEventListener("click", (e) => {
|
|
1952
|
+
const btn = e.target.closest(".msg-todo-spawn");
|
|
1953
|
+
if (!btn) return;
|
|
1954
|
+
e.preventDefault();
|
|
1955
|
+
e.stopPropagation();
|
|
1956
|
+
_spawnFromTodo(btn);
|
|
1957
|
+
});
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
async function _spawnFromTodo(btn) {
|
|
1961
|
+
if (btn.disabled) return;
|
|
1962
|
+
btn.disabled = true;
|
|
1963
|
+
const original = btn.textContent;
|
|
1964
|
+
btn.textContent = I18n.t("chat.todo.spawning");
|
|
1965
|
+
|
|
1966
|
+
const todoText = btn.dataset.todoText || "";
|
|
1967
|
+
const context = btn.dataset.todoContext || "";
|
|
1968
|
+
const prompt =
|
|
1969
|
+
`Task:\n${todoText}\n\n` +
|
|
1970
|
+
`--- Context (the message this task came from) ---\n${context}`;
|
|
1971
|
+
|
|
1972
|
+
try {
|
|
1973
|
+
await Sessions.startWith(prompt, {
|
|
1974
|
+
name: todoText.slice(0, 60),
|
|
1975
|
+
display: `📋 ${todoText}`,
|
|
1976
|
+
});
|
|
1977
|
+
} catch (err) {
|
|
1978
|
+
btn.disabled = false;
|
|
1979
|
+
btn.textContent = original;
|
|
1980
|
+
alert(I18n.t("chat.todo.spawnFailed", { msg: err.message }));
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1928
1984
|
// Install the click-delegation listener on #messages exactly once.
|
|
1929
1985
|
// Handles copy clicks for all current AND future assistant bubbles
|
|
1930
1986
|
// AND code block copy buttons.
|
|
@@ -2323,11 +2379,12 @@ const Sessions = (() => {
|
|
|
2323
2379
|
// ── List management ───────────────────────────────────────────────────
|
|
2324
2380
|
|
|
2325
2381
|
/** Populate list from initial session_list WS event (connect only). */
|
|
2326
|
-
setAll(list, hasMore = false, cronCount = 0) {
|
|
2382
|
+
setAll(list, hasMore = false, cronCount = 0, latestCronUpdatedAt = null) {
|
|
2327
2383
|
_sessions.length = 0;
|
|
2328
2384
|
_sessions.push(...list);
|
|
2329
|
-
_hasMore
|
|
2330
|
-
_cronCount
|
|
2385
|
+
_hasMore = !!hasMore;
|
|
2386
|
+
_cronCount = cronCount;
|
|
2387
|
+
_latestCronUpdatedAt = latestCronUpdatedAt || null;
|
|
2331
2388
|
},
|
|
2332
2389
|
|
|
2333
2390
|
/** Insert a newly created session into the local list. */
|
|
@@ -2338,6 +2395,36 @@ const Sessions = (() => {
|
|
|
2338
2395
|
}
|
|
2339
2396
|
},
|
|
2340
2397
|
|
|
2398
|
+
// Create a session, queue a command to run once subscribed, and navigate to
|
|
2399
|
+
// it. The user bubble is rendered locally on the "subscribed" event (see
|
|
2400
|
+
// ws-dispatcher), so callers never touch history or WS timing.
|
|
2401
|
+
// Returns the created session.
|
|
2402
|
+
async startWith(command, { name, source = "setup", display = null } = {}) {
|
|
2403
|
+
if (!name) {
|
|
2404
|
+
const maxN = _sessions.reduce((max, s) => {
|
|
2405
|
+
const m = s.name && s.name.match(/^Session (\d+)$/);
|
|
2406
|
+
return m ? Math.max(max, parseInt(m[1], 10)) : max;
|
|
2407
|
+
}, 0);
|
|
2408
|
+
name = "Session " + (maxN + 1);
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
const res = await fetch("/api/sessions", {
|
|
2412
|
+
method: "POST",
|
|
2413
|
+
headers: { "Content-Type": "application/json" },
|
|
2414
|
+
body: JSON.stringify({ name, source }),
|
|
2415
|
+
});
|
|
2416
|
+
const data = await res.json();
|
|
2417
|
+
if (!res.ok) throw new Error(data.error || "failed to create session");
|
|
2418
|
+
const session = data.session;
|
|
2419
|
+
if (!session) throw new Error("no session returned");
|
|
2420
|
+
|
|
2421
|
+
Sessions.add(session);
|
|
2422
|
+
Sessions.renderList();
|
|
2423
|
+
Sessions.setPendingMessage(session.id, command, display);
|
|
2424
|
+
Sessions.select(session.id);
|
|
2425
|
+
return session;
|
|
2426
|
+
},
|
|
2427
|
+
|
|
2341
2428
|
/** Patch a single session's fields (from session_update event).
|
|
2342
2429
|
* If the session is not in the list yet (e.g. just created by another tab),
|
|
2343
2430
|
* prepend it so the sidebar shows it immediately. */
|
|
@@ -2348,6 +2435,14 @@ const Sessions = (() => {
|
|
|
2348
2435
|
} else {
|
|
2349
2436
|
_sessions.unshift({ id, ...fields });
|
|
2350
2437
|
}
|
|
2438
|
+
// Keep _latestCronUpdatedAt current when a cron session is patched/added.
|
|
2439
|
+
const updated = _sessions.find(s => s.id === id);
|
|
2440
|
+
if (updated?.source === "cron") {
|
|
2441
|
+
const t = updated.updated_at || updated.created_at;
|
|
2442
|
+
if (t && (!_latestCronUpdatedAt || t > _latestCronUpdatedAt)) {
|
|
2443
|
+
_latestCronUpdatedAt = t;
|
|
2444
|
+
}
|
|
2445
|
+
}
|
|
2351
2446
|
},
|
|
2352
2447
|
|
|
2353
2448
|
/** Remove a session from the list (from session_deleted event). */
|
|
@@ -2375,21 +2470,17 @@ const Sessions = (() => {
|
|
|
2375
2470
|
Sessions.renderList();
|
|
2376
2471
|
|
|
2377
2472
|
try {
|
|
2378
|
-
// Cursor: oldest activity time
|
|
2379
|
-
//
|
|
2380
|
-
//
|
|
2381
|
-
// bypass pagination), so their time is irrelevant for the cursor.
|
|
2382
|
-
// Including them here would cause the cursor to jump too far back and
|
|
2383
|
-
// skip sessions between the oldest pinned one and the real
|
|
2384
|
-
// last-loaded non-pinned row.
|
|
2473
|
+
// Cursor: oldest activity time of non-pinned, non-cron sessions.
|
|
2474
|
+
// Cron sessions are excluded from the normal list pagination and have
|
|
2475
|
+
// their own independent cursor (_cronBefore) in the sub-view.
|
|
2385
2476
|
const oldest = _sessions.reduce((min, s) => {
|
|
2386
|
-
if (s.pinned) return min;
|
|
2477
|
+
if (s.pinned || s.source === "cron") return min;
|
|
2387
2478
|
const t = s.updated_at || s.created_at;
|
|
2388
2479
|
if (!t) return min;
|
|
2389
2480
|
return (!min || t < min) ? t : min;
|
|
2390
2481
|
}, null);
|
|
2391
2482
|
|
|
2392
|
-
const params = new URLSearchParams({ limit: "20" });
|
|
2483
|
+
const params = new URLSearchParams({ limit: "20", exclude_type: "cron" });
|
|
2393
2484
|
if (oldest) params.set("before", oldest);
|
|
2394
2485
|
if (_filter.q) params.set("q", _filter.q);
|
|
2395
2486
|
if (_filter.date) params.set("date", _filter.date);
|
|
@@ -2402,8 +2493,9 @@ const Sessions = (() => {
|
|
|
2402
2493
|
(data.sessions || []).forEach(s => {
|
|
2403
2494
|
if (!_sessions.find(x => x.id === s.id)) _sessions.push(s);
|
|
2404
2495
|
});
|
|
2405
|
-
_hasMore
|
|
2406
|
-
_cronCount
|
|
2496
|
+
_hasMore = !!data.has_more;
|
|
2497
|
+
_cronCount = data.cron_count ?? _cronCount;
|
|
2498
|
+
if (data.latest_cron_updated_at) _latestCronUpdatedAt = data.latest_cron_updated_at;
|
|
2407
2499
|
} catch (e) {
|
|
2408
2500
|
console.error("loadMore error:", e);
|
|
2409
2501
|
} finally {
|
|
@@ -2799,35 +2891,28 @@ const Sessions = (() => {
|
|
|
2799
2891
|
}
|
|
2800
2892
|
cronSessions.forEach(s => _renderSessionItem(list, s));
|
|
2801
2893
|
} else if (_cronCount > 0) {
|
|
2802
|
-
// Normal list view:
|
|
2803
|
-
//
|
|
2804
|
-
//
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
// would sort by created_at. Pinning is intentionally ignored for the
|
|
2808
|
-
// entry itself: a pinned cron session only takes effect *inside* the
|
|
2809
|
-
// folded sub-view, not on the outer entry.
|
|
2810
|
-
const cronHasRunning = cronSessions.some(s => s.status === "running");
|
|
2894
|
+
// Normal list view: virtual cron group entry positioned by _latestCronUpdatedAt.
|
|
2895
|
+
// Walk the sorted non-cron list and insert the entry at the first row
|
|
2896
|
+
// whose time is older than the newest cron session's updated_at.
|
|
2897
|
+
const cronHasRunning = _sessions.some(s => s.source === "cron" && s.status === "running");
|
|
2898
|
+
const nonCron = visible.filter(s => s.source !== "cron");
|
|
2811
2899
|
let cronEntryRendered = false;
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
if (!
|
|
2900
|
+
|
|
2901
|
+
nonCron.forEach(s => {
|
|
2902
|
+
if (!cronEntryRendered && _latestCronUpdatedAt) {
|
|
2903
|
+
const t = s.updated_at || s.created_at;
|
|
2904
|
+
if (!t || t < _latestCronUpdatedAt) {
|
|
2817
2905
|
_renderCronGroupItem(list, _cronCount, cronHasRunning);
|
|
2818
2906
|
cronEntryRendered = true;
|
|
2819
2907
|
}
|
|
2820
|
-
return;
|
|
2821
2908
|
}
|
|
2822
2909
|
_renderSessionItem(list, s);
|
|
2823
2910
|
});
|
|
2824
|
-
//
|
|
2825
|
-
//
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
// being forced onto the bottom of page 1 (which would mislead the
|
|
2830
|
-
// sort position and miss the running state of an unpaged cron).
|
|
2911
|
+
// If no insertion point found (all normal sessions are newer, or list is empty),
|
|
2912
|
+
// append the entry at the bottom of the loaded rows.
|
|
2913
|
+
if (!cronEntryRendered) {
|
|
2914
|
+
_renderCronGroupItem(list, _cronCount, cronHasRunning);
|
|
2915
|
+
}
|
|
2831
2916
|
} else {
|
|
2832
2917
|
// Normal list view, no cron sessions
|
|
2833
2918
|
visible.forEach(s => _renderSessionItem(list, s));
|
|
@@ -3508,6 +3593,7 @@ const Sessions = (() => {
|
|
|
3508
3593
|
el.dataset.raw = html || "";
|
|
3509
3594
|
el.innerHTML = _renderMarkdown(html);
|
|
3510
3595
|
_appendCopyButton(el);
|
|
3596
|
+
_enhanceTaskItems(el, html || "");
|
|
3511
3597
|
} else {
|
|
3512
3598
|
el.innerHTML = html;
|
|
3513
3599
|
}
|
|
@@ -3535,7 +3621,11 @@ const Sessions = (() => {
|
|
|
3535
3621
|
});
|
|
3536
3622
|
retryBtn.disabled = true;
|
|
3537
3623
|
};
|
|
3624
|
+
// Move any .error-raw-detail to after the retry button
|
|
3625
|
+
const rawDetail = el.querySelector(".error-raw-detail");
|
|
3626
|
+
if (rawDetail) el.removeChild(rawDetail);
|
|
3538
3627
|
el.appendChild(retryBtn);
|
|
3628
|
+
if (rawDetail) el.appendChild(rawDetail);
|
|
3539
3629
|
}
|
|
3540
3630
|
messages.appendChild(el);
|
|
3541
3631
|
}
|
|
@@ -3928,8 +4018,8 @@ const Sessions = (() => {
|
|
|
3928
4018
|
},
|
|
3929
4019
|
|
|
3930
4020
|
/** Register a slash-command message to send after subscribe is confirmed. */
|
|
3931
|
-
setPendingMessage(sessionId, content) {
|
|
3932
|
-
_pendingMessage = { session_id: sessionId, content };
|
|
4021
|
+
setPendingMessage(sessionId, content, display = null) {
|
|
4022
|
+
_pendingMessage = { session_id: sessionId, content, display };
|
|
3933
4023
|
},
|
|
3934
4024
|
|
|
3935
4025
|
/** Consume and return the pending message (clears it). */
|
data/lib/clacky/web/settings.js
CHANGED
|
@@ -1508,6 +1508,7 @@ const Settings = (() => {
|
|
|
1508
1508
|
_showBrandResult(true, I18n.t("settings.brand.activated", { name: data.product_name || "configured" }));
|
|
1509
1509
|
// Apply brand name and logo across the entire UI immediately
|
|
1510
1510
|
if (data.product_name) Brand.applyBrandName(data.product_name);
|
|
1511
|
+
if (data.theme_color) _applyAccentColor(data.theme_color, { persist: true });
|
|
1511
1512
|
Brand.clearBrandCache();
|
|
1512
1513
|
Brand.applyHeaderLogo();
|
|
1513
1514
|
// Refresh brand status flags (user_licensed may have flipped from false
|
|
@@ -1683,7 +1684,7 @@ const Settings = (() => {
|
|
|
1683
1684
|
// The state object per kind:
|
|
1684
1685
|
// { source, configured, model, base_url, api_key_masked, provider, available }
|
|
1685
1686
|
|
|
1686
|
-
const MEDIA_KINDS = ["image", "video", "audio", "ocr"];
|
|
1687
|
+
const MEDIA_KINDS = ["image", "video", "audio", "stt", "video_understanding", "ocr"];
|
|
1687
1688
|
let _mediaState = null;
|
|
1688
1689
|
let _mediaDefaults = null;
|
|
1689
1690
|
const _mediaCustomDraft = {};
|
|
@@ -2173,6 +2174,8 @@ const Settings = (() => {
|
|
|
2173
2174
|
Brand.applyBrandName("OpenClacky");
|
|
2174
2175
|
Brand.clearBrandCache();
|
|
2175
2176
|
Brand.applyHeaderLogo();
|
|
2177
|
+
const userAccent = (() => { try { return localStorage.getItem("clacky-accent-color"); } catch (_) { return null; } })();
|
|
2178
|
+
_applyAccentColor(userAccent || ACCENT_DEFAULT, { persist: false });
|
|
2176
2179
|
// Reset Skills panel state (hide Brand Skills tab, switch to My Skills)
|
|
2177
2180
|
if (typeof Skills !== "undefined" && Skills.resetAfterUnbind) {
|
|
2178
2181
|
Skills.resetAfterUnbind();
|
|
@@ -2203,6 +2206,7 @@ const Settings = (() => {
|
|
|
2203
2206
|
_initLangBtns();
|
|
2204
2207
|
_initFontBtns();
|
|
2205
2208
|
_initCurrencyBtns();
|
|
2209
|
+
_initAccentColorBtns();
|
|
2206
2210
|
|
|
2207
2211
|
// Re-render model cards when language changes (dynamic HTML, not data-i18n)
|
|
2208
2212
|
document.addEventListener("langchange", () => {
|
|
@@ -2364,6 +2368,34 @@ const Settings = (() => {
|
|
|
2364
2368
|
});
|
|
2365
2369
|
}
|
|
2366
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
|
+
|
|
2367
2399
|
// ── QR Code Lightbox ───────────────────────────────────────────────────
|
|
2368
2400
|
// Sets up click-to-enlarge behaviour for the support QR code.
|
|
2369
2401
|
// Safe to call multiple times — idempotent via a data attribute guard.
|
|
@@ -2404,5 +2436,5 @@ const Settings = (() => {
|
|
|
2404
2436
|
});
|
|
2405
2437
|
}
|
|
2406
2438
|
|
|
2407
|
-
return { open, init, loadBrand: _loadBrand };
|
|
2439
|
+
return { open, init, loadBrand: _loadBrand, applyAccentColor: _applyAccentColor };
|
|
2408
2440
|
})();
|
|
@@ -180,7 +180,7 @@ WS.onEvent(ev => {
|
|
|
180
180
|
|
|
181
181
|
// ── Session list ───────────────────────────────────────────────────
|
|
182
182
|
case "session_list": {
|
|
183
|
-
Sessions.setAll(ev.sessions || [], !!ev.has_more, ev.cron_count || 0);
|
|
183
|
+
Sessions.setAll(ev.sessions || [], !!ev.has_more, ev.cron_count || 0, ev.latest_cron_updated_at || null);
|
|
184
184
|
Sessions.renderList();
|
|
185
185
|
|
|
186
186
|
// Restore URL hash once on initial connect; ignore subsequent session_list events.
|
|
@@ -216,7 +216,7 @@ 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() });
|
|
219
|
+
Sessions.appendMsg("user", escapeHtml(pendingMsg.display || pendingMsg.content), { time: new Date() });
|
|
220
220
|
WS.send({ type: "message", session_id: pendingMsg.session_id, content: pendingMsg.content, lang: I18n.lang() });
|
|
221
221
|
}
|
|
222
222
|
break;
|
|
@@ -310,6 +310,9 @@ WS.onEvent(ev => {
|
|
|
310
310
|
if (ev.session_id !== Sessions.activeId) break;
|
|
311
311
|
Sessions.clearProgress();
|
|
312
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
|
+
}
|
|
313
316
|
break;
|
|
314
317
|
|
|
315
318
|
case "tool_call":
|
|
@@ -442,10 +445,15 @@ function renderErrorEvent(ev) {
|
|
|
442
445
|
const action = ev.top_up_url
|
|
443
446
|
? ` <a href="${escapeHtml(ev.top_up_url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(I18n.t("error.insufficient_credit.action"))} →</a>`
|
|
444
447
|
: "";
|
|
445
|
-
Sessions.appendMsg("error", `<span>${body}${action}</span
|
|
448
|
+
Sessions.appendMsg("error", `<span>${body}${action}</span>${_buildRawDetail(ev.raw_message)}`);
|
|
446
449
|
return;
|
|
447
450
|
}
|
|
448
|
-
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>`;
|
|
449
457
|
}
|
|
450
458
|
|
|
451
459
|
window.renderErrorEvent = renderErrorEvent;
|
data/lib/clacky.rb
CHANGED
|
@@ -155,20 +155,27 @@ require_relative "clacky/server/api_extension_dispatcher"
|
|
|
155
155
|
|
|
156
156
|
module Clacky
|
|
157
157
|
class AgentInterrupted < Exception; end # Inherit from Exception to bypass rescue StandardError
|
|
158
|
-
class AgentError < StandardError
|
|
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
|
|
159
166
|
class BadRequestError < AgentError
|
|
160
167
|
attr_reader :display_message
|
|
161
168
|
|
|
162
|
-
def initialize(message, display_message: nil)
|
|
163
|
-
super(message)
|
|
169
|
+
def initialize(message, display_message: nil, raw_message: nil)
|
|
170
|
+
super(message, raw_message: raw_message)
|
|
164
171
|
@display_message = display_message
|
|
165
172
|
end
|
|
166
173
|
end
|
|
167
174
|
class InsufficientCreditError < AgentError
|
|
168
175
|
attr_reader :error_code, :provider_id
|
|
169
176
|
|
|
170
|
-
def initialize(message, error_code: nil, provider_id: nil)
|
|
171
|
-
super(message)
|
|
177
|
+
def initialize(message, error_code: nil, provider_id: nil, raw_message: nil)
|
|
178
|
+
super(message, raw_message: raw_message)
|
|
172
179
|
@error_code = error_code
|
|
173
180
|
@provider_id = provider_id
|
|
174
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.
|
|
4
|
+
version: 1.3.6
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- windy
|
|
@@ -338,6 +338,7 @@ files:
|
|
|
338
338
|
- lib/clacky/aes_gcm.rb
|
|
339
339
|
- lib/clacky/agent.rb
|
|
340
340
|
- lib/clacky/agent/cost_tracker.rb
|
|
341
|
+
- lib/clacky/agent/fake_tool_call_detector.rb
|
|
341
342
|
- lib/clacky/agent/hook_manager.rb
|
|
342
343
|
- lib/clacky/agent/llm_caller.rb
|
|
343
344
|
- lib/clacky/agent/memory_updater.rb
|
|
@@ -375,6 +376,10 @@ files:
|
|
|
375
376
|
- lib/clacky/default_agents/coding/webui/.gitkeep
|
|
376
377
|
- lib/clacky/default_agents/general/profile.yml
|
|
377
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
|
|
378
383
|
- lib/clacky/default_parsers/doc_parser.rb
|
|
379
384
|
- lib/clacky/default_parsers/docx_parser.rb
|
|
380
385
|
- lib/clacky/default_parsers/pdf_parser.rb
|
|
@@ -399,6 +404,7 @@ files:
|
|
|
399
404
|
- lib/clacky/default_skills/extend-openclacky/SKILL.md
|
|
400
405
|
- lib/clacky/default_skills/mcp-manager/SKILL.md
|
|
401
406
|
- lib/clacky/default_skills/media-gen/SKILL.md
|
|
407
|
+
- lib/clacky/default_skills/media-gen/scripts/video_seq.sh
|
|
402
408
|
- lib/clacky/default_skills/new/SKILL.md
|
|
403
409
|
- lib/clacky/default_skills/new/scripts/create_rails_project.sh
|
|
404
410
|
- lib/clacky/default_skills/onboard/SKILL.md
|
|
@@ -449,6 +455,7 @@ files:
|
|
|
449
455
|
- lib/clacky/message_format/bedrock.rb
|
|
450
456
|
- lib/clacky/message_format/open_ai.rb
|
|
451
457
|
- lib/clacky/message_history.rb
|
|
458
|
+
- lib/clacky/null_ui_controller.rb
|
|
452
459
|
- lib/clacky/openai_stream_aggregator.rb
|
|
453
460
|
- lib/clacky/patch_loader.rb
|
|
454
461
|
- lib/clacky/plain_ui_controller.rb
|