openclacky 1.3.4 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +27 -0
- data/lib/clacky/agent/fake_tool_call_detector.rb +52 -0
- data/lib/clacky/agent/session_serializer.rb +3 -2
- data/lib/clacky/agent/tool_executor.rb +0 -12
- data/lib/clacky/agent.rb +74 -9
- data/lib/clacky/api_extension.rb +81 -0
- data/lib/clacky/api_extension_loader.rb +13 -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 +144 -9
- data/lib/clacky/server/session_registry.rb +4 -2
- data/lib/clacky/server/web_ui_controller.rb +3 -2
- 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/ui_controller.rb +140 -31
- 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 +145 -22
- data/lib/clacky/web/components/onboard.js +1 -14
- 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/version/store.js +2 -0
- data/lib/clacky/web/i18n.js +24 -1
- data/lib/clacky/web/index.html +15 -0
- data/lib/clacky/web/sessions.js +141 -51
- data/lib/clacky/web/settings.js +34 -2
- data/lib/clacky/web/ws-dispatcher.js +11 -3
- data/lib/clacky.rb +12 -5
- metadata +8 -1
|
@@ -575,20 +575,7 @@ const Onboard = (() => {
|
|
|
575
575
|
async function _launchOnboardSession() {
|
|
576
576
|
try {
|
|
577
577
|
await _complete();
|
|
578
|
-
|
|
579
|
-
method: "POST",
|
|
580
|
-
headers: { "Content-Type": "application/json" },
|
|
581
|
-
body: JSON.stringify({ name: "Onboard", source: "setup" })
|
|
582
|
-
});
|
|
583
|
-
const data = await res.json();
|
|
584
|
-
const session = data.session;
|
|
585
|
-
if (!session) throw new Error("No session returned");
|
|
586
|
-
|
|
587
|
-
Sessions.add(session);
|
|
588
|
-
Sessions.renderList();
|
|
589
|
-
Sessions.setPendingMessage(session.id, `/onboard lang:${_selectedLang}`);
|
|
590
|
-
Sessions.select(session.id);
|
|
591
|
-
|
|
578
|
+
await Sessions.startWith(`/onboard lang:${_selectedLang}`, { name: "Onboard", source: "setup" });
|
|
592
579
|
_bootUI();
|
|
593
580
|
} catch (_) {
|
|
594
581
|
// Fallback: just boot normally if session creation fails
|
|
@@ -18,6 +18,7 @@ const BrandView = (() => {
|
|
|
18
18
|
if (!data || !data.branded) return;
|
|
19
19
|
|
|
20
20
|
if (data.needs_activation) {
|
|
21
|
+
if (data.theme_color) Settings.applyAccentColor(data.theme_color, { persist: true });
|
|
21
22
|
_showActivationBanner(data.product_name);
|
|
22
23
|
_applyHeaderLogo();
|
|
23
24
|
if (data.distribution_refresh_pending) _scheduleDistributionRefreshPoll();
|
|
@@ -153,6 +154,7 @@ const BrandView = (() => {
|
|
|
153
154
|
if (data.ok) {
|
|
154
155
|
_setResult(true, I18n.t("brand.activate.success"));
|
|
155
156
|
if (data.product_name) _applyBrandName(data.product_name);
|
|
157
|
+
if (data.theme_color) Settings.applyAccentColor(data.theme_color, { persist: true });
|
|
156
158
|
Brand.clearBrandCache();
|
|
157
159
|
_applyHeaderLogo();
|
|
158
160
|
setTimeout(_bootUI, 800);
|
|
@@ -201,13 +203,14 @@ const BrandView = (() => {
|
|
|
201
203
|
const brandWrap = document.getElementById("header-brand");
|
|
202
204
|
|
|
203
205
|
if (info.theme_color) {
|
|
204
|
-
const root = document.documentElement;
|
|
205
|
-
root.style.setProperty("--color-accent-primary", info.theme_color);
|
|
206
|
-
root.style.setProperty("--color-accent-hover", info.theme_color);
|
|
207
|
-
root.style.setProperty("--color-button-primary", info.theme_color);
|
|
208
|
-
root.style.setProperty("--color-button-primary-hover", info.theme_color);
|
|
209
206
|
const metaTheme = document.querySelector("meta[name='theme-color']");
|
|
210
207
|
if (metaTheme) metaTheme.setAttribute("content", info.theme_color);
|
|
208
|
+
}
|
|
209
|
+
const userAccent = (() => { try { return localStorage.getItem("clacky-accent-color"); } catch (_) { return null; } })();
|
|
210
|
+
if (userAccent) {
|
|
211
|
+
Settings.applyAccentColor(userAccent, { persist: false });
|
|
212
|
+
} else if (info.theme_color) {
|
|
213
|
+
Settings.applyAccentColor(info.theme_color, { persist: true });
|
|
211
214
|
} else {
|
|
212
215
|
const root = document.documentElement;
|
|
213
216
|
root.style.removeProperty("--color-accent-primary");
|
|
@@ -37,26 +37,7 @@ const ChannelsStore = (() => {
|
|
|
37
37
|
// Create a session, register it, queue a command, and navigate to it.
|
|
38
38
|
async function _sendToAgent(command, sessionName) {
|
|
39
39
|
try {
|
|
40
|
-
|
|
41
|
-
const m = s.name.match(/^Session (\d+)$/);
|
|
42
|
-
return m ? Math.max(max, parseInt(m[1], 10)) : max;
|
|
43
|
-
}, 0);
|
|
44
|
-
const name = sessionName || ("Session " + (maxN + 1));
|
|
45
|
-
|
|
46
|
-
const res = await fetch("/api/sessions", {
|
|
47
|
-
method: "POST",
|
|
48
|
-
headers: { "Content-Type": "application/json" },
|
|
49
|
-
body: JSON.stringify({ name, source: "setup" }),
|
|
50
|
-
});
|
|
51
|
-
const data = await res.json();
|
|
52
|
-
if (!res.ok) throw new Error(data.error || I18n.t("channels.sessionError"));
|
|
53
|
-
const session = data.session;
|
|
54
|
-
if (!session) throw new Error(I18n.t("channels.noSession"));
|
|
55
|
-
|
|
56
|
-
Sessions.add(session);
|
|
57
|
-
Sessions.renderList();
|
|
58
|
-
Sessions.setPendingMessage(session.id, command);
|
|
59
|
-
Sessions.select(session.id);
|
|
40
|
+
await Sessions.startWith(command, { name: sessionName });
|
|
60
41
|
} catch (e) {
|
|
61
42
|
alert("Error: " + e.message);
|
|
62
43
|
}
|
|
@@ -43,26 +43,7 @@ const McpStore = (() => {
|
|
|
43
43
|
|
|
44
44
|
async function _sendToAgent(command, sessionName) {
|
|
45
45
|
try {
|
|
46
|
-
|
|
47
|
-
const m = s.name.match(/^Session (\d+)$/);
|
|
48
|
-
return m ? Math.max(max, parseInt(m[1], 10)) : max;
|
|
49
|
-
}, 0);
|
|
50
|
-
const name = sessionName || ("Session " + (maxN + 1));
|
|
51
|
-
|
|
52
|
-
const res = await fetch("/api/sessions", {
|
|
53
|
-
method: "POST",
|
|
54
|
-
headers: { "Content-Type": "application/json" },
|
|
55
|
-
body: JSON.stringify({ name, source: "mcp" }),
|
|
56
|
-
});
|
|
57
|
-
const data = await res.json();
|
|
58
|
-
if (!res.ok) throw new Error(data.error || "failed to create session");
|
|
59
|
-
const session = data.session;
|
|
60
|
-
if (!session) throw new Error("no session returned");
|
|
61
|
-
|
|
62
|
-
Sessions.add(session);
|
|
63
|
-
Sessions.renderList();
|
|
64
|
-
Sessions.setPendingMessage(session.id, command);
|
|
65
|
-
Sessions.select(session.id);
|
|
46
|
+
await Sessions.startWith(command, { name: sessionName });
|
|
66
47
|
} catch (e) {
|
|
67
48
|
alert("Error: " + e.message);
|
|
68
49
|
}
|
|
@@ -64,19 +64,7 @@ const ProfileStore = (() => {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
async function _openCurateSession(name, command) {
|
|
67
|
-
|
|
68
|
-
method: "POST",
|
|
69
|
-
headers: { "Content-Type": "application/json" },
|
|
70
|
-
body: JSON.stringify({ name, source: "onboard" })
|
|
71
|
-
});
|
|
72
|
-
const data = await res.json();
|
|
73
|
-
const session = data.session;
|
|
74
|
-
if (!session) throw new Error("No session returned");
|
|
75
|
-
|
|
76
|
-
Sessions.add(session);
|
|
77
|
-
Sessions.renderList();
|
|
78
|
-
Sessions.setPendingMessage(session.id, command);
|
|
79
|
-
Sessions.select(session.id);
|
|
67
|
+
await Sessions.startWith(command, { name, source: "onboard" });
|
|
80
68
|
}
|
|
81
69
|
|
|
82
70
|
const Profile = {
|
|
@@ -49,7 +49,7 @@ const ProfileView = (() => {
|
|
|
49
49
|
|
|
50
50
|
function flushPara() {
|
|
51
51
|
if (paraBuf.length === 0) return;
|
|
52
|
-
out.push("<p>" + _renderInline(paraBuf.join("
|
|
52
|
+
out.push("<p>" + _renderInline(paraBuf.join("<br>")) + "</p>");
|
|
53
53
|
paraBuf = [];
|
|
54
54
|
}
|
|
55
55
|
function openList(type) {
|
|
@@ -152,11 +152,17 @@ const ProfileView = (() => {
|
|
|
152
152
|
return;
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
const openSet = new Set(
|
|
156
|
+
[...list.querySelectorAll(".memory-card")]
|
|
157
|
+
.filter(c => c.querySelector(".memory-card-body")?.style.display !== "none")
|
|
158
|
+
.map(c => c.dataset.filename)
|
|
159
|
+
);
|
|
160
|
+
|
|
155
161
|
list.innerHTML = "";
|
|
156
|
-
memories.forEach(m => list.appendChild(_buildMemoryCard(m)));
|
|
162
|
+
memories.forEach(m => list.appendChild(_buildMemoryCard(m, openSet.has(m.filename))));
|
|
157
163
|
}
|
|
158
164
|
|
|
159
|
-
function _buildMemoryCard(m) {
|
|
165
|
+
function _buildMemoryCard(m, expanded = false) {
|
|
160
166
|
const card = document.createElement("div");
|
|
161
167
|
card.className = "memory-card";
|
|
162
168
|
card.dataset.filename = m.filename;
|
|
@@ -254,6 +260,8 @@ const ProfileView = (() => {
|
|
|
254
260
|
expandBtn.addEventListener("click", (e) => { e.stopPropagation(); toggle(); });
|
|
255
261
|
head.querySelector(".memory-card-info").addEventListener("click", toggle);
|
|
256
262
|
|
|
263
|
+
if (expanded) toggle();
|
|
264
|
+
|
|
257
265
|
return card;
|
|
258
266
|
}
|
|
259
267
|
|
|
@@ -303,6 +311,7 @@ const ProfileView = (() => {
|
|
|
303
311
|
onSave: async (newContent) => {
|
|
304
312
|
const r = await Profile.updateMemory(m.filename, newContent);
|
|
305
313
|
if (!r.ok) throw new Error(r.error);
|
|
314
|
+
Modal.toast(_t("profile.savedOk"), "success");
|
|
306
315
|
}
|
|
307
316
|
});
|
|
308
317
|
}
|
|
@@ -333,7 +342,10 @@ const ProfileView = (() => {
|
|
|
333
342
|
content: file.content || "",
|
|
334
343
|
title,
|
|
335
344
|
language: "markdown",
|
|
336
|
-
onSave: (newContent) =>
|
|
345
|
+
onSave: async (newContent) => {
|
|
346
|
+
await Profile.updateProfile(kind, newContent);
|
|
347
|
+
Modal.toast(_t("profile.savedOk"), "success");
|
|
348
|
+
}
|
|
337
349
|
});
|
|
338
350
|
}
|
|
339
351
|
|
|
@@ -71,28 +71,13 @@ const SkillsStore = (() => {
|
|
|
71
71
|
// Resolve the next "Session N" name and create a session, then hand off to
|
|
72
72
|
// Sessions. Used by every "open a session and run a command" action.
|
|
73
73
|
async function _openSessionWith(message) {
|
|
74
|
-
const maxN = Sessions.all.reduce((max, s) => {
|
|
75
|
-
const m = s.name.match(/^Session (\d+)$/);
|
|
76
|
-
return m ? Math.max(max, parseInt(m[1], 10)) : max;
|
|
77
|
-
}, 0);
|
|
78
|
-
const res = await fetch("/api/sessions", {
|
|
79
|
-
method: "POST",
|
|
80
|
-
headers: { "Content-Type": "application/json" },
|
|
81
|
-
body: JSON.stringify({ name: "Session " + (maxN + 1), source: "manual" })
|
|
82
|
-
});
|
|
83
|
-
const data = await res.json();
|
|
84
|
-
if (!res.ok) { alert(I18n.t("tasks.sessionError") + (data.error || "unknown")); return null; }
|
|
85
|
-
|
|
86
|
-
const session = data.session;
|
|
87
|
-
if (!session) return null;
|
|
88
|
-
|
|
89
74
|
if (!WS.ready) { WS.connect(); Skills.load(); }
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
75
|
+
try {
|
|
76
|
+
return await Sessions.startWith(message, { source: "manual" });
|
|
77
|
+
} catch (e) {
|
|
78
|
+
alert(I18n.t("tasks.sessionError") + (e.message || "unknown"));
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
96
81
|
}
|
|
97
82
|
|
|
98
83
|
/** Return a user-friendly message for install/update errors. */
|
data/lib/clacky/web/i18n.js
CHANGED
|
@@ -68,6 +68,9 @@ const I18n = (() => {
|
|
|
68
68
|
"chat.resetSession": "Reset session",
|
|
69
69
|
"chat.resetSessionConfirm": "Reset will start a brand-new session. The current conversation history stays in your sidebar but will no longer be active. Continue?",
|
|
70
70
|
"chat.copy": "Copy",
|
|
71
|
+
"chat.todo.spawn": "Start",
|
|
72
|
+
"chat.todo.spawning": "Starting…",
|
|
73
|
+
"chat.todo.spawnFailed": "Failed to start session: {msg}",
|
|
71
74
|
"chat.copied": "Copied",
|
|
72
75
|
"chat.continue": "Continue",
|
|
73
76
|
"chat.edit": "Edit",
|
|
@@ -570,6 +573,8 @@ const I18n = (() => {
|
|
|
570
573
|
"settings.media.kind.image": "Image",
|
|
571
574
|
"settings.media.kind.video": "Video",
|
|
572
575
|
"settings.media.kind.audio": "Audio",
|
|
576
|
+
"settings.media.kind.stt": "Speech-to-Text",
|
|
577
|
+
"settings.media.kind.video_understanding": "Video Understanding",
|
|
573
578
|
"settings.media.kind.ocr": "Vision",
|
|
574
579
|
"settings.media.source.off": "Off",
|
|
575
580
|
"settings.media.source.auto": "Auto",
|
|
@@ -727,6 +732,8 @@ const I18n = (() => {
|
|
|
727
732
|
"settings.currency.updated": "Updated from {{source}} on {{date}}",
|
|
728
733
|
"settings.currency.updateFailed": "Failed to fetch the latest rate. You can still enter it manually.",
|
|
729
734
|
|
|
735
|
+
"settings.accentColor.title": "Accent Color",
|
|
736
|
+
|
|
730
737
|
// ── Onboard ──
|
|
731
738
|
"onboard.title": "Welcome to {{brand}}",
|
|
732
739
|
"onboard.subtitle": "Let's get you set up in a minute.",
|
|
@@ -920,6 +927,7 @@ const I18n = (() => {
|
|
|
920
927
|
|
|
921
928
|
"error.insufficient_credit": "Insufficient LLM credit. Please top up your account to continue.",
|
|
922
929
|
"error.insufficient_credit.action": "Top up",
|
|
930
|
+
"error.show_detail": "Show details",
|
|
923
931
|
|
|
924
932
|
"billing.sessions": "Sessions",
|
|
925
933
|
"billing.sessionId": "Session",
|
|
@@ -986,6 +994,9 @@ const I18n = (() => {
|
|
|
986
994
|
"chat.newMessageHint": "有新消息 ↓",
|
|
987
995
|
"chat.retry": "重试",
|
|
988
996
|
"chat.copy": "复制",
|
|
997
|
+
"chat.todo.spawn": "开干",
|
|
998
|
+
"chat.todo.spawning": "创建中…",
|
|
999
|
+
"chat.todo.spawnFailed": "创建会话失败:{msg}",
|
|
989
1000
|
"chat.copied": "已复制",
|
|
990
1001
|
"chat.continue": "继续",
|
|
991
1002
|
"chat.edit": "编辑",
|
|
@@ -1481,12 +1492,14 @@ const I18n = (() => {
|
|
|
1481
1492
|
"settings.models.badge.default": "默认",
|
|
1482
1493
|
"settings.models.badge.lite": "轻量",
|
|
1483
1494
|
"settings.media.title": "配置副模型",
|
|
1484
|
-
"settings.media.desc": "
|
|
1495
|
+
"settings.media.desc": "图片生成 / 视频生成 / 音频生成 / 语音转写 / 视频理解 / 视觉理解(可选)",
|
|
1485
1496
|
"settings.media.loading": "加载中…",
|
|
1486
1497
|
"settings.media.error": "加载失败:{{msg}}",
|
|
1487
1498
|
"settings.media.kind.image": "图片生成",
|
|
1488
1499
|
"settings.media.kind.video": "视频生成",
|
|
1489
1500
|
"settings.media.kind.audio": "音频生成",
|
|
1501
|
+
"settings.media.kind.stt": "语音转写",
|
|
1502
|
+
"settings.media.kind.video_understanding": "视频理解",
|
|
1490
1503
|
"settings.media.kind.ocr": "视觉理解",
|
|
1491
1504
|
"settings.media.source.off": "关闭",
|
|
1492
1505
|
"settings.media.source.auto": "自动",
|
|
@@ -1514,6 +1527,13 @@ const I18n = (() => {
|
|
|
1514
1527
|
"settings.media.apiKey.required": "请填写 API Key",
|
|
1515
1528
|
"settings.media.model.required": "请填写模型名称",
|
|
1516
1529
|
"settings.media.baseUrl.required": "请填写 Base URL",
|
|
1530
|
+
"settings.media.output_dir.desc": "生成的图片、视频、音频文件保存位置(可选)",
|
|
1531
|
+
"settings.media.output_dir.browse": "选择目录…",
|
|
1532
|
+
"settings.media.output_dir.picker": "选择媒体输出目录",
|
|
1533
|
+
"settings.media.output_dir.clear": "清除",
|
|
1534
|
+
"settings.media.output_dir.saved": "已保存",
|
|
1535
|
+
"settings.media.output_dir.cleared": "已清除",
|
|
1536
|
+
"settings.media.output_dir.invalid": "目录无效",
|
|
1517
1537
|
"settings.models.field.quicksetup": "快速配置",
|
|
1518
1538
|
"settings.models.field.model": "Model",
|
|
1519
1539
|
"settings.models.field.baseurl": "Base URL",
|
|
@@ -1644,6 +1664,8 @@ const I18n = (() => {
|
|
|
1644
1664
|
"settings.currency.updated": "已从 {{source}} 更新,日期 {{date}}",
|
|
1645
1665
|
"settings.currency.updateFailed": "获取最新汇率失败,仍可手动输入。",
|
|
1646
1666
|
|
|
1667
|
+
"settings.accentColor.title": "主色",
|
|
1668
|
+
|
|
1647
1669
|
// ── Onboard ──
|
|
1648
1670
|
"onboard.title": "欢迎使用 {{brand}}",
|
|
1649
1671
|
"onboard.subtitle": "一分钟完成配置,马上开始。",
|
|
@@ -1838,6 +1860,7 @@ const I18n = (() => {
|
|
|
1838
1860
|
|
|
1839
1861
|
"error.insufficient_credit": "LLM(大模型)余额不足,请充值后继续使用。",
|
|
1840
1862
|
"error.insufficient_credit.action": "去充值",
|
|
1863
|
+
"error.show_detail": "查看详情",
|
|
1841
1864
|
"billing.sessions": "会话消耗",
|
|
1842
1865
|
"billing.sessionId": "会话",
|
|
1843
1866
|
"billing.tokens": "Token",
|
data/lib/clacky/web/index.html
CHANGED
|
@@ -890,6 +890,21 @@
|
|
|
890
890
|
</label>
|
|
891
891
|
</div>
|
|
892
892
|
</section>
|
|
893
|
+
|
|
894
|
+
<!-- Accent Color section -->
|
|
895
|
+
<section class="settings-section" id="accent-color-section">
|
|
896
|
+
<div class="settings-section-title">
|
|
897
|
+
<span data-i18n="settings.accentColor.title">Accent Color</span>
|
|
898
|
+
</div>
|
|
899
|
+
<div class="settings-accent-swatches">
|
|
900
|
+
<button class="settings-accent-swatch swatch-indigo" data-color="#4f46e5" title="Indigo"></button>
|
|
901
|
+
<button class="settings-accent-swatch swatch-aurora-blue" data-color="#3B82F6" title="Aurora Blue"></button>
|
|
902
|
+
<button class="settings-accent-swatch swatch-forest-green" data-color="#10B981" title="Forest Green"></button>
|
|
903
|
+
<button class="settings-accent-swatch swatch-sunrise-orange" data-color="#F59E0B" title="Sunrise Orange"></button>
|
|
904
|
+
<button class="settings-accent-swatch swatch-rose-violet" data-color="#8B5CF6" title="Rose Violet"></button>
|
|
905
|
+
<button class="settings-accent-swatch swatch-coral-red" data-color="#EF4444" title="Coral Red"></button>
|
|
906
|
+
</div>
|
|
907
|
+
</section>
|
|
893
908
|
</div>
|
|
894
909
|
|
|
895
910
|
<!-- ══ Tab: General ══ -->
|
data/lib/clacky/web/sessions.js
CHANGED
|
@@ -411,42 +411,27 @@ const Sessions = (() => {
|
|
|
411
411
|
_hideNewMessageBanner();
|
|
412
412
|
});
|
|
413
413
|
|
|
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
|
|
414
|
+
// Single source of truth for "is the user browsing history?": the scroll
|
|
415
|
+
// position itself. Every scrolling method — mouse wheel, dragging the
|
|
416
|
+
// scrollbar, keyboard (Up/PageUp/Home/Space), touch swipe and momentum
|
|
417
|
+
// scrolling — funnels through the `scroll` event, so reading the position
|
|
418
|
+
// here covers them all with no blind spots.
|
|
419
|
+
//
|
|
420
|
+
// The previous approach instead listened to specific input events
|
|
421
|
+
// (wheel/keydown/touchmove) to *infer* intent. Dragging the scrollbar
|
|
422
|
+
// fires `scroll` but none of those, so it was never detected and AI
|
|
423
|
+
// messages kept yanking the view back to the bottom — see C-5629.
|
|
424
|
+
//
|
|
425
|
+
// Streaming-append safety: appending content grows scrollHeight but leaves
|
|
426
|
+
// scrollTop untouched (verified in-browser, even with overflow-anchor:auto),
|
|
427
|
+
// so a content update never moves us "away from bottom" and never trips a
|
|
428
|
+
// false positive here. The 150px threshold in _isAtBottom is extra slack.
|
|
446
429
|
messages.addEventListener("scroll", () => {
|
|
447
430
|
if (_isAtBottom(messages)) {
|
|
448
431
|
_userScrolledUp = false;
|
|
449
432
|
_hideNewMessageBanner();
|
|
433
|
+
} else {
|
|
434
|
+
_userScrolledUp = true;
|
|
450
435
|
}
|
|
451
436
|
});
|
|
452
437
|
}
|
|
@@ -468,30 +453,26 @@ const Sessions = (() => {
|
|
|
468
453
|
document.getElementById("btn-new-session-inline")
|
|
469
454
|
.addEventListener("click", () => Sessions.create("general"));
|
|
470
455
|
|
|
471
|
-
// Split button: arrow
|
|
472
|
-
document.getElementById("btn-new-session-arrow")
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
const dd = document.getElementById("new-session-dropdown");
|
|
476
|
-
dd.hidden = !dd.hidden;
|
|
477
|
-
});
|
|
456
|
+
// Split button: arrow — show dropdown on hover, hide when leaving the whole wrap
|
|
457
|
+
const arrow = document.getElementById("btn-new-session-arrow");
|
|
458
|
+
const wrap = arrow.closest(".btn-split-wrap");
|
|
459
|
+
const dd = document.getElementById("new-session-dropdown");
|
|
478
460
|
|
|
479
|
-
|
|
480
|
-
|
|
461
|
+
arrow.addEventListener("mouseenter", () => { dd.hidden = false; });
|
|
462
|
+
dd.addEventListener("mouseenter", () => { dd.hidden = false; });
|
|
463
|
+
wrap.addEventListener("mouseleave", () => { dd.hidden = true; });
|
|
464
|
+
dd.addEventListener("mouseleave", () => { dd.hidden = true; });
|
|
465
|
+
|
|
466
|
+
// Dropdown item "Advanced Options…"
|
|
481
467
|
document.addEventListener("click", (e) => {
|
|
482
468
|
if (e.target && e.target.id === "btn-new-session-modal") {
|
|
483
|
-
|
|
484
|
-
document.getElementById("new-session-dropdown").hidden = true;
|
|
469
|
+
dd.hidden = true;
|
|
485
470
|
Sessions.openNewSessionModal();
|
|
471
|
+
} else if (!wrap.contains(e.target)) {
|
|
472
|
+
dd.hidden = true;
|
|
486
473
|
}
|
|
487
474
|
});
|
|
488
475
|
|
|
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
476
|
// Welcome screen "+ New Session" button
|
|
496
477
|
document.getElementById("btn-welcome-new")
|
|
497
478
|
.addEventListener("click", () => Sessions.create("general"));
|
|
@@ -1404,6 +1385,7 @@ const Sessions = (() => {
|
|
|
1404
1385
|
el.dataset.raw = ev.content || "";
|
|
1405
1386
|
el.innerHTML = _renderMarkdown(ev.content || "");
|
|
1406
1387
|
_appendCopyButton(el);
|
|
1388
|
+
_enhanceTaskItems(el, ev.content || "");
|
|
1407
1389
|
container.appendChild(el);
|
|
1408
1390
|
break;
|
|
1409
1391
|
}
|
|
@@ -1667,6 +1649,7 @@ const Sessions = (() => {
|
|
|
1667
1649
|
code: session.error_code,
|
|
1668
1650
|
message: session.error,
|
|
1669
1651
|
top_up_url: session.top_up_url,
|
|
1652
|
+
raw_message: session.raw_message,
|
|
1670
1653
|
});
|
|
1671
1654
|
} else {
|
|
1672
1655
|
Sessions.appendMsg("error", session.error);
|
|
@@ -1925,6 +1908,78 @@ const Sessions = (() => {
|
|
|
1925
1908
|
_ensureCopyDelegation();
|
|
1926
1909
|
}
|
|
1927
1910
|
|
|
1911
|
+
// System feature: any assistant message containing GFM task-list items
|
|
1912
|
+
// (`- [ ] ...`) gets a "spawn" button on each UNCHECKED item, turning a
|
|
1913
|
+
// todo into its own session. The new session's first prompt is the item
|
|
1914
|
+
// text plus the full message for context.
|
|
1915
|
+
function _stripThink(text) {
|
|
1916
|
+
if (!text) return "";
|
|
1917
|
+
return text.replace(/<think>[\s\S]*?<\/think>/g, "").replace(/^\s+/, "");
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
function _enhanceTaskItems(el, rawText) {
|
|
1921
|
+
const seen = new Set();
|
|
1922
|
+
const context = _stripThink(rawText);
|
|
1923
|
+
el.querySelectorAll("li").forEach((li) => {
|
|
1924
|
+
const box = li.querySelector('input[type="checkbox"]');
|
|
1925
|
+
if (!box || box.checked) return; // only unchecked todos
|
|
1926
|
+
if (seen.has(li)) return;
|
|
1927
|
+
seen.add(li);
|
|
1928
|
+
|
|
1929
|
+
const text = li.textContent.trim();
|
|
1930
|
+
if (!text) return;
|
|
1931
|
+
|
|
1932
|
+
const btn = document.createElement("button");
|
|
1933
|
+
btn.type = "button";
|
|
1934
|
+
btn.className = "msg-todo-spawn";
|
|
1935
|
+
btn.title = I18n.t("chat.todo.spawn");
|
|
1936
|
+
btn.textContent = I18n.t("chat.todo.spawn");
|
|
1937
|
+
btn.dataset.todoText = text;
|
|
1938
|
+
btn.dataset.todoContext = context;
|
|
1939
|
+
li.appendChild(btn);
|
|
1940
|
+
});
|
|
1941
|
+
_ensureTodoSpawnDelegation();
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
let _todoSpawnDelegationInstalled = false;
|
|
1945
|
+
function _ensureTodoSpawnDelegation() {
|
|
1946
|
+
if (_todoSpawnDelegationInstalled) return;
|
|
1947
|
+
const messages = RenderTarget.outer();
|
|
1948
|
+
if (!messages) return;
|
|
1949
|
+
_todoSpawnDelegationInstalled = true;
|
|
1950
|
+
messages.addEventListener("click", (e) => {
|
|
1951
|
+
const btn = e.target.closest(".msg-todo-spawn");
|
|
1952
|
+
if (!btn) return;
|
|
1953
|
+
e.preventDefault();
|
|
1954
|
+
e.stopPropagation();
|
|
1955
|
+
_spawnFromTodo(btn);
|
|
1956
|
+
});
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
async function _spawnFromTodo(btn) {
|
|
1960
|
+
if (btn.disabled) return;
|
|
1961
|
+
btn.disabled = true;
|
|
1962
|
+
const original = btn.textContent;
|
|
1963
|
+
btn.textContent = I18n.t("chat.todo.spawning");
|
|
1964
|
+
|
|
1965
|
+
const todoText = btn.dataset.todoText || "";
|
|
1966
|
+
const context = btn.dataset.todoContext || "";
|
|
1967
|
+
const prompt =
|
|
1968
|
+
`Task:\n${todoText}\n\n` +
|
|
1969
|
+
`--- Context (the message this task came from) ---\n${context}`;
|
|
1970
|
+
|
|
1971
|
+
try {
|
|
1972
|
+
await Sessions.startWith(prompt, {
|
|
1973
|
+
name: todoText.slice(0, 60),
|
|
1974
|
+
display: `📋 ${todoText}`,
|
|
1975
|
+
});
|
|
1976
|
+
} catch (err) {
|
|
1977
|
+
btn.disabled = false;
|
|
1978
|
+
btn.textContent = original;
|
|
1979
|
+
alert(I18n.t("chat.todo.spawnFailed", { msg: err.message }));
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1928
1983
|
// Install the click-delegation listener on #messages exactly once.
|
|
1929
1984
|
// Handles copy clicks for all current AND future assistant bubbles
|
|
1930
1985
|
// AND code block copy buttons.
|
|
@@ -2338,6 +2393,36 @@ const Sessions = (() => {
|
|
|
2338
2393
|
}
|
|
2339
2394
|
},
|
|
2340
2395
|
|
|
2396
|
+
// Create a session, queue a command to run once subscribed, and navigate to
|
|
2397
|
+
// it. The user bubble is rendered locally on the "subscribed" event (see
|
|
2398
|
+
// ws-dispatcher), so callers never touch history or WS timing.
|
|
2399
|
+
// Returns the created session.
|
|
2400
|
+
async startWith(command, { name, source = "setup", display = null } = {}) {
|
|
2401
|
+
if (!name) {
|
|
2402
|
+
const maxN = _sessions.reduce((max, s) => {
|
|
2403
|
+
const m = s.name && s.name.match(/^Session (\d+)$/);
|
|
2404
|
+
return m ? Math.max(max, parseInt(m[1], 10)) : max;
|
|
2405
|
+
}, 0);
|
|
2406
|
+
name = "Session " + (maxN + 1);
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
const res = await fetch("/api/sessions", {
|
|
2410
|
+
method: "POST",
|
|
2411
|
+
headers: { "Content-Type": "application/json" },
|
|
2412
|
+
body: JSON.stringify({ name, source }),
|
|
2413
|
+
});
|
|
2414
|
+
const data = await res.json();
|
|
2415
|
+
if (!res.ok) throw new Error(data.error || "failed to create session");
|
|
2416
|
+
const session = data.session;
|
|
2417
|
+
if (!session) throw new Error("no session returned");
|
|
2418
|
+
|
|
2419
|
+
Sessions.add(session);
|
|
2420
|
+
Sessions.renderList();
|
|
2421
|
+
Sessions.setPendingMessage(session.id, command, display);
|
|
2422
|
+
Sessions.select(session.id);
|
|
2423
|
+
return session;
|
|
2424
|
+
},
|
|
2425
|
+
|
|
2341
2426
|
/** Patch a single session's fields (from session_update event).
|
|
2342
2427
|
* If the session is not in the list yet (e.g. just created by another tab),
|
|
2343
2428
|
* prepend it so the sidebar shows it immediately. */
|
|
@@ -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). */
|