openclacky 1.0.0 → 1.0.2
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/README.md +87 -53
- data/lib/clacky/agent/cost_tracker.rb +19 -2
- data/lib/clacky/agent/llm_caller.rb +218 -0
- data/lib/clacky/agent/message_compressor_helper.rb +32 -2
- data/lib/clacky/agent.rb +54 -22
- data/lib/clacky/client.rb +44 -5
- data/lib/clacky/default_parsers/pdf_parser.rb +58 -17
- data/lib/clacky/default_parsers/pdf_parser_ocr.py +103 -0
- data/lib/clacky/default_parsers/pdf_parser_plumber.py +62 -0
- data/lib/clacky/default_skills/deploy/SKILL.md +201 -77
- data/lib/clacky/default_skills/new/SKILL.md +3 -114
- data/lib/clacky/default_skills/onboard/SKILL.md +349 -133
- data/lib/clacky/default_skills/onboard/scripts/import_external_skills.rb +371 -0
- data/lib/clacky/default_skills/onboard/scripts/install_builtin_skills.rb +175 -0
- data/lib/clacky/default_skills/skill-add/scripts/install_from_zip.rb +59 -26
- data/lib/clacky/message_format/anthropic.rb +72 -8
- data/lib/clacky/message_format/bedrock.rb +6 -3
- data/lib/clacky/providers.rb +146 -3
- data/lib/clacky/server/channel/adapters/feishu/adapter.rb +14 -0
- data/lib/clacky/server/channel/adapters/feishu/bot.rb +10 -0
- data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +1 -0
- data/lib/clacky/server/channel/channel_manager.rb +12 -4
- data/lib/clacky/server/channel/channel_ui_controller.rb +8 -2
- data/lib/clacky/server/http_server.rb +746 -13
- data/lib/clacky/server/session_registry.rb +55 -24
- data/lib/clacky/skill.rb +10 -9
- data/lib/clacky/skill_loader.rb +23 -11
- data/lib/clacky/tools/file_reader.rb +232 -127
- data/lib/clacky/tools/security.rb +42 -64
- data/lib/clacky/tools/terminal/persistent_session.rb +15 -4
- data/lib/clacky/tools/terminal/safe_rm.sh +106 -0
- data/lib/clacky/tools/terminal/session_manager.rb +8 -3
- data/lib/clacky/tools/terminal.rb +263 -16
- data/lib/clacky/ui2/layout_manager.rb +8 -1
- data/lib/clacky/ui2/output_buffer.rb +83 -23
- data/lib/clacky/ui2/ui_controller.rb +74 -7
- data/lib/clacky/utils/file_processor.rb +14 -40
- data/lib/clacky/utils/model_pricing.rb +215 -0
- data/lib/clacky/utils/parser_manager.rb +70 -6
- data/lib/clacky/utils/string_matcher.rb +23 -1
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +673 -9
- data/lib/clacky/web/app.js +40 -1608
- data/lib/clacky/web/i18n.js +209 -0
- data/lib/clacky/web/index.html +166 -2
- data/lib/clacky/web/onboard.js +77 -1
- data/lib/clacky/web/profile.js +442 -0
- data/lib/clacky/web/sessions.js +1034 -2
- data/lib/clacky/web/settings.js +127 -6
- data/lib/clacky/web/sidebar.js +39 -0
- data/lib/clacky/web/skills.js +460 -0
- data/lib/clacky/web/trash.js +343 -0
- data/lib/clacky/web/ws-dispatcher.js +255 -0
- data/lib/clacky.rb +5 -3
- metadata +16 -17
- data/lib/clacky/clacky_auth_client.rb +0 -152
- data/lib/clacky/clacky_cloud_config.rb +0 -123
- data/lib/clacky/cloud_project_client.rb +0 -169
- data/lib/clacky/default_skills/deploy/scripts/rails_deploy.rb +0 -1377
- data/lib/clacky/default_skills/deploy/tools/check_health.rb +0 -116
- data/lib/clacky/default_skills/deploy/tools/create_database_service.rb +0 -341
- data/lib/clacky/default_skills/deploy/tools/execute_deployment.rb +0 -99
- data/lib/clacky/default_skills/deploy/tools/fetch_runtime_logs.rb +0 -77
- data/lib/clacky/default_skills/deploy/tools/list_services.rb +0 -67
- data/lib/clacky/default_skills/deploy/tools/report_deploy_status.rb +0 -67
- data/lib/clacky/default_skills/deploy/tools/set_deploy_variables.rb +0 -189
- data/lib/clacky/default_skills/new/scripts/cloud_project_init.sh +0 -74
- data/lib/clacky/deploy_api_client.rb +0 -484
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
// trash.js — Recently Deleted panel
|
|
2
|
+
//
|
|
3
|
+
// Top-level sidebar panel that lists files moved to trash by the agent
|
|
4
|
+
// across every project-scoped trash dir under ~/.clacky/trash/.
|
|
5
|
+
//
|
|
6
|
+
// Each card shows the original path, project, size and deleted-at,
|
|
7
|
+
// plus Restore / Delete buttons. Bulk actions at the top:
|
|
8
|
+
// refresh, empty files older than 7 days, empty everything.
|
|
9
|
+
//
|
|
10
|
+
// Load order: after app.js modules (I18n, Modal), before app.js boot.
|
|
11
|
+
|
|
12
|
+
const Trash = (() => {
|
|
13
|
+
// ── Private state ────────────────────────────────────────────────────
|
|
14
|
+
let _files = [];
|
|
15
|
+
let _totals = { count: 0, size: 0 };
|
|
16
|
+
let _loading = false;
|
|
17
|
+
let _wired = false;
|
|
18
|
+
|
|
19
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
function $(id) { return document.getElementById(id); }
|
|
22
|
+
|
|
23
|
+
function escapeHtml(s) {
|
|
24
|
+
return String(s ?? "")
|
|
25
|
+
.replace(/&/g, "&").replace(/</g, "<")
|
|
26
|
+
.replace(/>/g, ">").replace(/"/g, """);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function _t(key) {
|
|
30
|
+
return I18n.t ? I18n.t(key) : key;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function _humanBytes(n) {
|
|
34
|
+
if (!n || n < 0) return "0 B";
|
|
35
|
+
const units = ["B", "KB", "MB", "GB"];
|
|
36
|
+
let i = 0;
|
|
37
|
+
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
|
|
38
|
+
return (i === 0 ? n.toFixed(0) : n.toFixed(2)) + " " + units[i];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function _humanTime(iso) {
|
|
42
|
+
if (!iso) return "";
|
|
43
|
+
const d = new Date(iso);
|
|
44
|
+
if (isNaN(d.getTime())) return iso;
|
|
45
|
+
const now = new Date();
|
|
46
|
+
const ms = now - d;
|
|
47
|
+
const mins = Math.floor(ms / 60000);
|
|
48
|
+
const hours = Math.floor(ms / 3600000);
|
|
49
|
+
const days = Math.floor(ms / 86400000);
|
|
50
|
+
const zh = I18n.lang() === "zh";
|
|
51
|
+
if (mins < 1) return zh ? "刚刚" : "just now";
|
|
52
|
+
if (mins < 60) return zh ? `${mins} 分钟前` : `${mins}m ago`;
|
|
53
|
+
if (hours < 24) return zh ? `${hours} 小时前` : `${hours}h ago`;
|
|
54
|
+
if (days < 7) return zh ? `${days} 天前` : `${days}d ago`;
|
|
55
|
+
return d.toLocaleDateString();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── Data loading ─────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
async function _load() {
|
|
61
|
+
if (_loading) return;
|
|
62
|
+
_loading = true;
|
|
63
|
+
const list = $("trash-list");
|
|
64
|
+
if (list) list.innerHTML =
|
|
65
|
+
`<div class="creator-loading">${_t("trash.loading")}</div>`;
|
|
66
|
+
try {
|
|
67
|
+
const res = await fetch("/api/trash");
|
|
68
|
+
const data = await res.json();
|
|
69
|
+
if (!res.ok) throw new Error(data.error || "Load failed");
|
|
70
|
+
_files = data.files || [];
|
|
71
|
+
_totals = { count: data.total_count || 0, size: data.total_size || 0 };
|
|
72
|
+
_render();
|
|
73
|
+
} catch (e) {
|
|
74
|
+
console.error("[Trash] load failed", e);
|
|
75
|
+
if (list) list.innerHTML =
|
|
76
|
+
`<div class="creator-empty creator-error">${escapeHtml(e.message)}</div>`;
|
|
77
|
+
} finally {
|
|
78
|
+
_loading = false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function _render() {
|
|
83
|
+
const list = $("trash-list");
|
|
84
|
+
const summary = $("trash-summary");
|
|
85
|
+
const btnOld = $("btn-trash-empty-old");
|
|
86
|
+
const btnOrphans = $("btn-trash-empty-orphans");
|
|
87
|
+
const btnAll = $("btn-trash-empty-all");
|
|
88
|
+
if (!list) return;
|
|
89
|
+
|
|
90
|
+
const orphanCount = _files.filter(f => {
|
|
91
|
+
const root = f.project_root || "";
|
|
92
|
+
return /^\/(?:var\/folders|tmp|private\/var\/folders)\b/.test(root) ||
|
|
93
|
+
/\/d\d{8}-\d+-[a-z0-9]+(?:\/|$)/.test(root);
|
|
94
|
+
}).length;
|
|
95
|
+
|
|
96
|
+
if (summary) {
|
|
97
|
+
summary.textContent = _files.length
|
|
98
|
+
? I18n.t("trash.summary", {
|
|
99
|
+
count: _totals.count,
|
|
100
|
+
size: _humanBytes(_totals.size)
|
|
101
|
+
}) + (orphanCount > 0 ? " • " + I18n.t("trash.summaryOrphans", { count: orphanCount }) : "")
|
|
102
|
+
: "";
|
|
103
|
+
}
|
|
104
|
+
if (btnOld) btnOld.disabled = _files.length === 0;
|
|
105
|
+
if (btnOrphans) btnOrphans.disabled = orphanCount === 0;
|
|
106
|
+
if (btnAll) btnAll.disabled = _files.length === 0;
|
|
107
|
+
|
|
108
|
+
if (_files.length === 0) {
|
|
109
|
+
list.innerHTML = `<div class="creator-empty">${_t("trash.empty")}</div>`;
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
list.innerHTML = "";
|
|
114
|
+
_files.forEach(f => list.appendChild(_buildCard(f)));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function _buildCard(file) {
|
|
118
|
+
const card = document.createElement("div");
|
|
119
|
+
card.className = "trash-card";
|
|
120
|
+
card.dataset.project = file.project_root;
|
|
121
|
+
card.dataset.path = file.original_path;
|
|
122
|
+
|
|
123
|
+
const original = file.original_path || "";
|
|
124
|
+
const basename = original.split("/").pop() || original;
|
|
125
|
+
// Show last two path segments after basename to give agents context when
|
|
126
|
+
// many files share the same basename (very common: "package.json", "index.js").
|
|
127
|
+
const parts = original.split("/").filter(Boolean);
|
|
128
|
+
const shortPath = parts.length > 3
|
|
129
|
+
? ".../" + parts.slice(-3).join("/")
|
|
130
|
+
: original;
|
|
131
|
+
const sizeStr = _humanBytes(file.file_size || 0);
|
|
132
|
+
const whenStr = _humanTime(file.deleted_at);
|
|
133
|
+
// Heuristic: if project_root starts with /var/folders or /tmp, or contains
|
|
134
|
+
// a tempdir-style name (d20260502-...), the original project is gone.
|
|
135
|
+
// We still show it, but mark it so the user can clean it up confidently.
|
|
136
|
+
const orphan = /^\/(?:var\/folders|tmp|private\/var\/folders)\b/.test(file.project_root || "") ||
|
|
137
|
+
/\/d\d{8}-\d+-[a-z0-9]+(?:\/|$)/.test(file.project_root || "");
|
|
138
|
+
|
|
139
|
+
card.innerHTML = `
|
|
140
|
+
<div class="trash-card-info">
|
|
141
|
+
<div class="trash-card-title" title="${escapeHtml(original)}">${escapeHtml(basename)}</div>
|
|
142
|
+
<div class="trash-card-path" title="${escapeHtml(original)}">${escapeHtml(shortPath)}</div>
|
|
143
|
+
<div class="trash-card-meta">
|
|
144
|
+
<span class="trash-project" title="${escapeHtml(file.project_root)}">
|
|
145
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
146
|
+
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
|
147
|
+
</svg>
|
|
148
|
+
${escapeHtml(file.project_name || "")}
|
|
149
|
+
</span>
|
|
150
|
+
<span>${sizeStr}</span>
|
|
151
|
+
<span title="${escapeHtml(file.deleted_at || "")}">${escapeHtml(whenStr)}</span>
|
|
152
|
+
${orphan ? `<span class="trash-missing" title="${_t("trash.orphanHint")}">⚠ ${_t("trash.orphan")}</span>` : ""}
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
<div class="trash-card-actions">
|
|
156
|
+
<button class="btn-trash-restore" title="${_t("trash.restore")}" ${orphan ? "disabled" : ""}>
|
|
157
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
158
|
+
<polyline points="1 4 1 10 7 10"/>
|
|
159
|
+
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/>
|
|
160
|
+
</svg>
|
|
161
|
+
${_t("trash.restore")}
|
|
162
|
+
</button>
|
|
163
|
+
<button class="btn-trash-delete" title="${_t("trash.delete")}">
|
|
164
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
165
|
+
<polyline points="3 6 5 6 21 6"/>
|
|
166
|
+
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
|
|
167
|
+
<path d="M10 11v6"/><path d="M14 11v6"/>
|
|
168
|
+
</svg>
|
|
169
|
+
</button>
|
|
170
|
+
</div>`;
|
|
171
|
+
|
|
172
|
+
card.querySelector(".btn-trash-restore").addEventListener("click", () =>
|
|
173
|
+
_restoreOne(file, card));
|
|
174
|
+
card.querySelector(".btn-trash-delete").addEventListener("click", () =>
|
|
175
|
+
_deleteOne(file, card));
|
|
176
|
+
|
|
177
|
+
return card;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function _restoreOne(file, card) {
|
|
181
|
+
const btn = card.querySelector(".btn-trash-restore");
|
|
182
|
+
btn.disabled = true;
|
|
183
|
+
try {
|
|
184
|
+
const res = await fetch("/api/trash/restore", {
|
|
185
|
+
method: "POST",
|
|
186
|
+
headers: { "Content-Type": "application/json" },
|
|
187
|
+
body: JSON.stringify({
|
|
188
|
+
project_root: file.project_root,
|
|
189
|
+
original_path: file.original_path
|
|
190
|
+
})
|
|
191
|
+
});
|
|
192
|
+
const data = await res.json();
|
|
193
|
+
if (!res.ok || !data.ok) {
|
|
194
|
+
alert(I18n.t("trash.restoreFail", {
|
|
195
|
+
msg: data.error || res.statusText
|
|
196
|
+
}));
|
|
197
|
+
} else {
|
|
198
|
+
// Remove card, update totals locally for instant feedback.
|
|
199
|
+
_files = _files.filter(f =>
|
|
200
|
+
!(f.project_root === file.project_root && f.original_path === file.original_path));
|
|
201
|
+
_totals = {
|
|
202
|
+
count: Math.max(0, _totals.count - 1),
|
|
203
|
+
size: Math.max(0, _totals.size - (file.file_size || 0))
|
|
204
|
+
};
|
|
205
|
+
_render();
|
|
206
|
+
}
|
|
207
|
+
} catch (e) {
|
|
208
|
+
alert(I18n.t("trash.restoreFail", { msg: e.message }));
|
|
209
|
+
} finally {
|
|
210
|
+
btn.disabled = false;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function _deleteOne(file, card) {
|
|
215
|
+
const basename = (file.original_path || "").split("/").pop() || file.original_path;
|
|
216
|
+
const confirmed = await Modal.confirm(
|
|
217
|
+
I18n.t("trash.confirmDeleteOne", { name: basename })
|
|
218
|
+
);
|
|
219
|
+
if (!confirmed) return;
|
|
220
|
+
|
|
221
|
+
const url = "/api/trash?" + new URLSearchParams({
|
|
222
|
+
project: file.project_root,
|
|
223
|
+
file: file.original_path
|
|
224
|
+
}).toString();
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
const res = await fetch(url, { method: "DELETE" });
|
|
228
|
+
const data = await res.json();
|
|
229
|
+
if (!res.ok || !data.ok) {
|
|
230
|
+
alert(data.error || res.statusText);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
_files = _files.filter(f =>
|
|
234
|
+
!(f.project_root === file.project_root && f.original_path === file.original_path));
|
|
235
|
+
_totals = {
|
|
236
|
+
count: Math.max(0, _totals.count - 1),
|
|
237
|
+
size: Math.max(0, _totals.size - (file.file_size || 0))
|
|
238
|
+
};
|
|
239
|
+
_render();
|
|
240
|
+
} catch (e) {
|
|
241
|
+
alert(e.message);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function _emptyBulk(daysOld, confirmKey) {
|
|
246
|
+
const confirmed = await Modal.confirm(_t(confirmKey));
|
|
247
|
+
if (!confirmed) return;
|
|
248
|
+
|
|
249
|
+
const qs = new URLSearchParams();
|
|
250
|
+
qs.set("days_old", String(daysOld));
|
|
251
|
+
const url = "/api/trash?" + qs.toString();
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
const res = await fetch(url, { method: "DELETE" });
|
|
255
|
+
const data = await res.json();
|
|
256
|
+
if (!res.ok || !data.ok) {
|
|
257
|
+
alert(data.error || res.statusText);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (data.deleted_count === 0 && daysOld > 0) {
|
|
261
|
+
alert(_t("trash.nothingOld"));
|
|
262
|
+
} else {
|
|
263
|
+
alert(I18n.t("trash.emptied", {
|
|
264
|
+
count: data.deleted_count || 0,
|
|
265
|
+
size: _humanBytes(data.freed_size || 0)
|
|
266
|
+
}));
|
|
267
|
+
}
|
|
268
|
+
await _load();
|
|
269
|
+
} catch (e) {
|
|
270
|
+
alert(e.message);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Detects trash entries whose original project_root clearly no longer
|
|
275
|
+
// exists (test temp dirs under /var/folders, /tmp, or dir-format "dYYYYMMDD-...").
|
|
276
|
+
// The delete API does permanent deletion on a per-file basis.
|
|
277
|
+
async function _emptyOrphans() {
|
|
278
|
+
const orphans = _files.filter(f => {
|
|
279
|
+
const root = f.project_root || "";
|
|
280
|
+
return /^\/(?:var\/folders|tmp|private\/var\/folders)\b/.test(root) ||
|
|
281
|
+
/\/d\d{8}-\d+-[a-z0-9]+(?:\/|$)/.test(root);
|
|
282
|
+
});
|
|
283
|
+
if (orphans.length === 0) {
|
|
284
|
+
alert(_t("trash.noOrphans"));
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const confirmed = await Modal.confirm(
|
|
288
|
+
I18n.t("trash.confirmEmptyOrphans", { count: orphans.length })
|
|
289
|
+
);
|
|
290
|
+
if (!confirmed) return;
|
|
291
|
+
|
|
292
|
+
let deleted = 0, freed = 0, failed = 0;
|
|
293
|
+
for (const f of orphans) {
|
|
294
|
+
const url = "/api/trash?" + new URLSearchParams({
|
|
295
|
+
project: f.project_root,
|
|
296
|
+
file: f.original_path
|
|
297
|
+
}).toString();
|
|
298
|
+
try {
|
|
299
|
+
const r = await fetch(url, { method: "DELETE" });
|
|
300
|
+
const d = await r.json();
|
|
301
|
+
if (r.ok && d.ok) {
|
|
302
|
+
deleted += 1;
|
|
303
|
+
freed += d.freed_size || 0;
|
|
304
|
+
} else {
|
|
305
|
+
failed += 1;
|
|
306
|
+
}
|
|
307
|
+
} catch (_e) {
|
|
308
|
+
failed += 1;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
alert(I18n.t("trash.orphansCleaned", {
|
|
312
|
+
count: deleted,
|
|
313
|
+
size: _humanBytes(freed),
|
|
314
|
+
failed: failed
|
|
315
|
+
}));
|
|
316
|
+
await _load();
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function _wire() {
|
|
320
|
+
if (_wired) return;
|
|
321
|
+
_wired = true;
|
|
322
|
+
const btnRefresh = $("btn-trash-refresh");
|
|
323
|
+
const btnOld = $("btn-trash-empty-old");
|
|
324
|
+
const btnOrphans = $("btn-trash-empty-orphans");
|
|
325
|
+
const btnAll = $("btn-trash-empty-all");
|
|
326
|
+
if (btnRefresh) btnRefresh.addEventListener("click", () => _load());
|
|
327
|
+
if (btnOld) btnOld.addEventListener("click",
|
|
328
|
+
() => _emptyBulk(7, "trash.confirmEmptyOld"));
|
|
329
|
+
if (btnOrphans) btnOrphans.addEventListener("click", () => _emptyOrphans());
|
|
330
|
+
if (btnAll) btnAll.addEventListener("click",
|
|
331
|
+
() => _emptyBulk(0, "trash.confirmEmptyAll"));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ── Public API ────────────────────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
return {
|
|
337
|
+
/** Called by Router when the trash panel becomes active. */
|
|
338
|
+
onPanelShow() {
|
|
339
|
+
_wire();
|
|
340
|
+
_load();
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
})();
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
// ── WS event dispatcher ───────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Consumes events emitted by WS (ws.js) and dispatches them to the right
|
|
4
|
+
// business module (Sessions, Tasks, Skills, Channels, Settings, Brand, ...).
|
|
5
|
+
//
|
|
6
|
+
// Kept as a separate file from ws.js on purpose:
|
|
7
|
+
// - ws.js is a pure transport layer (connect / send / subscribe / reconnect)
|
|
8
|
+
// - this file is the application-level router that knows about every
|
|
9
|
+
// business module. Mixing the two would force ws.js to depend on every
|
|
10
|
+
// other module, breaking layering.
|
|
11
|
+
//
|
|
12
|
+
// Depends on: WS (ws.js), Sessions, Tasks, Skills, Channels, Settings, Brand,
|
|
13
|
+
// Router, I18n, global $ / escapeHtml / showConfirmModal helpers.
|
|
14
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
15
|
+
(function() {
|
|
16
|
+
// Guard: restore hash routing only once after initial session_list arrives.
|
|
17
|
+
let _initialRestoreDone = false;
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
WS.onEvent(ev => {
|
|
21
|
+
console.log("[DEBUG] WS event received:", ev.type, ev);
|
|
22
|
+
switch (ev.type) {
|
|
23
|
+
|
|
24
|
+
// ── Internal WS lifecycle ──────────────────────────────────────────
|
|
25
|
+
case "_ws_connected": {
|
|
26
|
+
const banner = document.getElementById("offline-banner");
|
|
27
|
+
if (banner) banner.style.display = "none";
|
|
28
|
+
const hint = $("ws-disconnect-hint");
|
|
29
|
+
if (hint) hint.style.display = "none";
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
case "_ws_disconnected": {
|
|
34
|
+
const banner = document.getElementById("offline-banner");
|
|
35
|
+
if (banner) {
|
|
36
|
+
banner.textContent = I18n.t("offline.banner");
|
|
37
|
+
banner.style.display = "block";
|
|
38
|
+
}
|
|
39
|
+
// Do NOT force status bar to "idle" here — on a brief WS hiccup the
|
|
40
|
+
// agent may still be running, and reconnect will deliver a fresh
|
|
41
|
+
// session snapshot that patches the real status. Forcing idle here
|
|
42
|
+
// caused stuck UI after reconnect when the snapshot logic wasn't
|
|
43
|
+
// re-asserting status on every reconnect.
|
|
44
|
+
Sessions.clearAllProgress();
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Session list ───────────────────────────────────────────────────
|
|
49
|
+
case "session_list": {
|
|
50
|
+
Sessions.setAll(ev.sessions || [], !!ev.has_more);
|
|
51
|
+
Sessions.renderList();
|
|
52
|
+
|
|
53
|
+
// Restore URL hash once on initial connect; ignore subsequent session_list events.
|
|
54
|
+
// Skip if we are already on a session view (e.g. onboard flow navigated there
|
|
55
|
+
// before WS connected) — restoreFromHash would wrongly redirect to "welcome"
|
|
56
|
+
// because there is no hash set during onboarding.
|
|
57
|
+
if (!_initialRestoreDone) {
|
|
58
|
+
_initialRestoreDone = true;
|
|
59
|
+
if (Router.current !== "session") {
|
|
60
|
+
Router.restoreFromHash();
|
|
61
|
+
}
|
|
62
|
+
} else {
|
|
63
|
+
// If active session was deleted, go to welcome
|
|
64
|
+
if (Sessions.activeId && !Sessions.find(Sessions.activeId)) {
|
|
65
|
+
Router.navigate("welcome");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── Session lifecycle ──────────────────────────────────────────────
|
|
72
|
+
case "subscribed": {
|
|
73
|
+
// Re-enable send button now that the server has confirmed the subscription.
|
|
74
|
+
$("btn-send").disabled = false;
|
|
75
|
+
$("user-input").focus();
|
|
76
|
+
// If this session was created by Tasks.run(), fire the agent now that
|
|
77
|
+
// we're guaranteed to receive its broadcasts.
|
|
78
|
+
const pendingId = Sessions.takePendingRunTask();
|
|
79
|
+
if (pendingId && pendingId === ev.session_id) {
|
|
80
|
+
WS.send({ type: "run_task", session_id: pendingId });
|
|
81
|
+
}
|
|
82
|
+
// If a slash-command was queued (e.g. /onboard from first-boot flow),
|
|
83
|
+
// send it now — after restoreFromHash has settled — so appendMsg won't be wiped.
|
|
84
|
+
const pendingMsg = Sessions.takePendingMessage();
|
|
85
|
+
if (pendingMsg && pendingMsg.session_id === ev.session_id) {
|
|
86
|
+
Sessions.appendMsg("user", escapeHtml(pendingMsg.content), { time: new Date() });
|
|
87
|
+
WS.send({ type: "message", session_id: pendingMsg.session_id, content: pendingMsg.content });
|
|
88
|
+
}
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
case "session_update": {
|
|
93
|
+
// Two shapes arrive under this type:
|
|
94
|
+
// (1) Full session object from http_server broadcast_session_update:
|
|
95
|
+
// { type, session: { id, name, status, total_cost, total_tasks, ... } }
|
|
96
|
+
// (2) Partial real-time update from web_ui_controller (cost/tasks/status):
|
|
97
|
+
// { type, session_id, cost?, tasks?, status? }
|
|
98
|
+
let sid, patch;
|
|
99
|
+
if (ev.session) {
|
|
100
|
+
// Shape (1): full session — use as-is
|
|
101
|
+
sid = ev.session.id;
|
|
102
|
+
patch = ev.session;
|
|
103
|
+
} else {
|
|
104
|
+
// Shape (2): partial update — build patch from top-level fields
|
|
105
|
+
sid = ev.session_id;
|
|
106
|
+
patch = {};
|
|
107
|
+
if (ev.cost !== undefined) patch.total_cost = ev.cost;
|
|
108
|
+
if (ev.tasks !== undefined) patch.total_tasks = ev.tasks;
|
|
109
|
+
if (ev.status !== undefined) patch.status = ev.status;
|
|
110
|
+
// Latency pushed by Agent after each LLM call (see update_sessionbar).
|
|
111
|
+
// Stored under latest_latency — same field name the HTTP /api/sessions
|
|
112
|
+
// list returns, so updateInfoBar doesn't need to branch on the source.
|
|
113
|
+
if (ev.latency !== undefined) patch.latest_latency = ev.latency;
|
|
114
|
+
}
|
|
115
|
+
if (!sid) break;
|
|
116
|
+
Sessions.patch(sid, patch);
|
|
117
|
+
Sessions.renderList();
|
|
118
|
+
if (sid === Sessions.activeId) {
|
|
119
|
+
const current = Sessions.find(sid);
|
|
120
|
+
if (patch.status !== undefined) Sessions.updateStatusBar(patch.status);
|
|
121
|
+
Sessions.updateInfoBar(current);
|
|
122
|
+
// Update chat title/subtitle in case session was renamed or working_dir changed
|
|
123
|
+
Sessions.updateChatHeader(current);
|
|
124
|
+
}
|
|
125
|
+
// When a session finishes, refresh tasks and skills, and clear any progress state
|
|
126
|
+
if (patch.status === "idle") {
|
|
127
|
+
Tasks.load();
|
|
128
|
+
Skills.load();
|
|
129
|
+
// Clear progress state for this session (even if not currently active)
|
|
130
|
+
Sessions.clearProgress(sid);
|
|
131
|
+
}
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
case "session_renamed": {
|
|
136
|
+
Sessions.patch(ev.session_id, { name: ev.name });
|
|
137
|
+
Sessions.renderList();
|
|
138
|
+
// Title is now shown only in the sidebar; chat-header element was removed.
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
case "session_deleted":
|
|
143
|
+
Sessions.remove(ev.session_id);
|
|
144
|
+
if (ev.session_id === Sessions.activeId) Router.navigate("welcome");
|
|
145
|
+
Sessions.renderList();
|
|
146
|
+
break;
|
|
147
|
+
|
|
148
|
+
// ── Chat messages ──────────────────────────────────────────────────
|
|
149
|
+
case "history_user_message":
|
|
150
|
+
// Emitted only during history replay — never from live WS.
|
|
151
|
+
// Rendered by Sessions._fetchHistory; nothing to do here.
|
|
152
|
+
break;
|
|
153
|
+
|
|
154
|
+
case "assistant_message":
|
|
155
|
+
if (ev.session_id !== Sessions.activeId) break;
|
|
156
|
+
Sessions.clearProgress();
|
|
157
|
+
Sessions.appendMsg("assistant", ev.content);
|
|
158
|
+
break;
|
|
159
|
+
|
|
160
|
+
case "tool_call":
|
|
161
|
+
if (ev.session_id !== Sessions.activeId) break;
|
|
162
|
+
Sessions.clearProgress();
|
|
163
|
+
Sessions.appendToolCall(ev.name, ev.args, ev.summary);
|
|
164
|
+
break;
|
|
165
|
+
|
|
166
|
+
case "tool_result":
|
|
167
|
+
if (ev.session_id !== Sessions.activeId) break;
|
|
168
|
+
Sessions.appendToolResult(ev.result);
|
|
169
|
+
break;
|
|
170
|
+
|
|
171
|
+
case "tool_stdout":
|
|
172
|
+
if (ev.session_id !== Sessions.activeId) break;
|
|
173
|
+
Sessions.appendToolStdout(ev.lines);
|
|
174
|
+
break;
|
|
175
|
+
|
|
176
|
+
case "tool_error":
|
|
177
|
+
if (ev.session_id !== Sessions.activeId) break;
|
|
178
|
+
Sessions.appendMsg("info", `⚠ Tool error: ${escapeHtml(ev.error)}`);
|
|
179
|
+
break;
|
|
180
|
+
|
|
181
|
+
case "token_usage":
|
|
182
|
+
if (ev.session_id !== Sessions.activeId) break;
|
|
183
|
+
Sessions.appendTokenUsage(ev);
|
|
184
|
+
break;
|
|
185
|
+
|
|
186
|
+
case "progress":
|
|
187
|
+
console.log("[DEBUG] progress event:", ev);
|
|
188
|
+
if (ev.session_id !== Sessions.activeId) break;
|
|
189
|
+
if (ev.phase === "active" || ev.status === "start") {
|
|
190
|
+
const progress_type = ev.progress_type || "thinking";
|
|
191
|
+
const metadata = ev.metadata || {};
|
|
192
|
+
console.log("[DEBUG] calling showProgress:", { message: ev.message, progress_type, metadata, started_at: ev.started_at });
|
|
193
|
+
Sessions.showProgress(ev.message, progress_type, metadata, ev.started_at || null);
|
|
194
|
+
} else {
|
|
195
|
+
console.log("[DEBUG] calling clearProgress:", ev.message);
|
|
196
|
+
Sessions.clearProgress(ev.message);
|
|
197
|
+
}
|
|
198
|
+
break;
|
|
199
|
+
|
|
200
|
+
case "complete":
|
|
201
|
+
if (ev.session_id !== Sessions.activeId) break;
|
|
202
|
+
Sessions.clearProgress();
|
|
203
|
+
Sessions.collapseToolGroup();
|
|
204
|
+
{
|
|
205
|
+
const costSource = ev.cost_source;
|
|
206
|
+
const costDisplay = (!costSource || costSource === "estimated")
|
|
207
|
+
? "N/A"
|
|
208
|
+
: `$${(ev.cost || 0).toFixed(4)}`;
|
|
209
|
+
Sessions.appendInfo(`✓ ${I18n.t("chat.done", { n: ev.iterations, cost: costDisplay })}`);
|
|
210
|
+
}
|
|
211
|
+
break;
|
|
212
|
+
|
|
213
|
+
case "request_feedback":
|
|
214
|
+
if (ev.session_id !== Sessions.activeId) break;
|
|
215
|
+
Sessions.showFeedbackRequest(ev.question, ev.context, ev.options);
|
|
216
|
+
break;
|
|
217
|
+
|
|
218
|
+
case "request_confirmation":
|
|
219
|
+
if (ev.session_id !== Sessions.activeId) break;
|
|
220
|
+
showConfirmModal(ev.id, ev.message);
|
|
221
|
+
break;
|
|
222
|
+
|
|
223
|
+
case "interrupted":
|
|
224
|
+
if (ev.session_id !== Sessions.activeId) break;
|
|
225
|
+
Sessions.clearProgress();
|
|
226
|
+
Sessions.collapseToolGroup();
|
|
227
|
+
Sessions.appendInfo(I18n.t("chat.interrupted"));
|
|
228
|
+
break;
|
|
229
|
+
|
|
230
|
+
// ── Info / errors ──────────────────────────────────────────────────
|
|
231
|
+
case "info":
|
|
232
|
+
Sessions.appendInfo(ev.message);
|
|
233
|
+
break;
|
|
234
|
+
|
|
235
|
+
case "warning":
|
|
236
|
+
// Optimize retry messages for better UX
|
|
237
|
+
const friendlyWarning = _transformRetryWarning(ev.message);
|
|
238
|
+
if (friendlyWarning) {
|
|
239
|
+
Sessions.appendInfo(friendlyWarning);
|
|
240
|
+
}
|
|
241
|
+
break;
|
|
242
|
+
|
|
243
|
+
case "success":
|
|
244
|
+
Sessions.appendMsg("success", "✓ " + escapeHtml(ev.message));
|
|
245
|
+
break;
|
|
246
|
+
|
|
247
|
+
case "error":
|
|
248
|
+
if (!ev.session_id || ev.session_id === Sessions.activeId)
|
|
249
|
+
Sessions.appendMsg("error", escapeHtml(ev.message));
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
})();
|
data/lib/clacky.rb
CHANGED
|
@@ -74,9 +74,6 @@ require_relative "clacky/message_history"
|
|
|
74
74
|
require_relative "clacky/agent_config"
|
|
75
75
|
require_relative "clacky/agent_profile"
|
|
76
76
|
require_relative "clacky/providers"
|
|
77
|
-
require_relative "clacky/clacky_auth_client"
|
|
78
|
-
require_relative "clacky/clacky_cloud_config"
|
|
79
|
-
require_relative "clacky/cloud_project_client"
|
|
80
77
|
require_relative "clacky/session_manager"
|
|
81
78
|
require_relative "clacky/idle_compression_timer"
|
|
82
79
|
|
|
@@ -136,6 +133,11 @@ module Clacky
|
|
|
136
133
|
class AgentError < StandardError; end
|
|
137
134
|
class BadRequestError < AgentError; end # 400 errors — our request was malformed, history should be rolled back
|
|
138
135
|
class RetryableError < StandardError; end # Transient errors that should be retried (5xx, HTML response, rate limit)
|
|
136
|
+
# Upstream (model/router like OpenRouter/Bedrock) returned finish_reason="stop" together with
|
|
137
|
+
# one or more tool_calls whose `arguments` JSON was truncated (empty, "{}" placeholder, or
|
|
138
|
+
# otherwise unparseable). Subclass of RetryableError so it flows through the existing
|
|
139
|
+
# retry/fallback pipeline in LlmCaller#call_llm.
|
|
140
|
+
class UpstreamTruncatedError < RetryableError; end
|
|
139
141
|
class ToolCallError < AgentError; end # Raised when tool call fails due to invalid parameters
|
|
140
142
|
class BrowserNotReachableError < AgentError; end # Chrome/Edge not running or remote debugging disabled
|
|
141
143
|
# BrowserManager singleton: Clacky::BrowserManager.instance
|