openclacky 1.3.5 → 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 +12 -0
- data/lib/clacky/agent/message_compressor.rb +32 -8
- data/lib/clacky/agent/message_compressor_helper.rb +113 -12
- data/lib/clacky/agent.rb +0 -3
- data/lib/clacky/cli.rb +0 -1
- data/lib/clacky/server/http_server.rb +122 -119
- data/lib/clacky/server/session_registry.rb +50 -1
- data/lib/clacky/session_manager.rb +35 -4
- 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 +0 -9
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +54 -1
- 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/tasks/view.js +77 -28
- data/lib/clacky/web/i18n.js +22 -2
- data/lib/clacky/web/index.html +52 -26
- data/lib/clacky/web/sessions.js +36 -36
- data/lib/clacky/web/ws-dispatcher.js +1 -1
- metadata +1 -1
|
@@ -13,9 +13,7 @@ const Sidebar = (() => {
|
|
|
13
13
|
function init() {
|
|
14
14
|
// Settings button toggles between "settings" and "welcome" view.
|
|
15
15
|
document.getElementById("btn-settings").addEventListener("click", () => {
|
|
16
|
-
if (Router.current
|
|
17
|
-
Router.navigate("welcome");
|
|
18
|
-
} else {
|
|
16
|
+
if (Router.current !== "settings") {
|
|
19
17
|
Router.navigate("settings");
|
|
20
18
|
}
|
|
21
19
|
});
|
|
@@ -66,6 +66,29 @@ const BackupStore = (() => {
|
|
|
66
66
|
}
|
|
67
67
|
},
|
|
68
68
|
|
|
69
|
+
async openFolder() {
|
|
70
|
+
try {
|
|
71
|
+
await fetch("/api/backup/open-folder", { method: "POST" });
|
|
72
|
+
} catch (e) {
|
|
73
|
+
// Non-critical, fail quietly.
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
/** Upload a tar.gz archive to restore ~/.clacky. Returns { ok, error }. */
|
|
78
|
+
async restore(arrayBuffer) {
|
|
79
|
+
try {
|
|
80
|
+
const res = await fetch("/api/backup/restore", {
|
|
81
|
+
method: "POST",
|
|
82
|
+
headers: { "Content-Type": "application/octet-stream" },
|
|
83
|
+
body: arrayBuffer
|
|
84
|
+
});
|
|
85
|
+
const data = await res.json().catch(() => ({}));
|
|
86
|
+
return res.ok ? { ok: true } : { ok: false, error: data.error || "Restore failed" };
|
|
87
|
+
} catch (e) {
|
|
88
|
+
return { ok: false, error: e.message };
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
|
|
69
92
|
/** Fetch a one-off archive. Returns { ok, blob, filename, error }. */
|
|
70
93
|
async fetchArchive() {
|
|
71
94
|
try {
|
|
@@ -1,17 +1,8 @@
|
|
|
1
|
-
// ── Backup · view — settings rendering, toggles, download UI
|
|
2
|
-
//
|
|
3
|
-
// Renders the backup settings panel, wires the toggles + download button, and
|
|
4
|
-
// drives the file download from a blob. Reads through BackupStore.state; all
|
|
5
|
-
// I/O goes through store actions. Re-renders on store change events.
|
|
6
|
-
//
|
|
7
|
-
// Augments the `Backup` facade with load (re-exposing the store action so the
|
|
8
|
-
// existing Settings caller keeps working).
|
|
9
|
-
//
|
|
10
|
-
// Depends on: BackupStore, I18n.
|
|
11
|
-
// ───────────────────────────────────────────────────────────────────────────
|
|
1
|
+
// ── Backup · view — settings rendering, toggles, download/restore UI ──────
|
|
12
2
|
|
|
13
3
|
const BackupView = (() => {
|
|
14
4
|
const $ = (id) => document.getElementById(id);
|
|
5
|
+
let _restoreStatusEl = null;
|
|
15
6
|
|
|
16
7
|
function _fmtDate(iso) {
|
|
17
8
|
if (!iso) return "";
|
|
@@ -19,8 +10,12 @@ const BackupView = (() => {
|
|
|
19
10
|
}
|
|
20
11
|
|
|
21
12
|
function _render() {
|
|
22
|
-
const cfg
|
|
23
|
-
|
|
13
|
+
const cfg = Backup.state.config;
|
|
14
|
+
const status = Backup.state.status;
|
|
15
|
+
if (!status) return;
|
|
16
|
+
|
|
17
|
+
const autoToggle = $("backup-auto-toggle");
|
|
18
|
+
if (autoToggle) autoToggle.checked = !!cfg.enabled;
|
|
24
19
|
|
|
25
20
|
const incl = $("backup-include-sessions");
|
|
26
21
|
if (incl) {
|
|
@@ -28,8 +23,8 @@ const BackupView = (() => {
|
|
|
28
23
|
incl.disabled = !cfg.enabled;
|
|
29
24
|
}
|
|
30
25
|
|
|
31
|
-
const
|
|
32
|
-
if (
|
|
26
|
+
const destPath = $("backup-dest-path");
|
|
27
|
+
if (destPath) destPath.textContent = status.dest_dir || "—";
|
|
33
28
|
|
|
34
29
|
_renderLastRun(cfg);
|
|
35
30
|
}
|
|
@@ -48,10 +43,10 @@ const BackupView = (() => {
|
|
|
48
43
|
}
|
|
49
44
|
|
|
50
45
|
async function _downloadNow() {
|
|
51
|
-
const btn
|
|
52
|
-
const
|
|
46
|
+
const btn = $("btn-backup-now");
|
|
47
|
+
const statusEl = $("backup-manual-status");
|
|
53
48
|
if (btn) btn.disabled = true;
|
|
54
|
-
if (
|
|
49
|
+
if (statusEl) { statusEl.textContent = I18n.t("settings.backup.downloading"); statusEl.className = "model-test-result"; }
|
|
55
50
|
|
|
56
51
|
const res = await Backup.fetchArchive();
|
|
57
52
|
if (res.ok) {
|
|
@@ -63,14 +58,32 @@ const BackupView = (() => {
|
|
|
63
58
|
a.click();
|
|
64
59
|
a.remove();
|
|
65
60
|
URL.revokeObjectURL(url);
|
|
66
|
-
if (
|
|
67
|
-
} else if (
|
|
68
|
-
|
|
69
|
-
|
|
61
|
+
if (statusEl) { statusEl.textContent = I18n.t("settings.backup.downloaded"); statusEl.className = "model-test-result success"; }
|
|
62
|
+
} else if (statusEl) {
|
|
63
|
+
statusEl.textContent = I18n.t("settings.backup.lastError", { msg: res.error });
|
|
64
|
+
statusEl.className = "model-test-result error";
|
|
70
65
|
}
|
|
71
66
|
if (btn) btn.disabled = false;
|
|
72
67
|
}
|
|
73
68
|
|
|
69
|
+
async function _restoreBackup(e) {
|
|
70
|
+
const file = e.target.files[0];
|
|
71
|
+
if (!file) return;
|
|
72
|
+
e.target.value = "";
|
|
73
|
+
|
|
74
|
+
const statusEl = $("backup-manual-status");
|
|
75
|
+
if (statusEl) { statusEl.textContent = I18n.t("settings.backup.restoring"); statusEl.className = "model-test-result"; }
|
|
76
|
+
|
|
77
|
+
const buf = await file.arrayBuffer();
|
|
78
|
+
const res = await Backup.restore(buf);
|
|
79
|
+
if (res.ok) {
|
|
80
|
+
if (statusEl) { statusEl.textContent = I18n.t("settings.backup.restoreOk"); statusEl.className = "model-test-result success"; }
|
|
81
|
+
_restoreStatusEl = statusEl;
|
|
82
|
+
} else {
|
|
83
|
+
if (statusEl) { statusEl.textContent = res.error || "Restore failed"; statusEl.className = "model-test-result error"; }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
74
87
|
function _bind() {
|
|
75
88
|
const btn = $("btn-backup-now");
|
|
76
89
|
if (btn) btn.addEventListener("click", _downloadNow);
|
|
@@ -80,11 +93,25 @@ const BackupView = (() => {
|
|
|
80
93
|
|
|
81
94
|
const incl = $("backup-include-sessions");
|
|
82
95
|
if (incl) incl.addEventListener("change", () => Backup.saveConfig({ include_sessions: incl.checked }));
|
|
96
|
+
|
|
97
|
+
const openBtn = $("btn-backup-open-folder");
|
|
98
|
+
if (openBtn) openBtn.addEventListener("click", () => Backup.openFolder());
|
|
99
|
+
|
|
100
|
+
const restoreInput = $("input-backup-restore");
|
|
101
|
+
if (restoreInput) restoreInput.addEventListener("change", _restoreBackup);
|
|
83
102
|
}
|
|
84
103
|
|
|
85
104
|
function _subscribe() {
|
|
86
105
|
Backup.on("backup:changed", _render);
|
|
87
106
|
document.addEventListener("DOMContentLoaded", _bind);
|
|
107
|
+
WS.onEvent(ev => {
|
|
108
|
+
if (ev.type !== "_ws_connected" || !_restoreStatusEl) return;
|
|
109
|
+
const el = _restoreStatusEl;
|
|
110
|
+
_restoreStatusEl = null;
|
|
111
|
+
el.textContent = I18n.t("settings.backup.restartOk");
|
|
112
|
+
el.className = "model-test-result success";
|
|
113
|
+
if (typeof Settings !== "undefined") Settings.open();
|
|
114
|
+
});
|
|
88
115
|
}
|
|
89
116
|
|
|
90
117
|
return { init: _subscribe };
|
|
@@ -16,58 +16,107 @@ const TasksView = (() => {
|
|
|
16
16
|
if (!cron) return cron;
|
|
17
17
|
const parts = cron.trim().split(/\s+/);
|
|
18
18
|
if (parts.length !== 5) return cron;
|
|
19
|
-
|
|
19
|
+
let [min, hour, dom, month, dow] = parts;
|
|
20
|
+
|
|
21
|
+
// normalize */1 → *
|
|
22
|
+
if (min === "*/1") min = "*";
|
|
23
|
+
if (hour === "*/1") hour = "*";
|
|
24
|
+
if (dom === "*/1") dom = "*";
|
|
25
|
+
if (month === "*/1") month = "*";
|
|
26
|
+
if (dow === "*/1") dow = "*";
|
|
20
27
|
|
|
21
28
|
const isAny = v => v === "*";
|
|
22
|
-
const
|
|
29
|
+
const isInt = v => /^\d+$/.test(v);
|
|
23
30
|
const pad = n => String(n).padStart(2, "0");
|
|
24
31
|
|
|
25
32
|
const lang = (typeof I18n !== "undefined" && I18n.lang()) || "zh";
|
|
26
33
|
const isZh = lang === "zh";
|
|
27
34
|
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
35
|
+
const DOW_ZH = ["周日","周一","周二","周三","周四","周五","周六"];
|
|
36
|
+
const DOW_EN = ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"];
|
|
37
|
+
const MON_ZH = ["1月","2月","3月","4月","5月","6月","7月","8月","9月","10月","11月","12月"];
|
|
38
|
+
const MON_EN = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
|
|
31
39
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
40
|
+
// dow → label, e.g. "1-5"→"工作日", "1,3,5"→"周一、周三、周五", "1"→"每周一"
|
|
41
|
+
function dowLabel(d) {
|
|
42
|
+
if (isAny(d)) return null;
|
|
43
|
+
if (d === "1-5") return isZh ? "工作日" : "Weekdays";
|
|
44
|
+
if (d === "0,6" || d === "6,0") return isZh ? "周末" : "Weekends";
|
|
45
|
+
if (isInt(d)) {
|
|
46
|
+
const name = (isZh ? DOW_ZH : DOW_EN)[parseInt(d, 10)] || d;
|
|
47
|
+
return isZh ? `每${name}` : name;
|
|
48
|
+
}
|
|
49
|
+
if (/^[\d,]+$/.test(d)) {
|
|
50
|
+
const names = d.split(",").map(n => (isZh ? DOW_ZH : DOW_EN)[parseInt(n, 10)] || n);
|
|
51
|
+
return isZh ? names.join("、") : names.join("/");
|
|
52
|
+
}
|
|
53
|
+
return d;
|
|
54
|
+
}
|
|
35
55
|
|
|
36
|
-
|
|
56
|
+
// build HH:MM string; supports single hour or comma-list like "10,14"
|
|
57
|
+
function timeStr() {
|
|
58
|
+
if (!isInt(min)) return null;
|
|
59
|
+
if (isInt(hour)) return `${pad(hour)}:${pad(min)}`;
|
|
60
|
+
if (/^[\d,]+$/.test(hour))
|
|
61
|
+
return hour.split(",").map(h => `${pad(h)}:${pad(min)}`).join(isZh ? "、" : "/");
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
37
64
|
|
|
65
|
+
// ── every-N-minutes ───────────────────────────────────────────────────
|
|
38
66
|
if (min.startsWith("*/") && isAny(hour) && isAny(dom) && isAny(month) && isAny(dow)) {
|
|
39
67
|
const n = min.slice(2);
|
|
40
68
|
return isZh ? `每 ${n} 分钟` : `Every ${n} min`;
|
|
41
69
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
70
|
+
// ── every-N-hours ─────────────────────────────────────────────────────
|
|
71
|
+
if (isAny(dom) && isAny(month) && isAny(dow)) {
|
|
72
|
+
if (isAny(min) && hour.startsWith("*/")) {
|
|
73
|
+
return isZh ? `每 ${hour.slice(2)} 小时` : `Every ${hour.slice(2)} hr`;
|
|
74
|
+
}
|
|
75
|
+
if (isInt(min) && hour.startsWith("*/")) {
|
|
76
|
+
return isZh ? `每 ${hour.slice(2)} 小时` : `Every ${hour.slice(2)} hr`;
|
|
77
|
+
}
|
|
45
78
|
}
|
|
79
|
+
// ── every minute ──────────────────────────────────────────────────────
|
|
46
80
|
if (isAny(min) && isAny(hour) && isAny(dom) && isAny(month) && isAny(dow)) {
|
|
47
81
|
return isZh ? "每分钟" : "Every minute";
|
|
48
82
|
}
|
|
49
|
-
|
|
83
|
+
// ── hourly at :MM ─────────────────────────────────────────────────────
|
|
84
|
+
if (isInt(min) && isAny(hour) && isAny(dom) && isAny(month) && isAny(dow)) {
|
|
50
85
|
return isZh ? `每小时 :${pad(min)}` : `Hourly at :${pad(min)}`;
|
|
51
86
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
87
|
+
|
|
88
|
+
// ── every-N-min within hour range on certain days e.g. */1 9-14 * * 1-5
|
|
89
|
+
if (min.startsWith("*/") && /^\d+-\d+$/.test(hour) && isAny(dom) && isAny(month)) {
|
|
90
|
+
const n = min.slice(2);
|
|
91
|
+
const dl = dowLabel(dow);
|
|
92
|
+
const hr = isZh ? `${hour}时` : `${hour}h`;
|
|
93
|
+
const ev = isZh ? `每 ${n} 分钟` : `Every ${n} min`;
|
|
94
|
+
return dl ? `${dl} ${hr} ${ev}` : `${hr} ${ev}`;
|
|
58
95
|
}
|
|
59
|
-
|
|
60
|
-
|
|
96
|
+
// ── every minute within hour range e.g. * 9-14 * * 1-5
|
|
97
|
+
if (isAny(min) && /^\d+-\d+$/.test(hour) && isAny(dom) && isAny(month)) {
|
|
98
|
+
const dl = dowLabel(dow);
|
|
99
|
+
const hr = isZh ? `${hour}时` : `${hour}h`;
|
|
100
|
+
const ev = isZh ? "每分钟" : "Every minute";
|
|
101
|
+
return dl ? `${dl} ${hr} ${ev}` : `${hr} ${ev}`;
|
|
61
102
|
}
|
|
62
|
-
|
|
63
|
-
|
|
103
|
+
|
|
104
|
+
const ts = timeStr();
|
|
105
|
+
const dl = dowLabel(dow);
|
|
106
|
+
|
|
107
|
+
// ── fixed time, variable days ─────────────────────────────────────────
|
|
108
|
+
if (ts && isAny(dom) && isAny(month)) {
|
|
109
|
+
if (dl) return `${dl} ${ts}`;
|
|
110
|
+
return isZh ? `每天 ${ts}` : `Daily ${ts}`;
|
|
64
111
|
}
|
|
65
|
-
|
|
66
|
-
|
|
112
|
+
// ── fixed time, fixed day-of-month ────────────────────────────────────
|
|
113
|
+
if (ts && isInt(dom) && isAny(month) && isAny(dow)) {
|
|
114
|
+
return isZh ? `每月 ${dom} 日 ${ts}` : `Monthly day ${dom} ${ts}`;
|
|
67
115
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
116
|
+
// ── fixed time, fixed date ────────────────────────────────────────────
|
|
117
|
+
if (ts && isInt(dom) && isInt(month) && isAny(dow)) {
|
|
118
|
+
const m = (isZh ? MON_ZH : MON_EN)[parseInt(month, 10) - 1] || month;
|
|
119
|
+
return isZh ? `${m}${dom}日 ${ts}` : `${m} ${dom} ${ts}`;
|
|
71
120
|
}
|
|
72
121
|
|
|
73
122
|
return cron;
|
data/lib/clacky/web/i18n.js
CHANGED
|
@@ -552,6 +552,7 @@ const I18n = (() => {
|
|
|
552
552
|
"settings.tabs.models": "Models",
|
|
553
553
|
"settings.tabs.ui": "UI",
|
|
554
554
|
"settings.tabs.general": "General",
|
|
555
|
+
"settings.tabs.data": "Data Management",
|
|
555
556
|
"settings.tabs.about": "About",
|
|
556
557
|
"settings.about.version": "Version",
|
|
557
558
|
"settings.about.appName": "Application",
|
|
@@ -674,13 +675,22 @@ const I18n = (() => {
|
|
|
674
675
|
"settings.network.cleared": "Cleared — direct connection",
|
|
675
676
|
"settings.network.invalidUrl": "Invalid URL — use http://host:port or http://user:pass@host:port",
|
|
676
677
|
"settings.backup.title": "Backup",
|
|
678
|
+
"settings.backup.autoTitle": "Auto Backup",
|
|
679
|
+
"settings.backup.manualTitle": "Manual Backup",
|
|
677
680
|
"settings.backup.desc": "Back up your ~/.clacky directory (config, skills, memories, tasks, sessions). Regenerable caches and logs are excluded.",
|
|
678
681
|
"settings.backup.includeSessions": "Include session history (larger archive)",
|
|
679
682
|
"settings.backup.autoLabel": "Automatic backup",
|
|
680
683
|
"settings.backup.autoHint": "Daily at 03:00, keeps the latest 7",
|
|
681
|
-
"settings.backup.
|
|
684
|
+
"settings.backup.destLabel": "Backup folder",
|
|
685
|
+
"settings.backup.openFolder": "Open",
|
|
686
|
+
"settings.backup.runNow": "Download backup",
|
|
687
|
+
"settings.backup.restoreBtn": "Restore backup",
|
|
682
688
|
"settings.backup.running": "Preparing backup…",
|
|
689
|
+
"settings.backup.downloading": "Downloading…",
|
|
683
690
|
"settings.backup.downloaded": "Backup downloaded.",
|
|
691
|
+
"settings.backup.restoring": "Restoring… server will restart automatically.",
|
|
692
|
+
"settings.backup.restoreOk": "Restored. Restarting…",
|
|
693
|
+
"settings.backup.restartOk": "Restarted successfully.",
|
|
684
694
|
"settings.backup.lastOk": "Last backup: {{time}}",
|
|
685
695
|
"settings.backup.lastError": "Backup failed: {{msg}}",
|
|
686
696
|
"settings.brand.title": "Brand & License",
|
|
@@ -1477,6 +1487,7 @@ const I18n = (() => {
|
|
|
1477
1487
|
"settings.tabs.models": "模型",
|
|
1478
1488
|
"settings.tabs.ui": "界面",
|
|
1479
1489
|
"settings.tabs.general": "通用",
|
|
1490
|
+
"settings.tabs.data": "数据管理",
|
|
1480
1491
|
"settings.tabs.about": "关于",
|
|
1481
1492
|
"settings.about.version": "版本",
|
|
1482
1493
|
"settings.about.appName": "应用程序",
|
|
@@ -1606,13 +1617,22 @@ const I18n = (() => {
|
|
|
1606
1617
|
"settings.network.cleared": "已清除 — 直连",
|
|
1607
1618
|
"settings.network.invalidUrl": "地址格式不正确,请使用 http://host:port 或 http://user:pass@host:port",
|
|
1608
1619
|
"settings.backup.title": "备份",
|
|
1620
|
+
"settings.backup.autoTitle": "自动备份",
|
|
1621
|
+
"settings.backup.manualTitle": "手动备份",
|
|
1609
1622
|
"settings.backup.desc": "备份你的 ~/.clacky 目录(配置、技能、记忆、任务、会话)。可重新生成的缓存和日志会被排除。",
|
|
1610
1623
|
"settings.backup.includeSessions": "包含会话历史(归档更大)",
|
|
1611
1624
|
"settings.backup.autoLabel": "自动备份",
|
|
1612
1625
|
"settings.backup.autoHint": "每天 03:00 自动备份,保留最近 7 份",
|
|
1613
|
-
"settings.backup.
|
|
1626
|
+
"settings.backup.destLabel": "备份目录",
|
|
1627
|
+
"settings.backup.openFolder": "打开",
|
|
1628
|
+
"settings.backup.runNow": "下载备份",
|
|
1629
|
+
"settings.backup.restoreBtn": "恢复备份",
|
|
1614
1630
|
"settings.backup.running": "正在准备备份…",
|
|
1631
|
+
"settings.backup.downloading": "正在下载…",
|
|
1615
1632
|
"settings.backup.downloaded": "备份已下载。",
|
|
1633
|
+
"settings.backup.restoring": "正在恢复…服务将自动重启。",
|
|
1634
|
+
"settings.backup.restoreOk": "恢复成功,正在重启…",
|
|
1635
|
+
"settings.backup.restartOk": "重启成功。",
|
|
1616
1636
|
"settings.backup.lastOk": "上次备份:{{time}}",
|
|
1617
1637
|
"settings.backup.lastError": "备份失败:{{msg}}",
|
|
1618
1638
|
"settings.brand.title": "品牌 & 授权",
|
data/lib/clacky/web/index.html
CHANGED
|
@@ -805,6 +805,7 @@
|
|
|
805
805
|
<button class="settings-tab active" data-tab="models" data-i18n="settings.tabs.models">Models</button>
|
|
806
806
|
<button class="settings-tab" data-tab="ui" data-i18n="settings.tabs.ui">UI</button>
|
|
807
807
|
<button class="settings-tab" data-tab="general" data-i18n="settings.tabs.general">General</button>
|
|
808
|
+
<button class="settings-tab" data-tab="data" data-i18n="settings.tabs.data">Data</button>
|
|
808
809
|
<button class="settings-tab" data-tab="about" data-i18n="settings.tabs.about">About</button>
|
|
809
810
|
<!-- Extension tab slot: extensions add custom settings tabs here (button needs data-tab="<id>"). -->
|
|
810
811
|
<span id="ext-slot-settings-tabs" data-slot="settings.tabs"></span>
|
|
@@ -957,32 +958,7 @@
|
|
|
957
958
|
</section>
|
|
958
959
|
|
|
959
960
|
<!-- Backup section -->
|
|
960
|
-
<section class="settings-section" id="backup-section">
|
|
961
|
-
<div class="settings-section-title">
|
|
962
|
-
<span data-i18n="settings.backup.title">Backup</span>
|
|
963
|
-
</div>
|
|
964
|
-
<p class="settings-section-desc" data-i18n="settings.backup.desc">Back up your ~/.clacky directory (config, skills, memories, tasks, sessions). Regenerable caches and logs are excluded.</p>
|
|
965
|
-
|
|
966
|
-
<div class="backup-auto-card">
|
|
967
|
-
<div class="backup-auto-row">
|
|
968
|
-
<label class="toggle-switch">
|
|
969
|
-
<input type="checkbox" id="backup-auto-toggle">
|
|
970
|
-
<span class="toggle-slider"></span>
|
|
971
|
-
</label>
|
|
972
|
-
<span class="backup-auto-label" data-i18n="settings.backup.autoLabel">Automatic backup</span>
|
|
973
|
-
<span class="backup-auto-hint" data-i18n="settings.backup.autoHint">Daily at 03:00, keeps the latest 7</span>
|
|
974
|
-
</div>
|
|
975
|
-
|
|
976
|
-
<label class="backup-option backup-option-nested">
|
|
977
|
-
<input type="checkbox" id="backup-include-sessions">
|
|
978
|
-
<span data-i18n="settings.backup.includeSessions">Include session history (larger archive)</span>
|
|
979
|
-
</label>
|
|
980
|
-
</div>
|
|
981
|
-
|
|
982
|
-
<div class="backup-actions">
|
|
983
|
-
<button id="btn-backup-now" class="btn-settings-action" data-i18n="settings.backup.runNow">Download backup (full snapshot)</button>
|
|
984
|
-
<span id="backup-status" class="model-test-result"></span>
|
|
985
|
-
</div>
|
|
961
|
+
<section class="settings-section" id="backup-section" style="display:none">
|
|
986
962
|
</section>
|
|
987
963
|
|
|
988
964
|
<!-- Brand & License section -->
|
|
@@ -1073,6 +1049,56 @@
|
|
|
1073
1049
|
</section>
|
|
1074
1050
|
</div><!-- end Tab: General -->
|
|
1075
1051
|
|
|
1052
|
+
<!-- ══ Tab: Data ══ -->
|
|
1053
|
+
<div class="settings-tab-content" data-tab-content="data" style="display:none">
|
|
1054
|
+
|
|
1055
|
+
<!-- 自动备份 -->
|
|
1056
|
+
<section class="settings-section">
|
|
1057
|
+
<div class="settings-section-title">
|
|
1058
|
+
<span data-i18n="settings.backup.autoTitle">Auto Backup</span>
|
|
1059
|
+
</div>
|
|
1060
|
+
<p class="settings-section-desc" data-i18n="settings.backup.desc">Back up your ~/.clacky directory (config, skills, memories, tasks, sessions). Regenerable caches and logs are excluded.</p>
|
|
1061
|
+
|
|
1062
|
+
<div class="backup-auto-card">
|
|
1063
|
+
<div class="backup-auto-row">
|
|
1064
|
+
<label class="toggle-switch">
|
|
1065
|
+
<input type="checkbox" id="backup-auto-toggle">
|
|
1066
|
+
<span class="toggle-slider"></span>
|
|
1067
|
+
</label>
|
|
1068
|
+
<span class="backup-auto-label" data-i18n="settings.backup.autoLabel">Automatic backup</span>
|
|
1069
|
+
<span class="backup-auto-hint" data-i18n="settings.backup.autoHint">Daily at 03:00, keeps the latest 7</span>
|
|
1070
|
+
</div>
|
|
1071
|
+
<label class="backup-option backup-option-nested">
|
|
1072
|
+
<input type="checkbox" id="backup-include-sessions">
|
|
1073
|
+
<span data-i18n="settings.backup.includeSessions">Include session history (larger archive)</span>
|
|
1074
|
+
</label>
|
|
1075
|
+
<div class="backup-dest-row">
|
|
1076
|
+
<span class="backup-dest-label" data-i18n="settings.backup.destLabel">Backup folder</span>
|
|
1077
|
+
<span class="backup-dest-path" id="backup-dest-path">—</span>
|
|
1078
|
+
<button id="btn-backup-open-folder" class="btn-settings-link" data-i18n="settings.backup.openFolder">Open</button>
|
|
1079
|
+
</div>
|
|
1080
|
+
<div id="backup-status" class="model-test-result"></div>
|
|
1081
|
+
</div>
|
|
1082
|
+
</section>
|
|
1083
|
+
|
|
1084
|
+
<!-- 手动备份 -->
|
|
1085
|
+
<section class="settings-section">
|
|
1086
|
+
<div class="settings-section-title">
|
|
1087
|
+
<span data-i18n="settings.backup.manualTitle">Manual Backup</span>
|
|
1088
|
+
</div>
|
|
1089
|
+
|
|
1090
|
+
<div class="backup-actions">
|
|
1091
|
+
<button id="btn-backup-now" class="btn-settings-action" data-i18n="settings.backup.runNow">Download backup</button>
|
|
1092
|
+
<label class="btn-settings-action btn-settings-action--file">
|
|
1093
|
+
<span data-i18n="settings.backup.restoreBtn">Restore backup</span>
|
|
1094
|
+
<input type="file" id="input-backup-restore" accept=".tar.gz,.gz" style="display:none">
|
|
1095
|
+
</label>
|
|
1096
|
+
<span id="backup-manual-status" class="model-test-result"></span>
|
|
1097
|
+
</div>
|
|
1098
|
+
</section>
|
|
1099
|
+
|
|
1100
|
+
</div><!-- end Tab: Data -->
|
|
1101
|
+
|
|
1076
1102
|
<!-- ══ Tab: About ══ -->
|
|
1077
1103
|
<div class="settings-tab-content" data-tab-content="about" style="display:none">
|
|
1078
1104
|
<!-- Version section -->
|
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
|
|
@@ -2378,11 +2379,12 @@ const Sessions = (() => {
|
|
|
2378
2379
|
// ── List management ───────────────────────────────────────────────────
|
|
2379
2380
|
|
|
2380
2381
|
/** Populate list from initial session_list WS event (connect only). */
|
|
2381
|
-
setAll(list, hasMore = false, cronCount = 0) {
|
|
2382
|
+
setAll(list, hasMore = false, cronCount = 0, latestCronUpdatedAt = null) {
|
|
2382
2383
|
_sessions.length = 0;
|
|
2383
2384
|
_sessions.push(...list);
|
|
2384
|
-
_hasMore
|
|
2385
|
-
_cronCount
|
|
2385
|
+
_hasMore = !!hasMore;
|
|
2386
|
+
_cronCount = cronCount;
|
|
2387
|
+
_latestCronUpdatedAt = latestCronUpdatedAt || null;
|
|
2386
2388
|
},
|
|
2387
2389
|
|
|
2388
2390
|
/** Insert a newly created session into the local list. */
|
|
@@ -2433,6 +2435,14 @@ const Sessions = (() => {
|
|
|
2433
2435
|
} else {
|
|
2434
2436
|
_sessions.unshift({ id, ...fields });
|
|
2435
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
|
+
}
|
|
2436
2446
|
},
|
|
2437
2447
|
|
|
2438
2448
|
/** Remove a session from the list (from session_deleted event). */
|
|
@@ -2460,21 +2470,17 @@ const Sessions = (() => {
|
|
|
2460
2470
|
Sessions.renderList();
|
|
2461
2471
|
|
|
2462
2472
|
try {
|
|
2463
|
-
// Cursor: oldest activity time
|
|
2464
|
-
//
|
|
2465
|
-
//
|
|
2466
|
-
// bypass pagination), so their time is irrelevant for the cursor.
|
|
2467
|
-
// Including them here would cause the cursor to jump too far back and
|
|
2468
|
-
// skip sessions between the oldest pinned one and the real
|
|
2469
|
-
// 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.
|
|
2470
2476
|
const oldest = _sessions.reduce((min, s) => {
|
|
2471
|
-
if (s.pinned) return min;
|
|
2477
|
+
if (s.pinned || s.source === "cron") return min;
|
|
2472
2478
|
const t = s.updated_at || s.created_at;
|
|
2473
2479
|
if (!t) return min;
|
|
2474
2480
|
return (!min || t < min) ? t : min;
|
|
2475
2481
|
}, null);
|
|
2476
2482
|
|
|
2477
|
-
const params = new URLSearchParams({ limit: "20" });
|
|
2483
|
+
const params = new URLSearchParams({ limit: "20", exclude_type: "cron" });
|
|
2478
2484
|
if (oldest) params.set("before", oldest);
|
|
2479
2485
|
if (_filter.q) params.set("q", _filter.q);
|
|
2480
2486
|
if (_filter.date) params.set("date", _filter.date);
|
|
@@ -2487,8 +2493,9 @@ const Sessions = (() => {
|
|
|
2487
2493
|
(data.sessions || []).forEach(s => {
|
|
2488
2494
|
if (!_sessions.find(x => x.id === s.id)) _sessions.push(s);
|
|
2489
2495
|
});
|
|
2490
|
-
_hasMore
|
|
2491
|
-
_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;
|
|
2492
2499
|
} catch (e) {
|
|
2493
2500
|
console.error("loadMore error:", e);
|
|
2494
2501
|
} finally {
|
|
@@ -2884,35 +2891,28 @@ const Sessions = (() => {
|
|
|
2884
2891
|
}
|
|
2885
2892
|
cronSessions.forEach(s => _renderSessionItem(list, s));
|
|
2886
2893
|
} else if (_cronCount > 0) {
|
|
2887
|
-
// Normal list view:
|
|
2888
|
-
//
|
|
2889
|
-
//
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
// would sort by created_at. Pinning is intentionally ignored for the
|
|
2893
|
-
// entry itself: a pinned cron session only takes effect *inside* the
|
|
2894
|
-
// folded sub-view, not on the outer entry.
|
|
2895
|
-
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");
|
|
2896
2899
|
let cronEntryRendered = false;
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
if (!
|
|
2900
|
+
|
|
2901
|
+
nonCron.forEach(s => {
|
|
2902
|
+
if (!cronEntryRendered && _latestCronUpdatedAt) {
|
|
2903
|
+
const t = s.updated_at || s.created_at;
|
|
2904
|
+
if (!t || t < _latestCronUpdatedAt) {
|
|
2902
2905
|
_renderCronGroupItem(list, _cronCount, cronHasRunning);
|
|
2903
2906
|
cronEntryRendered = true;
|
|
2904
2907
|
}
|
|
2905
|
-
return;
|
|
2906
2908
|
}
|
|
2907
2909
|
_renderSessionItem(list, s);
|
|
2908
2910
|
});
|
|
2909
|
-
//
|
|
2910
|
-
//
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
// being forced onto the bottom of page 1 (which would mislead the
|
|
2915
|
-
// 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
|
+
}
|
|
2916
2916
|
} else {
|
|
2917
2917
|
// Normal list view, no cron sessions
|
|
2918
2918
|
visible.forEach(s => _renderSessionItem(list, s));
|
|
@@ -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.
|