openclacky 1.5.20 → 1.5.21
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 +49 -0
- data/README.md +18 -0
- data/lib/clacky/agent/chunk_index.rb +8 -1
- data/lib/clacky/agent/feedback.rb +30 -0
- data/lib/clacky/agent/history_navigation.rb +17 -3
- data/lib/clacky/agent/hook_manager.rb +6 -1
- data/lib/clacky/agent/llm_caller.rb +1 -0
- data/lib/clacky/agent/message_compressor_helper.rb +28 -2
- data/lib/clacky/agent/session_serializer.rb +20 -2
- data/lib/clacky/agent.rb +56 -43
- data/lib/clacky/agent_config.rb +82 -4
- data/lib/clacky/billing/platform_billing.rb +5 -1
- data/lib/clacky/brand_config.rb +1 -1
- data/lib/clacky/cli.rb +3 -2
- data/lib/clacky/client.rb +5 -5
- data/lib/clacky/deepseek_web_tools.rb +156 -0
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +8 -0
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +21 -1
- data/lib/clacky/extension/loader.rb +5 -3
- data/lib/clacky/locales/en.rb +5 -5
- data/lib/clacky/locales/zh.rb +5 -5
- data/lib/clacky/message_format/anthropic.rb +20 -0
- data/lib/clacky/message_format/open_ai.rb +31 -14
- data/lib/clacky/message_history.rb +21 -3
- data/lib/clacky/platform_http_client.rb +6 -2
- data/lib/clacky/providers.rb +23 -14
- data/lib/clacky/search_config.rb +47 -13
- data/lib/clacky/server/browser_manager.rb +35 -5
- data/lib/clacky/server/channel/channel_manager.rb +6 -6
- data/lib/clacky/server/discover.rb +5 -4
- data/lib/clacky/server/http_server.rb +194 -47
- data/lib/clacky/server/model_prices.rb +5 -3
- data/lib/clacky/server/pid_files.rb +25 -0
- data/lib/clacky/server/server_master.rb +9 -3
- data/lib/clacky/server/session_registry.rb +19 -7
- data/lib/clacky/server/web_ui_controller.rb +61 -38
- data/lib/clacky/session_manager.rb +7 -1
- data/lib/clacky/skill.rb +1 -1
- data/lib/clacky/tools/ask_user.rb +4 -1
- data/lib/clacky/tools/base.rb +3 -2
- data/lib/clacky/tools/browser.rb +18 -2
- data/lib/clacky/tools/file_reader.rb +1 -1
- data/lib/clacky/tools/terminal/session_manager.rb +19 -1
- data/lib/clacky/tools/terminal.rb +65 -23
- data/lib/clacky/tools/trash_manager.rb +5 -3
- data/lib/clacky/tools/web_fetch.rb +81 -22
- data/lib/clacky/tools/web_search.rb +45 -4
- data/lib/clacky/ui2/ui_controller.rb +1 -1
- data/lib/clacky/ui_interface.rb +4 -3
- data/lib/clacky/utils/file_processor.rb +51 -0
- data/lib/clacky/utils/model_pricing.rb +132 -13
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +366 -105
- data/lib/clacky/web/components/chat-navigator.js +8 -1
- data/lib/clacky/web/components/mentions.js +2 -13
- data/lib/clacky/web/components/model-picker.js +3 -1
- data/lib/clacky/web/components/onboard.js +13 -10
- data/lib/clacky/web/features/archive/store.js +30 -16
- data/lib/clacky/web/features/archive/view.js +20 -10
- data/lib/clacky/web/features/backup/view.js +5 -5
- data/lib/clacky/web/features/new-session/store.js +3 -3
- data/lib/clacky/web/features/trash/store.js +10 -7
- data/lib/clacky/web/features/trash/view.js +9 -4
- data/lib/clacky/web/i18n.js +106 -30
- data/lib/clacky/web/index.html +90 -60
- data/lib/clacky/web/projects.js +56 -1
- data/lib/clacky/web/sessions.js +244 -131
- data/lib/clacky/web/settings.js +143 -8
- data/lib/clacky/web/ws-dispatcher.js +9 -13
- data/lib/clacky.rb +1 -0
- metadata +4 -1
|
@@ -306,18 +306,24 @@
|
|
|
306
306
|
function syncMessages() {
|
|
307
307
|
if (!messages || sessionId !== Sessions.activeId) return;
|
|
308
308
|
const byId = new Map(items.map((item, index) => [item.id, index]));
|
|
309
|
+
const byTask = new Map();
|
|
309
310
|
const liveByTime = new Map();
|
|
310
311
|
items.forEach((item, index) => {
|
|
312
|
+
if (item.taskId) byTask.set(String(item.taskId), index);
|
|
311
313
|
if (!item.archived && item.created_at) liveByTime.set(String(item.created_at), index);
|
|
312
314
|
});
|
|
313
315
|
loaded = [];
|
|
314
316
|
messages.querySelectorAll(".msg-user-wrap").forEach(el => {
|
|
315
317
|
const bubble = el.querySelector(".msg-user");
|
|
316
318
|
let index = byId.get(bubble?.dataset.roundId);
|
|
319
|
+
// Compression re-keys rounds without re-rendering bubbles; task ids survive it.
|
|
320
|
+
if (index === undefined) index = byTask.get(bubble?.dataset.taskId);
|
|
317
321
|
// Only optimistic live bubbles need a timestamp fallback. Archived
|
|
318
322
|
// targets always use source locators, never synthetic timestamps.
|
|
319
323
|
if (index === undefined && bubble?.dataset.editable !== "false") index = liveByTime.get(bubble?.dataset.createdAt);
|
|
320
|
-
if (index
|
|
324
|
+
if (index === undefined) return;
|
|
325
|
+
if (items[index].taskId) bubble.dataset.taskId = String(items[index].taskId);
|
|
326
|
+
loaded.push({ index, el });
|
|
321
327
|
});
|
|
322
328
|
_updateActive();
|
|
323
329
|
}
|
|
@@ -379,6 +385,7 @@
|
|
|
379
385
|
const identity = source.identities?.[offset] || null;
|
|
380
386
|
return { id: JSON.stringify([source.key, offset, source.version, identity]),
|
|
381
387
|
bookmarkKey: _bookmarkKey(source, offset, identity),
|
|
388
|
+
taskId: source.task_ids?.[offset] || identity?.[1] || null,
|
|
382
389
|
archived: !source.identities, created_at: identity?.[0] };
|
|
383
390
|
}));
|
|
384
391
|
const validBookmarks = new Set(items.map(item => item.bookmarkKey));
|
|
@@ -67,7 +67,7 @@ const MentionAC = (() => {
|
|
|
67
67
|
async function _fetchSessions(before) {
|
|
68
68
|
// Cron/scheduled-task sessions are noise here — exclude them from references.
|
|
69
69
|
const params = new URLSearchParams({ limit: "20", exclude_type: "cron" });
|
|
70
|
-
if (before) params.set("before", before);
|
|
70
|
+
if (before) { params.set("before", before.before); params.set("before_id", before.before_id); }
|
|
71
71
|
const url = `/api/sessions?${params}`;
|
|
72
72
|
const res = await fetch(url);
|
|
73
73
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
@@ -303,18 +303,7 @@ const MentionAC = (() => {
|
|
|
303
303
|
const sessions = data.sessions || [];
|
|
304
304
|
_chatsHasMore = !!data.has_more;
|
|
305
305
|
_items = _items.concat(sessions.map((s) => ({ kind: "session", id: s.id, name: s.name || s.id })));
|
|
306
|
-
if (
|
|
307
|
-
// Cursor must come from a non-pinned session: pinned sessions sit at
|
|
308
|
-
// the top regardless of age, so an old pinned row would otherwise push
|
|
309
|
-
// the cursor too far back and leave the next page empty.
|
|
310
|
-
const oldest = sessions.reduce((min, s) => {
|
|
311
|
-
if (s.pinned) return min;
|
|
312
|
-
const t = s.updated_at || s.created_at;
|
|
313
|
-
if (!t) return min;
|
|
314
|
-
return (!min || t < min) ? t : min;
|
|
315
|
-
}, null);
|
|
316
|
-
if (oldest) _chatsCursor = oldest;
|
|
317
|
-
}
|
|
306
|
+
if (data.next_cursor) _chatsCursor = data.next_cursor;
|
|
318
307
|
_renderChatItems(listWrap, reset);
|
|
319
308
|
} catch (err) {
|
|
320
309
|
if (_mode !== "chats" || req !== _chatsReq) return;
|
|
@@ -182,7 +182,9 @@ const ModelPicker = (() => {
|
|
|
182
182
|
ratio.textContent = price.label;
|
|
183
183
|
el.appendChild(ratio);
|
|
184
184
|
const vars = { in: price.in, out: price.out };
|
|
185
|
-
|
|
185
|
+
// Bedrock GPT carries a 20% markup over list, so the standard-price claim
|
|
186
|
+
// would be wrong for these rows.
|
|
187
|
+
let tipKey = name.startsWith("abs-gpt-") ? "sib.price.tip.abs" : "sib.price.tip";
|
|
186
188
|
if (price.discount) {
|
|
187
189
|
const badge = document.createElement("span");
|
|
188
190
|
badge.className = "sib-model-discount";
|
|
@@ -414,8 +414,18 @@ const Onboard = (() => {
|
|
|
414
414
|
el.className = "setup-test-result" + (msg ? " result-fail" : "");
|
|
415
415
|
}
|
|
416
416
|
|
|
417
|
+
// Platform returns raw RFC 8628 codes (access_denied / expired_token); never
|
|
418
|
+
// surface those to users — map the status to a translated sentence instead.
|
|
419
|
+
function _deviceErrorText(status) {
|
|
420
|
+
switch (status) {
|
|
421
|
+
case "denied": return I18n.t("onboard.device.error.denied");
|
|
422
|
+
case "expired":
|
|
423
|
+
case "consumed": return I18n.t("onboard.device.error.expired");
|
|
424
|
+
default: return I18n.t("onboard.device.error.failed");
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
417
428
|
async function _startDeviceLogin() {
|
|
418
|
-
const zh = _selectedLang === "zh";
|
|
419
429
|
_setDeviceError("");
|
|
420
430
|
|
|
421
431
|
const w = window.open("about:blank", "_blank");
|
|
@@ -430,13 +440,11 @@ const Onboard = (() => {
|
|
|
430
440
|
|
|
431
441
|
if (!data || !data.ok) {
|
|
432
442
|
if (w && !w.closed) w.close();
|
|
433
|
-
_setDeviceError(
|
|
443
|
+
_setDeviceError(I18n.t("onboard.device.error.startFailed"));
|
|
434
444
|
return;
|
|
435
445
|
}
|
|
436
446
|
|
|
437
447
|
const url = data.verification_uri_complete || data.verification_uri;
|
|
438
|
-
const codeEl = $("setup-device-usercode");
|
|
439
|
-
if (codeEl) codeEl.textContent = data.user_code || "—";
|
|
440
448
|
const link = $("setup-device-link");
|
|
441
449
|
if (link && url) link.href = url;
|
|
442
450
|
|
|
@@ -452,8 +460,6 @@ const Onboard = (() => {
|
|
|
452
460
|
}
|
|
453
461
|
|
|
454
462
|
async function _pollDevice(deviceCode, intervalMs) {
|
|
455
|
-
const zh = _selectedLang === "zh";
|
|
456
|
-
|
|
457
463
|
while (_devicePolling) {
|
|
458
464
|
await new Promise(r => setTimeout(r, intervalMs));
|
|
459
465
|
if (!_devicePolling) return;
|
|
@@ -480,10 +486,7 @@ const Onboard = (() => {
|
|
|
480
486
|
// denied / expired / consumed / error
|
|
481
487
|
_devicePolling = false;
|
|
482
488
|
_showDevicePending(false);
|
|
483
|
-
|
|
484
|
-
? (zh ? "授权已被拒绝。" : "Authorization was denied.")
|
|
485
|
-
: (zh ? "授权已过期,请重新登录。" : "Authorization expired. Please try again.");
|
|
486
|
-
_setDeviceError(data.error || msg);
|
|
489
|
+
_setDeviceError(_deviceErrorText(data.status));
|
|
487
490
|
return;
|
|
488
491
|
}
|
|
489
492
|
}
|
|
@@ -14,6 +14,7 @@ const ArchiveStore = (() => {
|
|
|
14
14
|
|
|
15
15
|
let _items = [];
|
|
16
16
|
let _hasMore = false;
|
|
17
|
+
let _cursor = null; // server-issued sort key of the last loaded row
|
|
17
18
|
let _loading = false;
|
|
18
19
|
let _archivedTotal = 0; // whole archive, not just the loaded page
|
|
19
20
|
|
|
@@ -72,12 +73,9 @@ const ArchiveStore = (() => {
|
|
|
72
73
|
|
|
73
74
|
try {
|
|
74
75
|
const params = new URLSearchParams({ archived: "1", limit: String(PAGE_SIZE) });
|
|
75
|
-
if (!opts.reset &&
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const oldest = _items[_items.length - 1];
|
|
79
|
-
const cursor = oldest.updated_at || oldest.created_at;
|
|
80
|
-
if (cursor) params.set("before", cursor);
|
|
76
|
+
if (!opts.reset && _cursor) {
|
|
77
|
+
params.set("before", _cursor.before);
|
|
78
|
+
params.set("before_id", _cursor.before_id);
|
|
81
79
|
}
|
|
82
80
|
|
|
83
81
|
const res = await fetch(`/api/sessions?${params.toString()}`);
|
|
@@ -87,6 +85,7 @@ const ArchiveStore = (() => {
|
|
|
87
85
|
const rows = data.sessions || [];
|
|
88
86
|
_items = opts.reset === false ? _items.concat(rows) : rows;
|
|
89
87
|
_hasMore = !!data.has_more;
|
|
88
|
+
_cursor = data.next_cursor || (opts.reset === false ? _cursor : null);
|
|
90
89
|
if (typeof data.archived_count === "number") _archivedTotal = data.archived_count;
|
|
91
90
|
_emit("archive:changed");
|
|
92
91
|
} catch (e) {
|
|
@@ -110,6 +109,7 @@ const ArchiveStore = (() => {
|
|
|
110
109
|
Sessions.renderList();
|
|
111
110
|
}
|
|
112
111
|
if (typeof Projects !== "undefined") Projects.renderSection();
|
|
112
|
+
Archive.refreshIfShown();
|
|
113
113
|
return { ok: true };
|
|
114
114
|
} catch (e) {
|
|
115
115
|
return { ok: false, error: e.message };
|
|
@@ -155,21 +155,35 @@ const ArchiveStore = (() => {
|
|
|
155
155
|
},
|
|
156
156
|
|
|
157
157
|
/**
|
|
158
|
-
* Delete
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
158
|
+
* Delete archived chats (soft delete, same as remove).
|
|
159
|
+
* `sessions` is the visible selection when search or filters narrow the
|
|
160
|
+
* page; null means the whole archive, which is paged, so only the server
|
|
161
|
+
* can enumerate it. Either way the caller words its confirmation with the
|
|
162
|
+
* number this call takes out.
|
|
162
163
|
*/
|
|
163
|
-
async
|
|
164
|
+
async deleteMany(sessions) {
|
|
165
|
+
const ids = sessions ? sessions.map(s => s.id || s.session_id) : null;
|
|
166
|
+
const opts = { method: "POST" };
|
|
167
|
+
if (ids) {
|
|
168
|
+
opts.headers = { "Content-Type": "application/json" };
|
|
169
|
+
opts.body = JSON.stringify({ session_ids: ids });
|
|
170
|
+
}
|
|
164
171
|
try {
|
|
165
|
-
const res = await fetch("/api/archive/sessions",
|
|
172
|
+
const res = await fetch("/api/archive/sessions/delete", opts);
|
|
166
173
|
const data = await res.json();
|
|
167
174
|
if (!res.ok || !data.ok) return { ok: false, error: data.error || res.statusText };
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
175
|
+
const deleted = data.deleted_count || 0;
|
|
176
|
+
if (ids) {
|
|
177
|
+
_items = _items.filter(s => !ids.includes(s.id || s.session_id));
|
|
178
|
+
_archivedTotal = Math.max(0, _archivedTotal - deleted);
|
|
179
|
+
} else {
|
|
180
|
+
_items = [];
|
|
181
|
+
_hasMore = false;
|
|
182
|
+
_cursor = null;
|
|
183
|
+
_archivedTotal = 0;
|
|
184
|
+
}
|
|
171
185
|
_emit("archive:changed");
|
|
172
|
-
return { ok: true, deleted_count:
|
|
186
|
+
return { ok: true, deleted_count: deleted };
|
|
173
187
|
} catch (e) {
|
|
174
188
|
return { ok: false, error: e.message };
|
|
175
189
|
}
|
|
@@ -86,6 +86,10 @@ const ArchiveView = (() => {
|
|
|
86
86
|
.map(k => byId.get(k));
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
function _isNarrowed() {
|
|
90
|
+
return _query.trim() !== "" || _filterProject !== null || _filterType !== "";
|
|
91
|
+
}
|
|
92
|
+
|
|
89
93
|
// ── Rendering ────────────────────────────────────────────────────────
|
|
90
94
|
|
|
91
95
|
function _renderSkeleton() {
|
|
@@ -158,15 +162,15 @@ const ArchiveView = (() => {
|
|
|
158
162
|
_refreshProjectOptions();
|
|
159
163
|
|
|
160
164
|
const items = _visibleItems();
|
|
165
|
+
const narrowed = _isNarrowed();
|
|
161
166
|
if (summary) {
|
|
162
167
|
// With a filter or a search on, the number describes what the toolbar is
|
|
163
168
|
// showing. Without one it describes the whole archive, which can be
|
|
164
169
|
// bigger than the loaded page.
|
|
165
|
-
const narrowed = _query.trim() !== "" || _filterProject !== null || _filterType !== "";
|
|
166
170
|
const n = narrowed ? items.length : (Archive.state.archivedTotal || items.length);
|
|
167
171
|
summary.textContent = n ? I18n.t("archive.summary", { n }) : "";
|
|
168
172
|
}
|
|
169
|
-
if (btnAll) btnAll.disabled = Archive.state.archivedTotal === 0;
|
|
173
|
+
if (btnAll) btnAll.disabled = (narrowed ? items.length : Archive.state.archivedTotal) === 0;
|
|
170
174
|
if (btnMore) {
|
|
171
175
|
btnMore.style.display = Archive.state.hasMore ? "" : "none";
|
|
172
176
|
_setMoreLoading(false);
|
|
@@ -290,17 +294,18 @@ const ArchiveView = (() => {
|
|
|
290
294
|
}
|
|
291
295
|
|
|
292
296
|
async function _deleteAll() {
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
297
|
+
// What the toolbar shows is what goes: the visible rows when search or a
|
|
298
|
+
// filter is on, otherwise the whole archive, whose size only the server
|
|
299
|
+
// knows (`archivedTotal`) because the list is paged.
|
|
300
|
+
const selection = _isNarrowed() ? _visibleItems() : null;
|
|
301
|
+
const count = selection ? selection.length : Archive.state.archivedTotal;
|
|
302
|
+
if (count === 0) {
|
|
303
|
+
Modal.toast(_t(selection ? "archive.noMatch" : "archive.empty"), "info");
|
|
299
304
|
return;
|
|
300
305
|
}
|
|
301
|
-
const confirmed = await Modal.confirm(I18n.t("archive.confirmDeleteAll", { count
|
|
306
|
+
const confirmed = await Modal.confirm(I18n.t("archive.confirmDeleteAll", { count }));
|
|
302
307
|
if (!confirmed) return;
|
|
303
|
-
const res = await Archive.
|
|
308
|
+
const res = await Archive.deleteMany(selection);
|
|
304
309
|
if (!res.ok) Modal.toast(I18n.t("archive.deleteFail", { msg: res.error }), "error");
|
|
305
310
|
else Modal.toast(I18n.t("archive.deletedAll", { count: res.deleted_count }), "success");
|
|
306
311
|
}
|
|
@@ -390,6 +395,11 @@ const ArchiveView = (() => {
|
|
|
390
395
|
_wire();
|
|
391
396
|
Archive.load({ reset: true });
|
|
392
397
|
},
|
|
398
|
+
|
|
399
|
+
// Sidebar actions change the archive while this page may be open behind them.
|
|
400
|
+
refreshIfShown() {
|
|
401
|
+
if (Router.current === "archive") Archive.load({ reset: true });
|
|
402
|
+
},
|
|
393
403
|
};
|
|
394
404
|
|
|
395
405
|
return { init: _subscribe, api: viewApi };
|
|
@@ -35,10 +35,10 @@ const BackupView = (() => {
|
|
|
35
35
|
if (!cfg.last_run_at) { el.textContent = ""; el.className = "model-test-result"; return; }
|
|
36
36
|
if (cfg.last_status === "error") {
|
|
37
37
|
el.textContent = I18n.t("settings.backup.lastError", { msg: cfg.last_error || "" });
|
|
38
|
-
el.className = "model-test-result
|
|
38
|
+
el.className = "model-test-result result-fail";
|
|
39
39
|
} else {
|
|
40
40
|
el.textContent = I18n.t("settings.backup.lastOk", { time: _fmtDate(cfg.last_run_at) });
|
|
41
|
-
el.className = "model-test-result
|
|
41
|
+
el.className = "model-test-result result-ok";
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
|
|
@@ -62,7 +62,7 @@ const BackupView = (() => {
|
|
|
62
62
|
Modal.toast(I18n.t("settings.backup.downloaded"), "success");
|
|
63
63
|
} else if (statusEl) {
|
|
64
64
|
statusEl.textContent = I18n.t("settings.backup.lastError", { msg: res.error });
|
|
65
|
-
statusEl.className = "model-test-result
|
|
65
|
+
statusEl.className = "model-test-result result-fail";
|
|
66
66
|
}
|
|
67
67
|
if (btn) btn.disabled = false;
|
|
68
68
|
}
|
|
@@ -78,10 +78,10 @@ const BackupView = (() => {
|
|
|
78
78
|
const buf = await file.arrayBuffer();
|
|
79
79
|
const res = await Backup.restore(buf);
|
|
80
80
|
if (res.ok) {
|
|
81
|
-
if (statusEl) { statusEl.textContent = I18n.t("settings.backup.restoreOk"); statusEl.className = "model-test-result
|
|
81
|
+
if (statusEl) { statusEl.textContent = I18n.t("settings.backup.restoreOk"); statusEl.className = "model-test-result result-ok"; }
|
|
82
82
|
_restoreStatusEl = statusEl;
|
|
83
83
|
} else {
|
|
84
|
-
if (statusEl) { statusEl.textContent = res.error || "Restore failed"; statusEl.className = "model-test-result
|
|
84
|
+
if (statusEl) { statusEl.textContent = res.error || "Restore failed"; statusEl.className = "model-test-result result-fail"; }
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
87
|
|
|
@@ -152,11 +152,11 @@ const NewSessionStore = (() => {
|
|
|
152
152
|
_emit("newSession:creating", { creating: true });
|
|
153
153
|
|
|
154
154
|
try {
|
|
155
|
-
// useDefaults=true: quick-create from sidebar —
|
|
156
|
-
//
|
|
155
|
+
// useDefaults=true: quick-create from sidebar — ignore the /#new draft
|
|
156
|
+
// (agent / name / model / dir / project) so the session always starts clean.
|
|
157
157
|
const agentId = useDefaults ? "general" : (_state.selectedAgentId || "general");
|
|
158
158
|
const adv = _state.advanced;
|
|
159
|
-
const name = adv.name.trim() || _autoName(existingSessions);
|
|
159
|
+
const name = (!useDefaults && adv.name.trim()) || _autoName(existingSessions);
|
|
160
160
|
|
|
161
161
|
const payload = { name, agent_profile: agentId, source: "manual" };
|
|
162
162
|
if (!useDefaults) {
|
|
@@ -200,14 +200,14 @@ const TrashStore = (() => {
|
|
|
200
200
|
}
|
|
201
201
|
},
|
|
202
202
|
|
|
203
|
-
|
|
204
|
-
if (!Array.isArray(items)) return
|
|
205
|
-
if (!daysOld || daysOld <= 0) return items
|
|
203
|
+
matching(items, daysOld) {
|
|
204
|
+
if (!Array.isArray(items)) return [];
|
|
205
|
+
if (!daysOld || daysOld <= 0) return items;
|
|
206
206
|
const cutoff = Date.now() - daysOld * 86400000;
|
|
207
207
|
return items.filter(it => {
|
|
208
208
|
const t = Date.parse(it.deleted_at || "");
|
|
209
209
|
return !isNaN(t) && t < cutoff;
|
|
210
|
-
})
|
|
210
|
+
});
|
|
211
211
|
},
|
|
212
212
|
|
|
213
213
|
async emptyFilesBulk(daysOld) {
|
|
@@ -223,10 +223,13 @@ const TrashStore = (() => {
|
|
|
223
223
|
}
|
|
224
224
|
},
|
|
225
225
|
|
|
226
|
-
async
|
|
227
|
-
const url = "/api/trash/sessions?" + new URLSearchParams({ days_old: String(daysOld) }).toString();
|
|
226
|
+
async deleteSessions(sessions) {
|
|
228
227
|
try {
|
|
229
|
-
const res = await fetch(
|
|
228
|
+
const res = await fetch("/api/trash/sessions/delete", {
|
|
229
|
+
method: "POST",
|
|
230
|
+
headers: { "Content-Type": "application/json" },
|
|
231
|
+
body: JSON.stringify({ session_ids: sessions.map(s => s.session_id) })
|
|
232
|
+
});
|
|
230
233
|
const data = await res.json();
|
|
231
234
|
if (!res.ok || !data.ok) return { ok: false, error: data.error || res.statusText };
|
|
232
235
|
await Trash.loadSessions();
|
|
@@ -426,18 +426,18 @@ const TrashView = (() => {
|
|
|
426
426
|
async function _emptyBulk(daysOld, confirmKey) {
|
|
427
427
|
const isSession = _activeTab === "session-trash";
|
|
428
428
|
const items = isSession ? Trash.state.sessions : Trash.state.files;
|
|
429
|
-
const
|
|
429
|
+
const matches = Trash.matching(items, daysOld);
|
|
430
430
|
|
|
431
|
-
if (
|
|
431
|
+
if (matches.length === 0) {
|
|
432
432
|
Modal.toast(_t(daysOld > 0 ? "trash.nothingOld" : "trash.empty"), "info");
|
|
433
433
|
return;
|
|
434
434
|
}
|
|
435
435
|
|
|
436
|
-
const confirmed = await Modal.confirm(I18n.t(confirmKey, { count:
|
|
436
|
+
const confirmed = await Modal.confirm(I18n.t(confirmKey, { count: matches.length }));
|
|
437
437
|
if (!confirmed) return;
|
|
438
438
|
|
|
439
439
|
if (isSession) {
|
|
440
|
-
const res = await Trash.
|
|
440
|
+
const res = await Trash.deleteSessions(matches);
|
|
441
441
|
if (!res.ok) { Modal.toast(I18n.t("trash.cleanFail", { msg: res.error }), "error"); return; }
|
|
442
442
|
Modal.toast(I18n.t("trash.sessionsCleaned", { count: res.deleted_count }), "success");
|
|
443
443
|
} else {
|
|
@@ -550,6 +550,11 @@ const TrashView = (() => {
|
|
|
550
550
|
_activeTab = null;
|
|
551
551
|
// Sessions are what people come here for; the file trash is the side trip.
|
|
552
552
|
_switchTab("session-trash");
|
|
553
|
+
},
|
|
554
|
+
|
|
555
|
+
// Sidebar deletes land in the session trash while this page may be open behind them.
|
|
556
|
+
refreshIfShown() {
|
|
557
|
+
if (Router.current === "trash" && _activeTab === "session-trash") Trash.loadSessions();
|
|
553
558
|
}
|
|
554
559
|
};
|
|
555
560
|
|