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
data/lib/clacky/web/app.js
CHANGED
|
@@ -45,6 +45,8 @@ const PANELS = [
|
|
|
45
45
|
"task-detail-panel",
|
|
46
46
|
"skills-panel",
|
|
47
47
|
"channels-panel",
|
|
48
|
+
"trash-panel",
|
|
49
|
+
"profile-panel",
|
|
48
50
|
"settings-panel",
|
|
49
51
|
"creator-panel",
|
|
50
52
|
];
|
|
@@ -75,6 +77,11 @@ const Router = (() => {
|
|
|
75
77
|
if (h === "tasks") return { view: "tasks", params: {} };
|
|
76
78
|
if (h === "skills") return { view: "skills", params: {} };
|
|
77
79
|
if (h === "channels") return { view: "channels", params: {} };
|
|
80
|
+
if (h === "trash") return { view: "trash", params: {} };
|
|
81
|
+
if (h === "profile") return { view: "profile", params: {} };
|
|
82
|
+
// Legacy: #memories redirects to #profile (memories are now merged into
|
|
83
|
+
// the profile panel). Kept so bookmarks / external links don't 404.
|
|
84
|
+
if (h === "memories") return { view: "profile", params: {} };
|
|
78
85
|
if (h === "settings") return { view: "settings", params: {} };
|
|
79
86
|
if (h === "creator") return { view: "creator", params: {} };
|
|
80
87
|
const m = h.match(/^session\/(.+)$/);
|
|
@@ -89,6 +96,8 @@ const Router = (() => {
|
|
|
89
96
|
tasks: "tasks-sidebar-item",
|
|
90
97
|
skills: "skills-sidebar-item",
|
|
91
98
|
channels: "channels-sidebar-item",
|
|
99
|
+
trash: "trash-sidebar-item",
|
|
100
|
+
profile: "profile-sidebar-item",
|
|
92
101
|
creator: "creator-sidebar-item",
|
|
93
102
|
};
|
|
94
103
|
|
|
@@ -189,6 +198,20 @@ const Router = (() => {
|
|
|
189
198
|
Sessions.renderList();
|
|
190
199
|
break;
|
|
191
200
|
|
|
201
|
+
case "trash":
|
|
202
|
+
_setHash("trash");
|
|
203
|
+
$("trash-panel").style.display = "flex";
|
|
204
|
+
Trash.onPanelShow();
|
|
205
|
+
Sessions.renderList();
|
|
206
|
+
break;
|
|
207
|
+
|
|
208
|
+
case "profile":
|
|
209
|
+
_setHash("profile");
|
|
210
|
+
$("profile-panel").style.display = "flex";
|
|
211
|
+
Profile.onPanelShow();
|
|
212
|
+
Sessions.renderList();
|
|
213
|
+
break;
|
|
214
|
+
|
|
192
215
|
case "creator":
|
|
193
216
|
_setHash("creator");
|
|
194
217
|
$("creator-panel").style.display = "flex";
|
|
@@ -323,566 +346,14 @@ function showConfirmModal(confId, message) {
|
|
|
323
346
|
$("modal-no").onclick = () => answer("no");
|
|
324
347
|
}
|
|
325
348
|
|
|
326
|
-
// ── WS event dispatcher ───────────────────────────────────────────────────
|
|
327
|
-
// Guard: restore hash routing only once after initial session_list arrives.
|
|
328
|
-
let _initialRestoreDone = false;
|
|
329
|
-
|
|
330
|
-
WS.onEvent(ev => {
|
|
331
|
-
console.log("[DEBUG] WS event received:", ev.type, ev);
|
|
332
|
-
switch (ev.type) {
|
|
333
|
-
|
|
334
|
-
// ── Internal WS lifecycle ──────────────────────────────────────────
|
|
335
|
-
case "_ws_connected": {
|
|
336
|
-
const banner = document.getElementById("offline-banner");
|
|
337
|
-
if (banner) banner.style.display = "none";
|
|
338
|
-
const hint = $("ws-disconnect-hint");
|
|
339
|
-
if (hint) hint.style.display = "none";
|
|
340
|
-
break;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
case "_ws_disconnected": {
|
|
344
|
-
const banner = document.getElementById("offline-banner");
|
|
345
|
-
if (banner) {
|
|
346
|
-
banner.textContent = I18n.t("offline.banner");
|
|
347
|
-
banner.style.display = "block";
|
|
348
|
-
}
|
|
349
|
-
Sessions.clearAllProgress();
|
|
350
|
-
Sessions.updateStatusBar("idle");
|
|
351
|
-
break;
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
// ── Session list ───────────────────────────────────────────────────
|
|
355
|
-
case "session_list": {
|
|
356
|
-
Sessions.setAll(ev.sessions || [], !!ev.has_more);
|
|
357
|
-
Sessions.renderList();
|
|
358
|
-
|
|
359
|
-
// Restore URL hash once on initial connect; ignore subsequent session_list events.
|
|
360
|
-
// Skip if we are already on a session view (e.g. onboard flow navigated there
|
|
361
|
-
// before WS connected) — restoreFromHash would wrongly redirect to "welcome"
|
|
362
|
-
// because there is no hash set during onboarding.
|
|
363
|
-
if (!_initialRestoreDone) {
|
|
364
|
-
_initialRestoreDone = true;
|
|
365
|
-
if (Router.current !== "session") {
|
|
366
|
-
Router.restoreFromHash();
|
|
367
|
-
}
|
|
368
|
-
} else {
|
|
369
|
-
// If active session was deleted, go to welcome
|
|
370
|
-
if (Sessions.activeId && !Sessions.find(Sessions.activeId)) {
|
|
371
|
-
Router.navigate("welcome");
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
break;
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
// ── Session lifecycle ──────────────────────────────────────────────
|
|
378
|
-
case "subscribed": {
|
|
379
|
-
// Re-enable send button now that the server has confirmed the subscription.
|
|
380
|
-
$("btn-send").disabled = false;
|
|
381
|
-
$("user-input").focus();
|
|
382
|
-
// If this session was created by Tasks.run(), fire the agent now that
|
|
383
|
-
// we're guaranteed to receive its broadcasts.
|
|
384
|
-
const pendingId = Sessions.takePendingRunTask();
|
|
385
|
-
if (pendingId && pendingId === ev.session_id) {
|
|
386
|
-
WS.send({ type: "run_task", session_id: pendingId });
|
|
387
|
-
}
|
|
388
|
-
// If a slash-command was queued (e.g. /onboard from first-boot flow),
|
|
389
|
-
// send it now — after restoreFromHash has settled — so appendMsg won't be wiped.
|
|
390
|
-
const pendingMsg = Sessions.takePendingMessage();
|
|
391
|
-
if (pendingMsg && pendingMsg.session_id === ev.session_id) {
|
|
392
|
-
Sessions.appendMsg("user", escapeHtml(pendingMsg.content), { time: new Date() });
|
|
393
|
-
WS.send({ type: "message", session_id: pendingMsg.session_id, content: pendingMsg.content });
|
|
394
|
-
}
|
|
395
|
-
break;
|
|
396
|
-
}
|
|
397
349
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
// (1) Full session object from http_server broadcast_session_update:
|
|
401
|
-
// { type, session: { id, name, status, total_cost, total_tasks, ... } }
|
|
402
|
-
// (2) Partial real-time update from web_ui_controller (cost/tasks/status):
|
|
403
|
-
// { type, session_id, cost?, tasks?, status? }
|
|
404
|
-
let sid, patch;
|
|
405
|
-
if (ev.session) {
|
|
406
|
-
// Shape (1): full session — use as-is
|
|
407
|
-
sid = ev.session.id;
|
|
408
|
-
patch = ev.session;
|
|
409
|
-
} else {
|
|
410
|
-
// Shape (2): partial update — build patch from top-level fields
|
|
411
|
-
sid = ev.session_id;
|
|
412
|
-
patch = {};
|
|
413
|
-
if (ev.cost !== undefined) patch.total_cost = ev.cost;
|
|
414
|
-
if (ev.tasks !== undefined) patch.total_tasks = ev.tasks;
|
|
415
|
-
if (ev.status !== undefined) patch.status = ev.status;
|
|
416
|
-
// Latency pushed by Agent after each LLM call (see update_sessionbar).
|
|
417
|
-
// Stored under latest_latency — same field name the HTTP /api/sessions
|
|
418
|
-
// list returns, so updateInfoBar doesn't need to branch on the source.
|
|
419
|
-
if (ev.latency !== undefined) patch.latest_latency = ev.latency;
|
|
420
|
-
}
|
|
421
|
-
if (!sid) break;
|
|
422
|
-
Sessions.patch(sid, patch);
|
|
423
|
-
Sessions.renderList();
|
|
424
|
-
if (sid === Sessions.activeId) {
|
|
425
|
-
const current = Sessions.find(sid);
|
|
426
|
-
if (patch.status !== undefined) Sessions.updateStatusBar(patch.status);
|
|
427
|
-
Sessions.updateInfoBar(current);
|
|
428
|
-
// Update chat title/subtitle in case session was renamed or working_dir changed
|
|
429
|
-
Sessions.updateChatHeader(current);
|
|
430
|
-
}
|
|
431
|
-
// When a session finishes, refresh tasks and skills, and clear any progress state
|
|
432
|
-
if (patch.status === "idle") {
|
|
433
|
-
Tasks.load();
|
|
434
|
-
Skills.load();
|
|
435
|
-
// Clear progress state for this session (even if not currently active)
|
|
436
|
-
Sessions.clearProgress(sid);
|
|
437
|
-
}
|
|
438
|
-
break;
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
case "session_renamed": {
|
|
442
|
-
Sessions.patch(ev.session_id, { name: ev.name });
|
|
443
|
-
Sessions.renderList();
|
|
444
|
-
// Title is now shown only in the sidebar; chat-header element was removed.
|
|
445
|
-
break;
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
case "session_deleted":
|
|
449
|
-
Sessions.remove(ev.session_id);
|
|
450
|
-
if (ev.session_id === Sessions.activeId) Router.navigate("welcome");
|
|
451
|
-
Sessions.renderList();
|
|
452
|
-
break;
|
|
453
|
-
|
|
454
|
-
// ── Chat messages ──────────────────────────────────────────────────
|
|
455
|
-
case "history_user_message":
|
|
456
|
-
// Emitted only during history replay — never from live WS.
|
|
457
|
-
// Rendered by Sessions._fetchHistory; nothing to do here.
|
|
458
|
-
break;
|
|
459
|
-
|
|
460
|
-
case "assistant_message":
|
|
461
|
-
if (ev.session_id !== Sessions.activeId) break;
|
|
462
|
-
Sessions.clearProgress();
|
|
463
|
-
Sessions.appendMsg("assistant", ev.content);
|
|
464
|
-
break;
|
|
465
|
-
|
|
466
|
-
case "tool_call":
|
|
467
|
-
if (ev.session_id !== Sessions.activeId) break;
|
|
468
|
-
Sessions.clearProgress();
|
|
469
|
-
Sessions.appendToolCall(ev.name, ev.args, ev.summary);
|
|
470
|
-
break;
|
|
471
|
-
|
|
472
|
-
case "tool_result":
|
|
473
|
-
if (ev.session_id !== Sessions.activeId) break;
|
|
474
|
-
Sessions.appendToolResult(ev.result);
|
|
475
|
-
break;
|
|
476
|
-
|
|
477
|
-
case "tool_stdout":
|
|
478
|
-
if (ev.session_id !== Sessions.activeId) break;
|
|
479
|
-
Sessions.appendToolStdout(ev.lines);
|
|
480
|
-
break;
|
|
481
|
-
|
|
482
|
-
case "tool_error":
|
|
483
|
-
if (ev.session_id !== Sessions.activeId) break;
|
|
484
|
-
Sessions.appendMsg("info", `⚠ Tool error: ${escapeHtml(ev.error)}`);
|
|
485
|
-
break;
|
|
486
|
-
|
|
487
|
-
case "token_usage":
|
|
488
|
-
if (ev.session_id !== Sessions.activeId) break;
|
|
489
|
-
Sessions.appendTokenUsage(ev);
|
|
490
|
-
break;
|
|
491
|
-
|
|
492
|
-
case "progress":
|
|
493
|
-
console.log("[DEBUG] progress event:", ev);
|
|
494
|
-
if (ev.session_id !== Sessions.activeId) break;
|
|
495
|
-
if (ev.phase === "active" || ev.status === "start") {
|
|
496
|
-
const progress_type = ev.progress_type || "thinking";
|
|
497
|
-
const metadata = ev.metadata || {};
|
|
498
|
-
console.log("[DEBUG] calling showProgress:", { message: ev.message, progress_type, metadata, started_at: ev.started_at });
|
|
499
|
-
Sessions.showProgress(ev.message, progress_type, metadata, ev.started_at || null);
|
|
500
|
-
} else {
|
|
501
|
-
console.log("[DEBUG] calling clearProgress:", ev.message);
|
|
502
|
-
Sessions.clearProgress(ev.message);
|
|
503
|
-
}
|
|
504
|
-
break;
|
|
505
|
-
|
|
506
|
-
case "complete":
|
|
507
|
-
if (ev.session_id !== Sessions.activeId) break;
|
|
508
|
-
Sessions.clearProgress();
|
|
509
|
-
Sessions.collapseToolGroup();
|
|
510
|
-
{
|
|
511
|
-
const costSource = ev.cost_source;
|
|
512
|
-
const costDisplay = (!costSource || costSource === "estimated")
|
|
513
|
-
? "N/A"
|
|
514
|
-
: `$${(ev.cost || 0).toFixed(4)}`;
|
|
515
|
-
Sessions.appendInfo(`✓ ${I18n.t("chat.done", { n: ev.iterations, cost: costDisplay })}`);
|
|
516
|
-
}
|
|
517
|
-
break;
|
|
518
|
-
|
|
519
|
-
case "request_feedback":
|
|
520
|
-
if (ev.session_id !== Sessions.activeId) break;
|
|
521
|
-
Sessions.showFeedbackRequest(ev.question, ev.context, ev.options);
|
|
522
|
-
break;
|
|
523
|
-
|
|
524
|
-
case "request_confirmation":
|
|
525
|
-
if (ev.session_id !== Sessions.activeId) break;
|
|
526
|
-
showConfirmModal(ev.id, ev.message);
|
|
527
|
-
break;
|
|
528
|
-
|
|
529
|
-
case "interrupted":
|
|
530
|
-
if (ev.session_id !== Sessions.activeId) break;
|
|
531
|
-
Sessions.clearProgress();
|
|
532
|
-
Sessions.collapseToolGroup();
|
|
533
|
-
Sessions.appendInfo(I18n.t("chat.interrupted"));
|
|
534
|
-
break;
|
|
535
|
-
|
|
536
|
-
// ── Info / errors ──────────────────────────────────────────────────
|
|
537
|
-
case "info":
|
|
538
|
-
Sessions.appendInfo(ev.message);
|
|
539
|
-
break;
|
|
540
|
-
|
|
541
|
-
case "warning":
|
|
542
|
-
// Optimize retry messages for better UX
|
|
543
|
-
const friendlyWarning = _transformRetryWarning(ev.message);
|
|
544
|
-
if (friendlyWarning) {
|
|
545
|
-
Sessions.appendInfo(friendlyWarning);
|
|
546
|
-
}
|
|
547
|
-
break;
|
|
548
|
-
|
|
549
|
-
case "success":
|
|
550
|
-
Sessions.appendMsg("success", "✓ " + escapeHtml(ev.message));
|
|
551
|
-
break;
|
|
552
|
-
|
|
553
|
-
case "error":
|
|
554
|
-
if (!ev.session_id || ev.session_id === Sessions.activeId)
|
|
555
|
-
Sessions.appendMsg("error", escapeHtml(ev.message));
|
|
556
|
-
break;
|
|
557
|
-
}
|
|
558
|
-
});
|
|
350
|
+
// ── WS event dispatcher ───────────────────────────────────────────────────
|
|
351
|
+
// Moved to ws-dispatcher.js.
|
|
559
352
|
|
|
560
353
|
// ── Image & file attachments ──────────────────────────────────────────────
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
const MAX_IMAGE_BYTES_SEND = 512 * 1024; // 512 KB — target after compression
|
|
565
|
-
const MAX_IMAGE_LONG_EDGE = 1920; // px — scale down if larger
|
|
566
|
-
const MAX_FILE_BYTES = 32 * 1024 * 1024; // 32 MB
|
|
567
|
-
const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
|
568
|
-
const ACCEPTED_DOC_TYPES = [
|
|
569
|
-
"application/pdf",
|
|
570
|
-
"application/zip",
|
|
571
|
-
"application/x-zip-compressed",
|
|
572
|
-
"application/gzip",
|
|
573
|
-
"application/x-gzip",
|
|
574
|
-
"application/x-tar",
|
|
575
|
-
"application/x-compressed-tar",
|
|
576
|
-
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", // .docx
|
|
577
|
-
"application/msword", // .doc
|
|
578
|
-
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", // .xlsx
|
|
579
|
-
"application/vnd.ms-excel", // .xls
|
|
580
|
-
"application/vnd.openxmlformats-officedocument.presentationml.presentation", // .pptx
|
|
581
|
-
"application/vnd.ms-powerpoint", // .ppt
|
|
582
|
-
"text/csv", // .csv
|
|
583
|
-
"application/csv", // .csv (some browsers)
|
|
584
|
-
"text/markdown", // .md
|
|
585
|
-
"text/x-markdown", // .md (some browsers)
|
|
586
|
-
"text/plain", // .md / .txt (many browsers report this)
|
|
587
|
-
];
|
|
588
|
-
|
|
589
|
-
// Extension-based fallback for files whose MIME type is missing or unreliable.
|
|
590
|
-
// Browsers frequently report "" or "application/octet-stream" for .md / .tar.gz.
|
|
591
|
-
const ACCEPTED_DOC_EXTENSIONS = [
|
|
592
|
-
".pdf", ".zip",
|
|
593
|
-
".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
|
|
594
|
-
".csv",
|
|
595
|
-
".md", ".markdown", ".txt", ".log",
|
|
596
|
-
".tar", ".gz", ".tgz", ".tar.gz", ".rar", ".7z"
|
|
597
|
-
];
|
|
598
|
-
|
|
599
|
-
function _hasAcceptedDocExt(filename) {
|
|
600
|
-
const lower = (filename || "").toLowerCase();
|
|
601
|
-
return ACCEPTED_DOC_EXTENSIONS.some(ext => lower.endsWith(ext));
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
function _isAcceptedDoc(file) {
|
|
605
|
-
if (!file) return false;
|
|
606
|
-
if (file.type && ACCEPTED_DOC_TYPES.includes(file.type)) return true;
|
|
607
|
-
return _hasAcceptedDocExt(file.name);
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
function _isAcceptedImage(file) {
|
|
611
|
-
if (!file) return false;
|
|
612
|
-
return ACCEPTED_IMAGE_TYPES.includes(file.type);
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
function _isAcceptedFile(file) {
|
|
616
|
-
return _isAcceptedImage(file) || _isAcceptedDoc(file);
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
function _docTypeIcon(mimeType, filename) {
|
|
620
|
-
const lower = (filename || "").toLowerCase();
|
|
621
|
-
if (mimeType === "application/pdf" || lower.endsWith(".pdf")) return "📄";
|
|
622
|
-
if (mimeType === "application/zip" || mimeType === "application/x-zip-compressed" || lower.endsWith(".zip")) return "🗜️";
|
|
623
|
-
if (mimeType === "application/gzip" || mimeType === "application/x-gzip" ||
|
|
624
|
-
mimeType === "application/x-tar" || mimeType === "application/x-compressed-tar" ||
|
|
625
|
-
lower.endsWith(".tar") || lower.endsWith(".gz") || lower.endsWith(".tgz") || lower.endsWith(".tar.gz") ||
|
|
626
|
-
lower.endsWith(".rar") || lower.endsWith(".7z")) return "🗜️";
|
|
627
|
-
if ((mimeType && mimeType.includes("wordprocessingml")) || mimeType === "application/msword" ||
|
|
628
|
-
lower.endsWith(".doc") || lower.endsWith(".docx")) return "📝";
|
|
629
|
-
if ((mimeType && mimeType.includes("spreadsheetml")) || mimeType === "application/vnd.ms-excel" ||
|
|
630
|
-
lower.endsWith(".xls") || lower.endsWith(".xlsx")) return "📊";
|
|
631
|
-
if ((mimeType && mimeType.includes("presentationml")) || mimeType === "application/vnd.ms-powerpoint" ||
|
|
632
|
-
lower.endsWith(".ppt") || lower.endsWith(".pptx")) return "📋";
|
|
633
|
-
if (mimeType === "text/csv" || mimeType === "application/csv" || lower.endsWith(".csv")) return "📊";
|
|
634
|
-
if (mimeType === "text/markdown" || mimeType === "text/x-markdown" ||
|
|
635
|
-
lower.endsWith(".md") || lower.endsWith(".markdown")) return "📝";
|
|
636
|
-
if (mimeType === "text/plain" || lower.endsWith(".txt") || lower.endsWith(".log")) return "📄";
|
|
637
|
-
return "📎";
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
// Compress an image File/Blob to a data URL within MAX_IMAGE_BYTES_SEND.
|
|
641
|
-
// Strategy: scale down to MAX_IMAGE_LONG_EDGE, then reduce JPEG quality until small enough.
|
|
642
|
-
// GIF is not compressible via Canvas — returned as-is if within limit.
|
|
643
|
-
function _compressImage(file) {
|
|
644
|
-
return new Promise((resolve, reject) => {
|
|
645
|
-
const reader = new FileReader();
|
|
646
|
-
reader.onerror = () => reject(new Error("Failed to read image"));
|
|
647
|
-
reader.onload = e => {
|
|
648
|
-
const img = new Image();
|
|
649
|
-
img.onerror = () => reject(new Error("Failed to decode image"));
|
|
650
|
-
img.onload = () => {
|
|
651
|
-
// Scale down if needed
|
|
652
|
-
let { width, height } = img;
|
|
653
|
-
if (width > MAX_IMAGE_LONG_EDGE || height > MAX_IMAGE_LONG_EDGE) {
|
|
654
|
-
const ratio = Math.min(MAX_IMAGE_LONG_EDGE / width, MAX_IMAGE_LONG_EDGE / height);
|
|
655
|
-
width = Math.round(width * ratio);
|
|
656
|
-
height = Math.round(height * ratio);
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
const canvas = document.createElement("canvas");
|
|
660
|
-
canvas.width = width;
|
|
661
|
-
canvas.height = height;
|
|
662
|
-
const ctx = canvas.getContext("2d");
|
|
663
|
-
ctx.drawImage(img, 0, 0, width, height);
|
|
664
|
-
|
|
665
|
-
// GIF: LLMs only see the first frame anyway — render via Canvas and compress as JPEG
|
|
666
|
-
|
|
667
|
-
// Try decreasing quality until under limit
|
|
668
|
-
let quality = 0.85;
|
|
669
|
-
let dataUrl = canvas.toDataURL("image/jpeg", quality);
|
|
670
|
-
while (dataUrl.length * 0.75 > MAX_IMAGE_BYTES_SEND && quality > 0.2) {
|
|
671
|
-
quality -= 0.1;
|
|
672
|
-
dataUrl = canvas.toDataURL("image/jpeg", quality);
|
|
673
|
-
}
|
|
674
|
-
resolve(dataUrl);
|
|
675
|
-
};
|
|
676
|
-
img.src = e.target.result;
|
|
677
|
-
};
|
|
678
|
-
reader.readAsDataURL(file);
|
|
679
|
-
});
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
function _addImageFile(file) {
|
|
683
|
-
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
|
|
684
|
-
alert(`Unsupported image type: ${file.type}\nSupported: PNG, JPEG, GIF, WEBP`);
|
|
685
|
-
return;
|
|
686
|
-
}
|
|
687
|
-
if (file.size > MAX_IMAGE_SIZE) {
|
|
688
|
-
alert(`Image too large: ${file.name} (max 5 MB)`);
|
|
689
|
-
return;
|
|
690
|
-
}
|
|
691
|
-
_compressImage(file)
|
|
692
|
-
.then(dataUrl => {
|
|
693
|
-
_pendingImages.push({ dataUrl, name: file.name, mimeType: "image/jpeg" });
|
|
694
|
-
_renderAttachmentPreviews();
|
|
695
|
-
})
|
|
696
|
-
.catch(err => alert(`Image processing failed: ${err.message}`));
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
function _addGenericFile(file) {
|
|
700
|
-
if (file.size > MAX_FILE_BYTES) {
|
|
701
|
-
alert(`File too large: ${file.name} (max 32 MB)`);
|
|
702
|
-
return;
|
|
703
|
-
}
|
|
704
|
-
// Upload file to server via HTTP — only the path is returned, no base64 in memory
|
|
705
|
-
const formData = new FormData();
|
|
706
|
-
formData.append("file", file);
|
|
707
|
-
fetch("/api/upload", { method: "POST", body: formData })
|
|
708
|
-
.then(r => r.json())
|
|
709
|
-
.then(data => {
|
|
710
|
-
if (!data.ok) { alert(`Upload failed: ${data.error}`); return; }
|
|
711
|
-
_pendingFiles.push({
|
|
712
|
-
name: data.name,
|
|
713
|
-
path: data.path,
|
|
714
|
-
mime_type: file.type
|
|
715
|
-
});
|
|
716
|
-
_renderAttachmentPreviews();
|
|
717
|
-
setTimeout(() => $("user-input").focus(), 100);
|
|
718
|
-
})
|
|
719
|
-
.catch(err => alert(`Upload error: ${err.message}`));
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
function _addAttachmentFile(file) {
|
|
723
|
-
// Route by content category. Images must match known image MIME types
|
|
724
|
-
// (MIME is reliable for images). Documents fall back to extension-based
|
|
725
|
-
// detection because browsers frequently report "" or "application/octet-stream"
|
|
726
|
-
// for .md / .tar.gz files.
|
|
727
|
-
if (_isAcceptedImage(file)) {
|
|
728
|
-
_addImageFile(file);
|
|
729
|
-
} else if (_isAcceptedDoc(file)) {
|
|
730
|
-
_addGenericFile(file);
|
|
731
|
-
} else {
|
|
732
|
-
// Unknown type — surface a helpful error instead of silently rejecting.
|
|
733
|
-
alert(`Unsupported file: ${file.name}\nSupported: images (PNG/JPG/GIF/WEBP), PDF, Office (DOC/XLS/PPT), ZIP, TAR, TAR.GZ, MD, TXT, CSV`);
|
|
734
|
-
}
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
function _renderAttachmentPreviews() {
|
|
738
|
-
const strip = $("image-preview-strip");
|
|
739
|
-
strip.innerHTML = "";
|
|
740
|
-
const hasContent = _pendingImages.length > 0 || _pendingFiles.length > 0;
|
|
741
|
-
if (!hasContent) {
|
|
742
|
-
strip.style.display = "none";
|
|
743
|
-
return;
|
|
744
|
-
}
|
|
745
|
-
strip.style.display = "flex";
|
|
746
|
-
|
|
747
|
-
// Render image thumbnails
|
|
748
|
-
_pendingImages.forEach((img, idx) => {
|
|
749
|
-
const item = document.createElement("div");
|
|
750
|
-
item.className = "img-preview-item";
|
|
751
|
-
item.title = img.name;
|
|
752
|
-
const thumbnail = document.createElement("img");
|
|
753
|
-
thumbnail.src = img.dataUrl;
|
|
754
|
-
thumbnail.alt = img.name;
|
|
755
|
-
const removeBtn = document.createElement("button");
|
|
756
|
-
removeBtn.className = "img-preview-remove";
|
|
757
|
-
removeBtn.textContent = "✕";
|
|
758
|
-
removeBtn.title = "Remove";
|
|
759
|
-
removeBtn.addEventListener("click", () => {
|
|
760
|
-
_pendingImages.splice(idx, 1);
|
|
761
|
-
_renderAttachmentPreviews();
|
|
762
|
-
});
|
|
763
|
-
item.appendChild(thumbnail);
|
|
764
|
-
item.appendChild(removeBtn);
|
|
765
|
-
strip.appendChild(item);
|
|
766
|
-
});
|
|
767
|
-
|
|
768
|
-
// Render file cards (PDF, ZIP, DOC, XLS, PPT, etc.)
|
|
769
|
-
_pendingFiles.forEach((f, idx) => {
|
|
770
|
-
const item = document.createElement("div");
|
|
771
|
-
item.className = "pdf-preview-item";
|
|
772
|
-
item.title = f.name;
|
|
773
|
-
|
|
774
|
-
const icon = document.createElement("div");
|
|
775
|
-
icon.className = "pdf-preview-icon";
|
|
776
|
-
icon.textContent = _docTypeIcon(f.mime_type, f.name);
|
|
777
|
-
|
|
778
|
-
const info = document.createElement("div");
|
|
779
|
-
info.className = "pdf-preview-info";
|
|
780
|
-
|
|
781
|
-
const name = document.createElement("div");
|
|
782
|
-
name.className = "pdf-preview-name";
|
|
783
|
-
name.textContent = f.name;
|
|
784
|
-
|
|
785
|
-
const typeLabel = document.createElement("div");
|
|
786
|
-
typeLabel.className = "pdf-preview-type";
|
|
787
|
-
const _lowerName = (f.name || "").toLowerCase();
|
|
788
|
-
typeLabel.textContent = _lowerName.endsWith(".tar.gz")
|
|
789
|
-
? "TAR.GZ"
|
|
790
|
-
: (f.name.split(".").pop() || "file").toUpperCase();
|
|
791
|
-
|
|
792
|
-
info.appendChild(name);
|
|
793
|
-
info.appendChild(typeLabel);
|
|
794
|
-
|
|
795
|
-
const removeBtn = document.createElement("button");
|
|
796
|
-
removeBtn.className = "pdf-preview-remove";
|
|
797
|
-
removeBtn.textContent = "✕";
|
|
798
|
-
removeBtn.title = "Remove";
|
|
799
|
-
removeBtn.addEventListener("click", () => {
|
|
800
|
-
_pendingFiles.splice(idx, 1);
|
|
801
|
-
_renderAttachmentPreviews();
|
|
802
|
-
});
|
|
803
|
-
|
|
804
|
-
item.appendChild(icon);
|
|
805
|
-
item.appendChild(info);
|
|
806
|
-
item.appendChild(removeBtn);
|
|
807
|
-
strip.appendChild(item);
|
|
808
|
-
});
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
// Keep backward-compat alias (used in drag-drop / paste handlers below)
|
|
812
|
-
function _renderImagePreviews() { _renderAttachmentPreviews(); }
|
|
813
|
-
|
|
814
|
-
// ── Send message ──────────────────────────────────────────────────────────
|
|
815
|
-
let _sending = false;
|
|
816
|
-
|
|
817
|
-
function sendMessage() {
|
|
818
|
-
if (_sending) return;
|
|
819
|
-
const input = $("user-input");
|
|
820
|
-
const content = input.value.trim();
|
|
821
|
-
if (!content && _pendingImages.length === 0 && _pendingFiles.length === 0) return;
|
|
822
|
-
if (!Sessions.activeId) return;
|
|
823
|
-
|
|
824
|
-
if (!WS.ready) {
|
|
825
|
-
const hint = $("ws-disconnect-hint");
|
|
826
|
-
if (hint) {
|
|
827
|
-
hint.textContent = I18n.t("chat.disconnected.hint");
|
|
828
|
-
hint.style.display = "block";
|
|
829
|
-
hint.style.opacity = "1";
|
|
830
|
-
clearTimeout(hint._hideTimer);
|
|
831
|
-
hint._hideTimer = setTimeout(() => {
|
|
832
|
-
hint.style.opacity = "0";
|
|
833
|
-
setTimeout(() => { hint.style.display = "none"; }, 400);
|
|
834
|
-
}, 2000);
|
|
835
|
-
}
|
|
836
|
-
return;
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
_sending = true;
|
|
840
|
-
|
|
841
|
-
let bubbleHtml = content ? escapeHtml(content) : "";
|
|
842
|
-
if (_pendingImages.length > 0) {
|
|
843
|
-
const thumbs = _pendingImages
|
|
844
|
-
.map(img => `<img src="${img.dataUrl}" alt="${escapeHtml(img.name)}" class="msg-image-thumb">`)
|
|
845
|
-
.join("");
|
|
846
|
-
bubbleHtml = thumbs + (bubbleHtml ? "<br>" + bubbleHtml : "");
|
|
847
|
-
}
|
|
848
|
-
if (_pendingFiles.length > 0) {
|
|
849
|
-
const badges = _pendingFiles.map(f => {
|
|
850
|
-
const icon = _docTypeIcon(f.mime_type);
|
|
851
|
-
const ext = (f.name.split(".").pop() || "file").toUpperCase();
|
|
852
|
-
return `<span class="msg-pdf-badge">` +
|
|
853
|
-
`<span class="msg-pdf-badge-icon">${icon}</span>` +
|
|
854
|
-
`<span class="msg-pdf-badge-info">` +
|
|
855
|
-
`<span class="msg-pdf-badge-name">${escapeHtml(f.name)}</span>` +
|
|
856
|
-
`<span class="msg-pdf-badge-type">${escapeHtml(ext)}</span>` +
|
|
857
|
-
`</span>` +
|
|
858
|
-
`</span>`;
|
|
859
|
-
}).join(" ");
|
|
860
|
-
bubbleHtml = badges + (bubbleHtml ? "<br>" + bubbleHtml : "");
|
|
861
|
-
}
|
|
862
|
-
Sessions.appendMsg("user", bubbleHtml, { time: new Date() });
|
|
863
|
-
|
|
864
|
-
// Merge images and files into unified files array
|
|
865
|
-
const files = [
|
|
866
|
-
..._pendingImages.map(img => ({
|
|
867
|
-
name: img.name,
|
|
868
|
-
mime_type: img.mimeType || "image/jpeg",
|
|
869
|
-
data_url: img.dataUrl
|
|
870
|
-
})),
|
|
871
|
-
..._pendingFiles.map(f => ({
|
|
872
|
-
name: f.name,
|
|
873
|
-
path: f.path,
|
|
874
|
-
mime_type: f.mime_type
|
|
875
|
-
}))
|
|
876
|
-
];
|
|
877
|
-
_pendingImages.length = 0;
|
|
878
|
-
_pendingFiles.length = 0;
|
|
879
|
-
_renderAttachmentPreviews();
|
|
880
|
-
|
|
881
|
-
WS.send({ type: "message", session_id: Sessions.activeId, content, files });
|
|
882
|
-
input.value = "";
|
|
883
|
-
input.style.height = "auto";
|
|
884
|
-
setTimeout(() => { _sending = false; }, 300);
|
|
885
|
-
}
|
|
354
|
+
// Moved to sessions.js (Composer section — _initComposer() in Sessions.init()).
|
|
355
|
+
// All state (_pendingImages/_pendingFiles), helpers (_addAttachmentFile/etc.),
|
|
356
|
+
// preview rendering, and sendMessage() now live there as private members.
|
|
886
357
|
|
|
887
358
|
// ── DOM event listeners ───────────────────────────────────────────────────
|
|
888
359
|
// Sidebar toggle (with mobile overlay support)
|
|
@@ -928,625 +399,31 @@ function _mobileCloseSidebar() {
|
|
|
928
399
|
if (_isMobile()) _closeSidebar();
|
|
929
400
|
}
|
|
930
401
|
|
|
931
|
-
// ── New session
|
|
932
|
-
//
|
|
933
|
-
// Arrow button: show dropdown with "Advanced Options..." to open modal
|
|
934
|
-
if ($("btn-new-session-inline")) {
|
|
935
|
-
$("btn-new-session-inline").addEventListener("click", () => Sessions.create("general"));
|
|
936
|
-
}
|
|
937
|
-
if ($("btn-new-session-arrow")) {
|
|
938
|
-
$("btn-new-session-arrow").addEventListener("click", (e) => {
|
|
939
|
-
e.stopPropagation();
|
|
940
|
-
const dd = $("new-session-dropdown");
|
|
941
|
-
if (dd) dd.hidden = !dd.hidden;
|
|
942
|
-
});
|
|
943
|
-
}
|
|
944
|
-
// Dropdown item: Advanced Options → open modal
|
|
945
|
-
document.addEventListener("click", (e) => {
|
|
946
|
-
if (e.target && e.target.id === "btn-new-session-modal") {
|
|
947
|
-
e.stopPropagation();
|
|
948
|
-
$("new-session-dropdown").hidden = true;
|
|
949
|
-
Sessions.openNewSessionModal();
|
|
950
|
-
}
|
|
951
|
-
});
|
|
952
|
-
// Close dropdown when clicking elsewhere
|
|
953
|
-
document.addEventListener("click", () => {
|
|
954
|
-
const dd = $("new-session-dropdown");
|
|
955
|
-
if (dd && !dd.hidden) dd.hidden = true;
|
|
956
|
-
});
|
|
957
|
-
|
|
958
|
-
$("btn-welcome-new").addEventListener("click", () => Sessions.create("general"));
|
|
959
|
-
|
|
960
|
-
// ── New Session Modal event handlers ───────────────────────────────────────
|
|
961
|
-
if ($("new-session-modal-close")) {
|
|
962
|
-
$("new-session-modal-close").addEventListener("click", () => Sessions.closeNewSessionModal());
|
|
963
|
-
}
|
|
964
|
-
if ($("new-session-cancel")) {
|
|
965
|
-
$("new-session-cancel").addEventListener("click", () => Sessions.closeNewSessionModal());
|
|
966
|
-
}
|
|
967
|
-
if ($("new-session-create")) {
|
|
968
|
-
$("new-session-create").addEventListener("click", () => Sessions.createFromModal());
|
|
969
|
-
}
|
|
970
|
-
// Close modal when clicking overlay
|
|
971
|
-
if ($("new-session-modal")) {
|
|
972
|
-
$("new-session-modal").addEventListener("click", (e) => {
|
|
973
|
-
if (e.target.id === "new-session-modal") {
|
|
974
|
-
Sessions.closeNewSessionModal();
|
|
975
|
-
}
|
|
976
|
-
});
|
|
977
|
-
}
|
|
978
|
-
// Browse button (placeholder - actual file browsing would need native integration)
|
|
979
|
-
if ($("new-session-browse-btn")) {
|
|
980
|
-
$("new-session-browse-btn").addEventListener("click", () => {
|
|
981
|
-
alert("Tip: Enter your desired path directly, e.g., ~/clacky_workspace/my-project");
|
|
982
|
-
});
|
|
983
|
-
}
|
|
984
|
-
|
|
985
|
-
// Load-more sessions button (dynamic — rendered by Sessions.renderList, use delegation)
|
|
986
|
-
document.addEventListener("click", e => {
|
|
987
|
-
if (e.target && e.target.id === "btn-load-more-sessions") {
|
|
988
|
-
Sessions.loadMore();
|
|
989
|
-
}
|
|
990
|
-
});
|
|
402
|
+
// ── New session controls ───────────────────────────────────────────────────
|
|
403
|
+
// Moved to sessions.js (_initNewSessionControls, called from Sessions.init()).
|
|
991
404
|
|
|
992
405
|
// ── Session search bar ─────────────────────────────────────────────────────
|
|
993
|
-
//
|
|
994
|
-
document.addEventListener("click", e => {
|
|
995
|
-
if (e.target && e.target.closest("#btn-session-search-toggle")) {
|
|
996
|
-
Sessions.toggleSearch();
|
|
997
|
-
}
|
|
998
|
-
});
|
|
406
|
+
// Moved to sessions.js (_initSearch in Sessions.init()).
|
|
999
407
|
|
|
1000
|
-
//
|
|
1001
|
-
document.addEventListener("click", e => {
|
|
1002
|
-
if (e.target && e.target.id === "btn-session-search-close") {
|
|
1003
|
-
if (Sessions.searchOpen) Sessions.toggleSearch();
|
|
1004
|
-
}
|
|
1005
|
-
});
|
|
1006
|
-
|
|
1007
|
-
// Enter key → commitSearch
|
|
1008
|
-
document.addEventListener("keydown", e => {
|
|
1009
|
-
if (e.key === "Enter" && e.target && e.target.id === "session-search-q") {
|
|
1010
|
-
e.preventDefault();
|
|
1011
|
-
Sessions.commitSearch();
|
|
1012
|
-
}
|
|
1013
|
-
});
|
|
1014
|
-
|
|
1015
|
-
// Inline ✕ button — clear the q input and re-fetch
|
|
1016
|
-
document.addEventListener("click", e => {
|
|
1017
|
-
if (e.target && e.target.id === "btn-search-q-clear") {
|
|
1018
|
-
const qEl = document.getElementById("session-search-q");
|
|
1019
|
-
if (qEl) qEl.value = "";
|
|
1020
|
-
Sessions.clearFilter("q");
|
|
1021
|
-
}
|
|
1022
|
-
});
|
|
1023
|
-
|
|
1024
|
-
// Clear active filters (type + date) and re-fetch once
|
|
1025
|
-
document.addEventListener("click", e => {
|
|
1026
|
-
if (e.target && e.target.id === "btn-search-clear-all") {
|
|
1027
|
-
const typeEl = document.getElementById("session-search-type");
|
|
1028
|
-
const dateEl = document.getElementById("session-search-date");
|
|
1029
|
-
if (typeEl) typeEl.value = "";
|
|
1030
|
-
if (dateEl) dateEl.value = "";
|
|
1031
|
-
Sessions.commitSearch();
|
|
1032
|
-
}
|
|
1033
|
-
});
|
|
1034
|
-
|
|
1035
|
-
// Show/hide inline ✕ as user types in search input
|
|
1036
|
-
document.addEventListener("input", e => {
|
|
1037
|
-
if (e.target && e.target.id === "session-search-q") {
|
|
1038
|
-
const btn = document.getElementById("btn-search-q-clear");
|
|
1039
|
-
if (btn) btn.hidden = !e.target.value;
|
|
1040
|
-
}
|
|
1041
|
-
});
|
|
1042
|
-
|
|
1043
|
-
// Type select and date change → immediate commit (they're explicit choices, not typing)
|
|
1044
|
-
document.addEventListener("change", e => {
|
|
1045
|
-
if (e.target && (e.target.id === "session-search-type" || e.target.id === "session-search-date")) {
|
|
1046
|
-
Sessions.commitSearch();
|
|
1047
|
-
}
|
|
1048
|
-
});
|
|
408
|
+
// ── Theme / session-scoped message panel bindings ──────────────────────────
|
|
1049
409
|
|
|
1050
410
|
// Theme toggle in header
|
|
1051
411
|
if ($("theme-toggle-header")) {
|
|
1052
412
|
$("theme-toggle-header").addEventListener("click", () => Theme.toggle());
|
|
1053
413
|
}
|
|
1054
|
-
// btn-delete-session
|
|
1055
|
-
//
|
|
1056
|
-
const _btnDeleteSession = $("btn-delete-session");
|
|
1057
|
-
if (_btnDeleteSession) {
|
|
1058
|
-
_btnDeleteSession.addEventListener("click", () => {
|
|
1059
|
-
if (Sessions.activeId) Sessions.deleteSession(Sessions.activeId);
|
|
1060
|
-
});
|
|
1061
|
-
}
|
|
414
|
+
// btn-delete-session, #messages scroll-to-top (load history), and btn-interrupt
|
|
415
|
+
// moved to sessions.js (_initMessageHistory in Sessions.init()).
|
|
1062
416
|
|
|
1063
|
-
//
|
|
1064
|
-
|
|
1065
|
-
const messages = $("messages");
|
|
1066
|
-
if (messages.scrollTop < 80 && Sessions.activeId && Sessions.hasMoreHistory(Sessions.activeId)) {
|
|
1067
|
-
Sessions.loadMoreHistory(Sessions.activeId);
|
|
1068
|
-
}
|
|
1069
|
-
});
|
|
1070
|
-
$("btn-send").addEventListener("click", sendMessage);
|
|
1071
|
-
$("btn-interrupt").addEventListener("click", () =>
|
|
1072
|
-
WS.send({ type: "interrupt", session_id: Sessions.activeId })
|
|
1073
|
-
);
|
|
1074
|
-
|
|
1075
|
-
$("btn-attach").addEventListener("click", () => $("image-file-input").click());
|
|
1076
|
-
|
|
1077
|
-
// / button: set input to "/" and open skill autocomplete
|
|
1078
|
-
// mousedown + preventDefault prevents the textarea from losing focus (which would
|
|
1079
|
-
// trigger the blur→hide timer and immediately close the dropdown we're about to open).
|
|
1080
|
-
$("btn-slash").addEventListener("mousedown", e => {
|
|
1081
|
-
e.preventDefault(); // keep focus on user-input
|
|
1082
|
-
});
|
|
1083
|
-
$("btn-slash").addEventListener("click", () => {
|
|
1084
|
-
const input = $("user-input");
|
|
1085
|
-
if (input.value === "" || input.value === "/") {
|
|
1086
|
-
input.value = "/";
|
|
1087
|
-
input.style.height = "auto";
|
|
1088
|
-
input.style.height = Math.min(input.scrollHeight, 200) + "px";
|
|
1089
|
-
}
|
|
1090
|
-
SkillAC.toggle(); // Toggle dropdown instead of always opening
|
|
1091
|
-
if (SkillAC.visible) {
|
|
1092
|
-
$("btn-slash").classList.add("active");
|
|
1093
|
-
}
|
|
1094
|
-
input.focus();
|
|
1095
|
-
});
|
|
1096
|
-
$("image-file-input").addEventListener("change", e => {
|
|
1097
|
-
Array.from(e.target.files).forEach(_addAttachmentFile);
|
|
1098
|
-
e.target.value = "";
|
|
1099
|
-
});
|
|
1100
|
-
|
|
1101
|
-
const inputArea = document.getElementById("input-area");
|
|
1102
|
-
inputArea.addEventListener("dragover", e => {
|
|
1103
|
-
e.preventDefault();
|
|
1104
|
-
inputArea.classList.add("drag-over");
|
|
1105
|
-
});
|
|
1106
|
-
inputArea.addEventListener("dragleave", e => {
|
|
1107
|
-
if (!inputArea.contains(e.relatedTarget)) inputArea.classList.remove("drag-over");
|
|
1108
|
-
});
|
|
1109
|
-
inputArea.addEventListener("drop", e => {
|
|
1110
|
-
e.preventDefault();
|
|
1111
|
-
inputArea.classList.remove("drag-over");
|
|
1112
|
-
const files = Array.from(e.dataTransfer.files).filter(_isAcceptedFile);
|
|
1113
|
-
if (files.length === 0) return;
|
|
1114
|
-
files.forEach(_addAttachmentFile);
|
|
1115
|
-
});
|
|
1116
|
-
|
|
1117
|
-
$("user-input").addEventListener("paste", e => {
|
|
1118
|
-
const items = Array.from(e.clipboardData?.items || []);
|
|
1119
|
-
// Paste filter: any file-kind item that's an image, or a document whose
|
|
1120
|
-
// type/name passes our doc filter. Must check name via getAsFile() for
|
|
1121
|
-
// markdown/tar.gz (browsers often leave item.type empty for these).
|
|
1122
|
-
const attachItems = items.filter(it => {
|
|
1123
|
-
if (it.kind !== "file") return false;
|
|
1124
|
-
if (ACCEPTED_IMAGE_TYPES.includes(it.type)) return true;
|
|
1125
|
-
if (ACCEPTED_DOC_TYPES.includes(it.type)) return true;
|
|
1126
|
-
// Last resort: check filename extension on the actual File object.
|
|
1127
|
-
const f = it.getAsFile && it.getAsFile();
|
|
1128
|
-
return f ? _hasAcceptedDocExt(f.name) : false;
|
|
1129
|
-
});
|
|
1130
|
-
if (attachItems.length === 0) return;
|
|
1131
|
-
e.preventDefault();
|
|
1132
|
-
attachItems.forEach(it => _addAttachmentFile(it.getAsFile()));
|
|
1133
|
-
});
|
|
1134
|
-
|
|
1135
|
-
// Cross-browser IME composition fix:
|
|
1136
|
-
// Safari fires compositionend BEFORE keydown (violating W3C spec), and the
|
|
1137
|
-
// gap between compositionend and keydown is ~5ms on Safari. We record the
|
|
1138
|
-
// timestamp of compositionend and treat any Enter keydown within 20ms as
|
|
1139
|
-
// still-composing. Chrome is unaffected because e.isComposing is still true.
|
|
1140
|
-
// Reference: https://bugs.webkit.org/show_bug.cgi?id=165004
|
|
1141
|
-
let _lastCompositionEndTime = -Infinity;
|
|
1142
|
-
$("user-input").addEventListener("compositionend", () => {
|
|
1143
|
-
_lastCompositionEndTime = Date.now();
|
|
1144
|
-
});
|
|
1145
|
-
|
|
1146
|
-
// ── Skill autocomplete ────────────────────────────────────────────────────
|
|
1147
|
-
const SkillAC = (() => {
|
|
1148
|
-
let _visible = false;
|
|
1149
|
-
let _activeIndex = -1;
|
|
1150
|
-
let _items = []; // filtered [{ name, description, encrypted, source }]
|
|
1151
|
-
let _currentSession = null; // track active session id for live fetch
|
|
1152
|
-
|
|
1153
|
-
// Load from localStorage, default to false (hide system skills)
|
|
1154
|
-
let _showSystemSkills = localStorage.getItem("skill-ac-show-system") === "true";
|
|
1155
|
-
|
|
1156
|
-
/** Called whenever the active session changes — just store the id, no prefetch. */
|
|
1157
|
-
function _loadForSession(sessionId) {
|
|
1158
|
-
_currentSession = sessionId || null;
|
|
1159
|
-
}
|
|
1160
|
-
|
|
1161
|
-
/** Fetch live skill list from server for the current session. */
|
|
1162
|
-
async function _fetchSkills() {
|
|
1163
|
-
if (!_currentSession) return [];
|
|
1164
|
-
try {
|
|
1165
|
-
const res = await fetch(`/api/sessions/${_currentSession}/skills`);
|
|
1166
|
-
const data = await res.json();
|
|
1167
|
-
return data.skills || [];
|
|
1168
|
-
} catch (e) {
|
|
1169
|
-
console.error("[SkillAC] fetchSkills failed", e);
|
|
1170
|
-
return [];
|
|
1171
|
-
}
|
|
1172
|
-
}
|
|
1173
|
-
|
|
1174
|
-
/** Return the /xxx prefix if the entire input is a slash command, else null. */
|
|
1175
|
-
function _getSlashQuery(value) {
|
|
1176
|
-
// Full-width slash / dunhao are already replaced in the input event handler,
|
|
1177
|
-
// but guard here too in case value is passed programmatically.
|
|
1178
|
-
let trimmed = value.replace(/^[/、]/, "/");
|
|
1179
|
-
|
|
1180
|
-
// Only activate when the whole input starts with / (no leading space)
|
|
1181
|
-
if (!trimmed.startsWith("/")) return null;
|
|
1182
|
-
// Only single-word slash token — no spaces allowed after /
|
|
1183
|
-
if (/^\/\S*$/.test(trimmed)) return trimmed.slice(1).toLowerCase();
|
|
1184
|
-
return null;
|
|
1185
|
-
}
|
|
1186
|
-
|
|
1187
|
-
/**
|
|
1188
|
-
* Score how well a skill matches the query string.
|
|
1189
|
-
* Only matches against name and name_zh — description is intentionally excluded.
|
|
1190
|
-
* All matches are contiguous substring matches (no fuzzy/subsequence).
|
|
1191
|
-
* Returns 0 if no match (should be filtered out).
|
|
1192
|
-
*
|
|
1193
|
-
* Scoring tiers:
|
|
1194
|
-
* 100 — name or name_zh exact match
|
|
1195
|
-
* 80 — name or name_zh starts-with
|
|
1196
|
-
* 60 — name or name_zh contains
|
|
1197
|
-
* 0 — no match
|
|
1198
|
-
*/
|
|
1199
|
-
function _scoreMatch(skill, query) {
|
|
1200
|
-
if (!query) return 50; // empty query → show all with neutral score
|
|
1201
|
-
|
|
1202
|
-
const q = query.toLowerCase();
|
|
1203
|
-
const name = (skill.name || "").toLowerCase();
|
|
1204
|
-
const zh = (skill.name_zh || "").toLowerCase();
|
|
1205
|
-
|
|
1206
|
-
// Exact match
|
|
1207
|
-
if (name === q || zh === q) return 100;
|
|
1208
|
-
|
|
1209
|
-
// Prefix match
|
|
1210
|
-
if (name.startsWith(q) || zh.startsWith(q)) return 80;
|
|
1211
|
-
|
|
1212
|
-
// Contains match (contiguous substring)
|
|
1213
|
-
if (name.includes(q) || zh.includes(q)) return 60;
|
|
1214
|
-
|
|
1215
|
-
return 0;
|
|
1216
|
-
}
|
|
1217
|
-
|
|
1218
|
-
/**
|
|
1219
|
-
* Wrap the matching substring in <mark> for highlighting.
|
|
1220
|
-
* Returns an array of DOM nodes (text + mark nodes).
|
|
1221
|
-
*/
|
|
1222
|
-
function _highlight(text, query) {
|
|
1223
|
-
if (!query) return [document.createTextNode(text)];
|
|
1224
|
-
const idx = text.toLowerCase().indexOf(query.toLowerCase());
|
|
1225
|
-
if (idx === -1) return [document.createTextNode(text)];
|
|
1226
|
-
|
|
1227
|
-
const nodes = [];
|
|
1228
|
-
if (idx > 0) nodes.push(document.createTextNode(text.slice(0, idx)));
|
|
1229
|
-
const mark = document.createElement("span");
|
|
1230
|
-
mark.className = "skill-ac-highlight";
|
|
1231
|
-
mark.textContent = text.slice(idx, idx + query.length);
|
|
1232
|
-
nodes.push(mark);
|
|
1233
|
-
if (idx + query.length < text.length) {
|
|
1234
|
-
nodes.push(document.createTextNode(text.slice(idx + query.length)));
|
|
1235
|
-
}
|
|
1236
|
-
return nodes;
|
|
1237
|
-
}
|
|
1238
|
-
|
|
1239
|
-
async function _render(query) {
|
|
1240
|
-
const all = await _fetchSkills();
|
|
1241
|
-
|
|
1242
|
-
// Score and filter
|
|
1243
|
-
let scored = all
|
|
1244
|
-
.map(s => ({ skill: s, score: _scoreMatch(s, query) }))
|
|
1245
|
-
.filter(({ score }) => score > 0);
|
|
1246
|
-
|
|
1247
|
-
if (!_showSystemSkills) {
|
|
1248
|
-
scored = scored.filter(({ skill }) => skill.source_type !== "default");
|
|
1249
|
-
}
|
|
417
|
+
// btn-send, btn-attach, image-file-input change, input-area drag/drop, and
|
|
418
|
+
// user-input paste handlers moved to sessions.js (_initComposer).
|
|
1250
419
|
|
|
1251
|
-
// Sort by score descending, stable secondary sort by name
|
|
1252
|
-
scored.sort((a, b) => b.score - a.score || a.skill.name.localeCompare(b.skill.name));
|
|
1253
420
|
|
|
1254
|
-
|
|
421
|
+
// ── Skill autocomplete + composer bindings ───────────────────────────────
|
|
422
|
+
// Moved to skills.js (SkillAC IIFE, initialized from SkillAC.init()).
|
|
1255
423
|
|
|
1256
|
-
const list = $("skill-autocomplete-list");
|
|
1257
|
-
list.innerHTML = "";
|
|
1258
|
-
|
|
1259
|
-
if (_items.length === 0) {
|
|
1260
|
-
// Show empty state instead of hiding the dropdown
|
|
1261
|
-
const emptyEl = document.createElement("div");
|
|
1262
|
-
emptyEl.className = "skill-ac-empty";
|
|
1263
|
-
emptyEl.textContent = I18n.t("skills.ac.empty");
|
|
1264
|
-
list.appendChild(emptyEl);
|
|
1265
|
-
$("skill-autocomplete").style.display = "";
|
|
1266
|
-
_visible = true;
|
|
1267
|
-
_createOverlay();
|
|
1268
|
-
return;
|
|
1269
|
-
}
|
|
1270
|
-
|
|
1271
|
-
_items.forEach((skill, idx) => {
|
|
1272
|
-
const item = document.createElement("div");
|
|
1273
|
-
item.className = "skill-ac-item" + (idx === _activeIndex ? " active" : "");
|
|
1274
|
-
item.setAttribute("role", "option");
|
|
1275
|
-
item.setAttribute("data-idx", idx);
|
|
1276
|
-
|
|
1277
|
-
const nameEl = document.createElement("span");
|
|
1278
|
-
nameEl.className = "skill-ac-name";
|
|
1279
|
-
|
|
1280
|
-
const currentLangForName = I18n.lang();
|
|
1281
|
-
const showZhFirst = currentLangForName === "zh" && skill.name_zh;
|
|
1282
|
-
|
|
1283
|
-
if (showZhFirst) {
|
|
1284
|
-
// Chinese UI: /中文名 first (with slash), then english id (no slash) after
|
|
1285
|
-
const zhEl = document.createElement("span");
|
|
1286
|
-
zhEl.className = "skill-ac-name-zh";
|
|
1287
|
-
zhEl.appendChild(document.createTextNode("/"));
|
|
1288
|
-
_highlight(skill.name_zh, query).forEach(function(n) { zhEl.appendChild(n); });
|
|
1289
|
-
nameEl.appendChild(zhEl);
|
|
1290
|
-
|
|
1291
|
-
const nameTextEl = document.createElement("span");
|
|
1292
|
-
nameTextEl.className = "skill-ac-name-id";
|
|
1293
|
-
_highlight(skill.name, query).forEach(function(n) { nameTextEl.appendChild(n); });
|
|
1294
|
-
nameEl.appendChild(nameTextEl);
|
|
1295
|
-
} else {
|
|
1296
|
-
// English UI (or no zh name): show /id only, no zh name
|
|
1297
|
-
const nameTextEl = document.createElement("span");
|
|
1298
|
-
nameTextEl.appendChild(document.createTextNode("/"));
|
|
1299
|
-
_highlight(skill.name, query).forEach(function(n) { nameTextEl.appendChild(n); });
|
|
1300
|
-
nameEl.appendChild(nameTextEl);
|
|
1301
|
-
}
|
|
1302
|
-
|
|
1303
|
-
// meta: encrypted badge + source type label (subtle)
|
|
1304
|
-
const metaEl = document.createElement("span");
|
|
1305
|
-
metaEl.className = "skill-ac-meta";
|
|
1306
|
-
if (skill.encrypted) {
|
|
1307
|
-
const encBadge = document.createElement("span");
|
|
1308
|
-
encBadge.className = "skill-ac-enc";
|
|
1309
|
-
encBadge.textContent = "🔒";
|
|
1310
|
-
metaEl.appendChild(encBadge);
|
|
1311
|
-
}
|
|
1312
|
-
const sourceLabel = {
|
|
1313
|
-
"default": "built-in",
|
|
1314
|
-
"global_clacky": "user",
|
|
1315
|
-
"global_claude": "user",
|
|
1316
|
-
"project_clacky": "project",
|
|
1317
|
-
"project_claude": "project",
|
|
1318
|
-
"brand": "brand",
|
|
1319
|
-
}[skill.source_type];
|
|
1320
|
-
if (sourceLabel) {
|
|
1321
|
-
const srcEl = document.createElement("span");
|
|
1322
|
-
srcEl.className = "skill-ac-src";
|
|
1323
|
-
srcEl.textContent = sourceLabel;
|
|
1324
|
-
metaEl.appendChild(srcEl);
|
|
1325
|
-
}
|
|
1326
|
-
|
|
1327
|
-
const descEl = document.createElement("span");
|
|
1328
|
-
descEl.className = "skill-ac-desc";
|
|
1329
|
-
// Choose description based on current language
|
|
1330
|
-
const description = (currentLangForName === "zh" && skill.description_zh)
|
|
1331
|
-
? skill.description_zh
|
|
1332
|
-
: skill.description || "";
|
|
1333
|
-
descEl.textContent = description;
|
|
1334
|
-
|
|
1335
|
-
item.appendChild(nameEl);
|
|
1336
|
-
item.appendChild(metaEl);
|
|
1337
|
-
item.appendChild(descEl);
|
|
1338
|
-
|
|
1339
|
-
item.addEventListener("mousedown", e => {
|
|
1340
|
-
// mousedown fires before blur — prevent input losing focus
|
|
1341
|
-
e.preventDefault();
|
|
1342
|
-
_select(idx);
|
|
1343
|
-
});
|
|
1344
|
-
|
|
1345
|
-
list.appendChild(item);
|
|
1346
|
-
});
|
|
1347
|
-
|
|
1348
|
-
$("skill-autocomplete").style.display = "";
|
|
1349
|
-
_visible = true;
|
|
1350
|
-
_createOverlay();
|
|
1351
|
-
}
|
|
1352
|
-
|
|
1353
|
-
function _hide() {
|
|
1354
|
-
$("skill-autocomplete").style.display = "none";
|
|
1355
|
-
_visible = false;
|
|
1356
|
-
_activeIndex = -1;
|
|
1357
|
-
_items = [];
|
|
1358
|
-
$("btn-slash")?.classList.remove("active");
|
|
1359
|
-
_removeOverlay();
|
|
1360
|
-
}
|
|
1361
|
-
|
|
1362
|
-
function _createOverlay() {
|
|
1363
|
-
// Remove existing overlay if any
|
|
1364
|
-
_removeOverlay();
|
|
1365
|
-
|
|
1366
|
-
const overlay = document.createElement("div");
|
|
1367
|
-
overlay.id = "skill-ac-overlay";
|
|
1368
|
-
overlay.style.cssText = "position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 999; background: transparent;";
|
|
1369
|
-
|
|
1370
|
-
// Click overlay to close dropdown
|
|
1371
|
-
overlay.addEventListener("click", () => {
|
|
1372
|
-
_hide();
|
|
1373
|
-
});
|
|
1374
|
-
|
|
1375
|
-
document.body.appendChild(overlay);
|
|
1376
|
-
}
|
|
1377
|
-
|
|
1378
|
-
function _removeOverlay() {
|
|
1379
|
-
const overlay = document.getElementById("skill-ac-overlay");
|
|
1380
|
-
if (overlay) overlay.remove();
|
|
1381
|
-
}
|
|
1382
|
-
|
|
1383
|
-
function _select(idx) {
|
|
1384
|
-
const skill = _items[idx];
|
|
1385
|
-
if (!skill) return;
|
|
1386
|
-
const input = $("user-input");
|
|
1387
|
-
input.value = "/" + skill.name + " ";
|
|
1388
|
-
input.style.height = "auto";
|
|
1389
|
-
input.style.height = Math.min(input.scrollHeight, 200) + "px";
|
|
1390
|
-
_hide();
|
|
1391
|
-
input.focus();
|
|
1392
|
-
}
|
|
1393
|
-
|
|
1394
|
-
function _moveActive(delta) {
|
|
1395
|
-
if (!_visible || _items.length === 0) return;
|
|
1396
|
-
_activeIndex = (_activeIndex + delta + _items.length) % _items.length;
|
|
1397
|
-
// Re-render to apply active class
|
|
1398
|
-
const list = $("skill-autocomplete-list");
|
|
1399
|
-
list.querySelectorAll(".skill-ac-item").forEach((el, i) => {
|
|
1400
|
-
el.classList.toggle("active", i === _activeIndex);
|
|
1401
|
-
if (i === _activeIndex) el.scrollIntoView({ block: "nearest" });
|
|
1402
|
-
});
|
|
1403
|
-
}
|
|
1404
|
-
|
|
1405
|
-
/** Open the dropdown showing all skills, used by the / button. */
|
|
1406
|
-
async function _openAll() {
|
|
1407
|
-
_activeIndex = 0; // Default to first item
|
|
1408
|
-
await _render("");
|
|
1409
|
-
$("user-input").focus();
|
|
1410
|
-
}
|
|
1411
|
-
|
|
1412
|
-
/** Toggle the dropdown (open if hidden, close if visible). */
|
|
1413
|
-
async function _toggle() {
|
|
1414
|
-
if (_visible) {
|
|
1415
|
-
_hide();
|
|
1416
|
-
} else {
|
|
1417
|
-
await _openAll();
|
|
1418
|
-
}
|
|
1419
|
-
}
|
|
1420
|
-
|
|
1421
|
-
return {
|
|
1422
|
-
get visible() { return _visible; },
|
|
1423
|
-
get activeIndex() { return _activeIndex; },
|
|
1424
|
-
|
|
1425
|
-
/** Initialize event listeners (call once on page load). */
|
|
1426
|
-
init() {
|
|
1427
|
-
const chk = $("chk-ac-show-system-skills");
|
|
1428
|
-
|
|
1429
|
-
if (chk) {
|
|
1430
|
-
// Restore state from localStorage
|
|
1431
|
-
chk.checked = _showSystemSkills;
|
|
1432
|
-
|
|
1433
|
-
chk.addEventListener("change", async () => {
|
|
1434
|
-
_showSystemSkills = chk.checked;
|
|
1435
|
-
// Persist to localStorage
|
|
1436
|
-
localStorage.setItem("skill-ac-show-system", _showSystemSkills ? "true" : "false");
|
|
1437
|
-
|
|
1438
|
-
// If dropdown is visible, re-fetch and re-render
|
|
1439
|
-
if (_visible) {
|
|
1440
|
-
const input = $("user-input");
|
|
1441
|
-
const query = _getSlashQuery(input.value);
|
|
1442
|
-
if (query !== null) {
|
|
1443
|
-
await _render(query);
|
|
1444
|
-
}
|
|
1445
|
-
}
|
|
1446
|
-
});
|
|
1447
|
-
}
|
|
1448
|
-
},
|
|
1449
|
-
|
|
1450
|
-
/** Called on every `input` event — decide whether to show/hide/update. */
|
|
1451
|
-
update(value) {
|
|
1452
|
-
const query = _getSlashQuery(value);
|
|
1453
|
-
if (query === null) { _hide(); return; }
|
|
1454
|
-
_activeIndex = 0; // Always highlight the first match
|
|
1455
|
-
_render(query); // async, fire-and-forget
|
|
1456
|
-
},
|
|
1457
|
-
|
|
1458
|
-
/** Open dropdown with all skills (triggered by / button). */
|
|
1459
|
-
openAll: _openAll,
|
|
1460
|
-
|
|
1461
|
-
/** Toggle dropdown visibility (used by / button). */
|
|
1462
|
-
toggle: _toggle,
|
|
1463
|
-
|
|
1464
|
-
/** Hide the dropdown. */
|
|
1465
|
-
hide: _hide,
|
|
1466
|
-
|
|
1467
|
-
/** Reload session-scoped skill list when the active session changes. */
|
|
1468
|
-
loadForSession: _loadForSession,
|
|
1469
|
-
|
|
1470
|
-
/** Handle keyboard nav inside the dropdown. Returns true if event was consumed. */
|
|
1471
|
-
handleKey(e) {
|
|
1472
|
-
if (!_visible) return false;
|
|
1473
|
-
if (e.key === "ArrowDown") { e.preventDefault(); _moveActive(1); return true; }
|
|
1474
|
-
if (e.key === "ArrowUp") { e.preventDefault(); _moveActive(-1); return true; }
|
|
1475
|
-
if (e.key === "Escape") { e.preventDefault(); _hide(); return true; }
|
|
1476
|
-
if (e.key === "Tab") {
|
|
1477
|
-
// Tab: select active item if one is highlighted, otherwise select first item
|
|
1478
|
-
e.preventDefault();
|
|
1479
|
-
const targetIdx = _activeIndex >= 0 ? _activeIndex : 0;
|
|
1480
|
-
_select(targetIdx);
|
|
1481
|
-
return true;
|
|
1482
|
-
}
|
|
1483
|
-
if (e.key === "Enter" && !e.isComposing && (Date.now() - _lastCompositionEndTime) > 20) {
|
|
1484
|
-
if (_activeIndex >= 0) {
|
|
1485
|
-
e.preventDefault();
|
|
1486
|
-
_select(_activeIndex);
|
|
1487
|
-
return true;
|
|
1488
|
-
}
|
|
1489
|
-
// No item highlighted — select first item if available
|
|
1490
|
-
if (_items.length > 0) {
|
|
1491
|
-
e.preventDefault();
|
|
1492
|
-
_select(0);
|
|
1493
|
-
return true;
|
|
1494
|
-
}
|
|
1495
|
-
// No items — let Enter fall through to sendMessage
|
|
1496
|
-
_hide();
|
|
1497
|
-
return false;
|
|
1498
|
-
}
|
|
1499
|
-
return false;
|
|
1500
|
-
},
|
|
1501
|
-
|
|
1502
|
-
hide: _hide,
|
|
1503
|
-
};
|
|
1504
|
-
})();
|
|
1505
|
-
|
|
1506
|
-
$("user-input").addEventListener("keydown", e => {
|
|
1507
|
-
// Let skill autocomplete consume arrow/enter/escape first
|
|
1508
|
-
if (SkillAC.handleKey(e)) return;
|
|
1509
|
-
|
|
1510
|
-
if (e.key === "Enter" && !e.shiftKey && !e.isComposing && (Date.now() - _lastCompositionEndTime) > 20) {
|
|
1511
|
-
e.preventDefault();
|
|
1512
|
-
sendMessage();
|
|
1513
|
-
}
|
|
1514
|
-
});
|
|
1515
|
-
|
|
1516
|
-
$("user-input").addEventListener("input", () => {
|
|
1517
|
-
const el = $("user-input");
|
|
1518
|
-
el.style.height = "auto";
|
|
1519
|
-
el.style.height = Math.min(el.scrollHeight, 200) + "px";
|
|
1520
|
-
|
|
1521
|
-
// Replace full-width slash / or Chinese dunhao 、 with ASCII / in-place
|
|
1522
|
-
if (/^[/、]/.test(el.value)) {
|
|
1523
|
-
const pos = el.selectionStart;
|
|
1524
|
-
el.value = el.value.replace(/^[/、]/, "/");
|
|
1525
|
-
el.setSelectionRange(pos, pos);
|
|
1526
|
-
}
|
|
1527
|
-
|
|
1528
|
-
// Trigger skill autocomplete
|
|
1529
|
-
SkillAC.update(el.value);
|
|
1530
|
-
});
|
|
1531
|
-
|
|
1532
|
-
$("btn-settings").addEventListener("click", () => {
|
|
1533
|
-
if (Router.current === "settings") {
|
|
1534
|
-
Router.navigate("welcome");
|
|
1535
|
-
} else {
|
|
1536
|
-
Router.navigate("settings");
|
|
1537
|
-
}
|
|
1538
|
-
});
|
|
1539
|
-
|
|
1540
|
-
$("tasks-sidebar-item").addEventListener("click", () => Router.navigate("tasks"));
|
|
1541
|
-
$("skills-sidebar-item").addEventListener("click", () => Router.navigate("skills"));
|
|
1542
|
-
$("channels-sidebar-item").addEventListener("click", () => Router.navigate("channels"));
|
|
1543
|
-
// creator-sidebar-item is only present when user_licensed — guard with ?
|
|
1544
|
-
document.getElementById("creator-sidebar-item")?.addEventListener("click", () => Router.navigate("creator"));
|
|
1545
|
-
|
|
1546
|
-
$("btn-create-skill").addEventListener("click", () => Skills.createInSession());
|
|
1547
|
-
$("btn-import-skill").addEventListener("click", () => Skills.toggleImportBar());
|
|
1548
424
|
|
|
1549
425
|
// ── Boot ──────────────────────────────────────────────────────────────────
|
|
426
|
+
Sidebar.init();
|
|
1550
427
|
Settings.init();
|
|
1551
428
|
Channels.init();
|
|
1552
429
|
Sessions.init();
|
|
@@ -1638,450 +515,5 @@ window.bootAfterBrand = async function() {
|
|
|
1638
515
|
});
|
|
1639
516
|
})();
|
|
1640
517
|
|
|
1641
|
-
//
|
|
1642
|
-
(function() {
|
|
1643
|
-
let _isOpen = false;
|
|
1644
|
-
// Cache of the most recent benchmark results, keyed by model_id. Kept at
|
|
1645
|
-
// closure scope so the numbers survive closing & reopening the dropdown —
|
|
1646
|
-
// the user shouldn't have to re-run the test just to peek at results. We
|
|
1647
|
-
// intentionally do NOT persist this to disk: latency is a point-in-time
|
|
1648
|
-
// measurement, and yesterday's numbers are misleading.
|
|
1649
|
-
let _benchCache = {}; // { [model_id]: { ttft_ms, ok, error, ts } }
|
|
1650
|
-
let _benchInFlight = false; // prevent double-click spam
|
|
1651
|
-
|
|
1652
|
-
// Toggle model dropdown when clicking on model name
|
|
1653
|
-
document.addEventListener("click", async (e) => {
|
|
1654
|
-
const modelEl = e.target.closest("#sib-model");
|
|
1655
|
-
if (modelEl) {
|
|
1656
|
-
e.stopPropagation();
|
|
1657
|
-
const dropdown = $("sib-model-dropdown");
|
|
1658
|
-
if (!dropdown) return;
|
|
1659
|
-
|
|
1660
|
-
if (_isOpen) {
|
|
1661
|
-
dropdown.style.display = "none";
|
|
1662
|
-
_isOpen = false;
|
|
1663
|
-
} else {
|
|
1664
|
-
await _populateModelDropdown(modelEl.dataset.sessionId, modelEl.textContent.trim());
|
|
1665
|
-
|
|
1666
|
-
// Calculate position relative to the model element (fixed positioning)
|
|
1667
|
-
const rect = modelEl.getBoundingClientRect();
|
|
1668
|
-
dropdown.style.left = `${rect.left + rect.width / 2}px`;
|
|
1669
|
-
dropdown.style.top = `${rect.top - 6}px`; // 6px above the element
|
|
1670
|
-
dropdown.style.transform = "translate(-50%, -100%)"; // Center horizontally, move up by its own height
|
|
1671
|
-
|
|
1672
|
-
dropdown.style.display = "block";
|
|
1673
|
-
_isOpen = true;
|
|
1674
|
-
}
|
|
1675
|
-
return;
|
|
1676
|
-
}
|
|
1677
|
-
|
|
1678
|
-
// Close dropdown when clicking outside
|
|
1679
|
-
if (_isOpen && !e.target.closest(".sib-model-dropdown")) {
|
|
1680
|
-
const dropdown = $("sib-model-dropdown");
|
|
1681
|
-
if (dropdown) dropdown.style.display = "none";
|
|
1682
|
-
_isOpen = false;
|
|
1683
|
-
}
|
|
1684
|
-
});
|
|
1685
|
-
|
|
1686
|
-
// Populate dropdown with available models
|
|
1687
|
-
async function _populateModelDropdown(sessionId, currentModel) {
|
|
1688
|
-
const dropdown = $("sib-model-dropdown");
|
|
1689
|
-
if (!dropdown) return;
|
|
1690
|
-
|
|
1691
|
-
try {
|
|
1692
|
-
console.log("[Model Switcher] Fetching /api/config...");
|
|
1693
|
-
const res = await fetch("/api/config");
|
|
1694
|
-
const data = await res.json();
|
|
1695
|
-
console.log("[Model Switcher] Received data:", data);
|
|
1696
|
-
const models = data.models || [];
|
|
1697
|
-
console.log("[Model Switcher] Models count:", models.length);
|
|
1698
|
-
|
|
1699
|
-
if (models.length === 0) {
|
|
1700
|
-
dropdown.innerHTML = '<div style="padding:12px;text-align:center;color:var(--color-text-secondary);font-size:11px;">No models configured</div>';
|
|
1701
|
-
return;
|
|
1702
|
-
}
|
|
1703
|
-
|
|
1704
|
-
dropdown.innerHTML = "";
|
|
1705
|
-
|
|
1706
|
-
// ── Benchmark floating button (top-right of dropdown) ──────────────
|
|
1707
|
-
// Tiny ⚡ button pinned to the dropdown's top-right corner. Runs one
|
|
1708
|
-
// concurrent request per model and back-fills each row's latency cell.
|
|
1709
|
-
// We deliberately avoid a full-width banner — it ate visual space that
|
|
1710
|
-
// the model list needs, and most users open the dropdown to SWITCH,
|
|
1711
|
-
// not to benchmark. The floating button is discoverable but unobtrusive.
|
|
1712
|
-
const bench = document.createElement("div");
|
|
1713
|
-
bench.className = "sib-model-bench";
|
|
1714
|
-
const btnLabel = (typeof I18n !== "undefined") ? I18n.t("sib.bench.btn") : "Benchmark";
|
|
1715
|
-
const btnTooltip = (typeof I18n !== "undefined") ? I18n.t("sib.bench.tooltip") : "Test response latency for every configured model";
|
|
1716
|
-
bench.innerHTML = `
|
|
1717
|
-
<button type="button" class="sib-bench-btn" title="${btnTooltip}">⚡ <span class="sib-bench-label">${btnLabel}</span></button>
|
|
1718
|
-
<span class="sib-bench-hint"></span>
|
|
1719
|
-
`;
|
|
1720
|
-
dropdown.appendChild(bench);
|
|
1721
|
-
|
|
1722
|
-
const benchBtn = bench.querySelector(".sib-bench-btn");
|
|
1723
|
-
const benchLabel = bench.querySelector(".sib-bench-label");
|
|
1724
|
-
const benchHint = bench.querySelector(".sib-bench-hint");
|
|
1725
|
-
benchBtn.addEventListener("click", (ev) => {
|
|
1726
|
-
ev.stopPropagation();
|
|
1727
|
-
_runBenchmark(sessionId, dropdown, benchBtn, benchLabel, benchHint);
|
|
1728
|
-
});
|
|
1729
|
-
|
|
1730
|
-
// ── Model rows ─────────────────────────────────────────────────────
|
|
1731
|
-
models.forEach(m => {
|
|
1732
|
-
console.log("[Model Switcher] Adding model:", m.model, "id:", m.id, "current:", currentModel);
|
|
1733
|
-
const opt = document.createElement("div");
|
|
1734
|
-
opt.className = "sib-model-option";
|
|
1735
|
-
opt.dataset.modelId = m.id;
|
|
1736
|
-
if (m.model === currentModel) opt.classList.add("current");
|
|
1737
|
-
|
|
1738
|
-
const left = document.createElement("span");
|
|
1739
|
-
left.className = "sib-model-name";
|
|
1740
|
-
left.textContent = m.model;
|
|
1741
|
-
opt.appendChild(left);
|
|
1742
|
-
|
|
1743
|
-
const right = document.createElement("span");
|
|
1744
|
-
right.className = "sib-model-right";
|
|
1745
|
-
|
|
1746
|
-
if (m.type === "default") {
|
|
1747
|
-
const badge = document.createElement("span");
|
|
1748
|
-
badge.className = `model-badge ${m.type}`;
|
|
1749
|
-
badge.textContent = m.type;
|
|
1750
|
-
right.appendChild(badge);
|
|
1751
|
-
}
|
|
1752
|
-
|
|
1753
|
-
// Latency cell — populated from _benchCache on open, updated live
|
|
1754
|
-
// when a benchmark run completes. Empty slot keeps row heights stable
|
|
1755
|
-
// so the list doesn't visually jump mid-benchmark.
|
|
1756
|
-
const lat = document.createElement("span");
|
|
1757
|
-
lat.className = "sib-model-latency";
|
|
1758
|
-
_fillLatencyCell(lat, _benchCache[m.id]);
|
|
1759
|
-
right.appendChild(lat);
|
|
1760
|
-
|
|
1761
|
-
opt.appendChild(right);
|
|
1762
|
-
|
|
1763
|
-
// Switch by id (stable across reorders/edits). Keep model name for UI update.
|
|
1764
|
-
opt.addEventListener("click", () => _switchModel(sessionId, m.id, m.model));
|
|
1765
|
-
dropdown.appendChild(opt);
|
|
1766
|
-
});
|
|
1767
|
-
console.log("[Model Switcher] Dropdown populated, children count:", dropdown.children.length);
|
|
1768
|
-
} catch (e) {
|
|
1769
|
-
console.error("Failed to load models:", e);
|
|
1770
|
-
dropdown.innerHTML = '<div style="padding:12px;text-align:center;color:var(--color-error);font-size:11px;">Error loading models</div>';
|
|
1771
|
-
}
|
|
1772
|
-
}
|
|
1773
|
-
|
|
1774
|
-
// Render one latency cell based on a cached result.
|
|
1775
|
-
// undefined → empty slot (never tested / in-flight starts from here)
|
|
1776
|
-
// { ok:true } → "812ms" in green/amber/red per threshold
|
|
1777
|
-
// { ok:false } → "✕" with error in tooltip
|
|
1778
|
-
// { pending:true } → "…" spinner-ish marker
|
|
1779
|
-
function _fillLatencyCell(el, entry) {
|
|
1780
|
-
el.className = "sib-model-latency";
|
|
1781
|
-
el.textContent = "";
|
|
1782
|
-
el.removeAttribute("title");
|
|
1783
|
-
if (!entry) return;
|
|
1784
|
-
if (entry.pending) {
|
|
1785
|
-
el.textContent = "…";
|
|
1786
|
-
el.classList.add("is-pending");
|
|
1787
|
-
return;
|
|
1788
|
-
}
|
|
1789
|
-
if (!entry.ok) {
|
|
1790
|
-
el.textContent = "✕";
|
|
1791
|
-
el.classList.add("is-err");
|
|
1792
|
-
el.title = entry.error || "failed";
|
|
1793
|
-
return;
|
|
1794
|
-
}
|
|
1795
|
-
const ms = entry.ttft_ms;
|
|
1796
|
-
// Same thresholds as the sib-signal status bar — keep them aligned so
|
|
1797
|
-
// "3 bars in the status bar" ≈ "green number in the picker".
|
|
1798
|
-
// We measure full non-streaming response time (not real TTFT), so ≤60s is
|
|
1799
|
-
// normal, ≤120s is slow, beyond is bad. ≤2s still gets the "feels instant"
|
|
1800
|
-
// green treatment like the 4-bar signal.
|
|
1801
|
-
let cls = "is-bad";
|
|
1802
|
-
if (ms <= 2000) cls = "is-ok";
|
|
1803
|
-
else if (ms <= 60000) cls = "is-ok";
|
|
1804
|
-
else if (ms <= 120000) cls = "is-warn";
|
|
1805
|
-
el.classList.add(cls);
|
|
1806
|
-
el.textContent = ms >= 1000 ? (ms / 1000).toFixed(1) + "s" : ms + "ms";
|
|
1807
|
-
if (typeof I18n !== "undefined") {
|
|
1808
|
-
el.title = I18n.t("sib.bench.latencyTooltip", {
|
|
1809
|
-
ttft: el.textContent,
|
|
1810
|
-
time: new Date(entry.ts).toLocaleTimeString(),
|
|
1811
|
-
});
|
|
1812
|
-
} else {
|
|
1813
|
-
el.title = `TTFT ${el.textContent} · tested ${new Date(entry.ts).toLocaleTimeString()}`;
|
|
1814
|
-
}
|
|
1815
|
-
}
|
|
1816
|
-
|
|
1817
|
-
async function _runBenchmark(sessionId, dropdown, btn, label, hint) {
|
|
1818
|
-
if (_benchInFlight) return;
|
|
1819
|
-
_benchInFlight = true;
|
|
1820
|
-
btn.disabled = true;
|
|
1821
|
-
const origLabel = label.textContent;
|
|
1822
|
-
const _t = (key, vars) => (typeof I18n !== "undefined") ? I18n.t(key, vars) : key;
|
|
1823
|
-
label.textContent = _t("sib.bench.running");
|
|
1824
|
-
hint.textContent = "";
|
|
1825
|
-
|
|
1826
|
-
// Mark every row as pending so the user sees instant feedback instead of
|
|
1827
|
-
// a silent button. _fillLatencyCell handles the visual treatment.
|
|
1828
|
-
dropdown.querySelectorAll(".sib-model-option").forEach(opt => {
|
|
1829
|
-
const id = opt.dataset.modelId;
|
|
1830
|
-
if (!id) return;
|
|
1831
|
-
_benchCache[id] = { pending: true };
|
|
1832
|
-
_fillLatencyCell(opt.querySelector(".sib-model-latency"), _benchCache[id]);
|
|
1833
|
-
});
|
|
1834
|
-
|
|
1835
|
-
const t0 = performance.now();
|
|
1836
|
-
try {
|
|
1837
|
-
const res = await fetch(`/api/sessions/${sessionId}/benchmark`, { method: "POST" });
|
|
1838
|
-
const data = await res.json();
|
|
1839
|
-
if (!res.ok || !data.ok) throw new Error(data.error || "benchmark failed");
|
|
1840
|
-
|
|
1841
|
-
const now = Date.now();
|
|
1842
|
-
(data.results || []).forEach(r => {
|
|
1843
|
-
_benchCache[r.model_id] = {
|
|
1844
|
-
ok: !!r.ok,
|
|
1845
|
-
ttft_ms: r.ttft_ms,
|
|
1846
|
-
error: r.error,
|
|
1847
|
-
ts: now,
|
|
1848
|
-
};
|
|
1849
|
-
const opt = dropdown.querySelector(`.sib-model-option[data-model-id="${CSS.escape(r.model_id)}"]`);
|
|
1850
|
-
if (opt) _fillLatencyCell(opt.querySelector(".sib-model-latency"), _benchCache[r.model_id]);
|
|
1851
|
-
});
|
|
1852
|
-
|
|
1853
|
-
const elapsed = ((performance.now() - t0) / 1000).toFixed(1);
|
|
1854
|
-
hint.textContent = _t("sib.bench.done", { t: elapsed });
|
|
1855
|
-
} catch (e) {
|
|
1856
|
-
console.error("Benchmark failed:", e);
|
|
1857
|
-
hint.textContent = _t("sib.bench.failed", { msg: e.message });
|
|
1858
|
-
// Clear pending markers so rows don't stay stuck on "…"
|
|
1859
|
-
dropdown.querySelectorAll(".sib-model-option").forEach(opt => {
|
|
1860
|
-
const id = opt.dataset.modelId;
|
|
1861
|
-
if (id && _benchCache[id]?.pending) {
|
|
1862
|
-
_benchCache[id] = undefined;
|
|
1863
|
-
_fillLatencyCell(opt.querySelector(".sib-model-latency"), undefined);
|
|
1864
|
-
}
|
|
1865
|
-
});
|
|
1866
|
-
} finally {
|
|
1867
|
-
_benchInFlight = false;
|
|
1868
|
-
btn.disabled = false;
|
|
1869
|
-
label.textContent = origLabel;
|
|
1870
|
-
}
|
|
1871
|
-
}
|
|
1872
|
-
|
|
1873
|
-
// Switch session model via API
|
|
1874
|
-
// modelId — stable runtime id (required by backend)
|
|
1875
|
-
// modelName — display name, used for optimistic UI update
|
|
1876
|
-
async function _switchModel(sessionId, modelId, modelName) {
|
|
1877
|
-
const dropdown = $("sib-model-dropdown");
|
|
1878
|
-
if (dropdown) {
|
|
1879
|
-
dropdown.style.display = "none";
|
|
1880
|
-
_isOpen = false;
|
|
1881
|
-
}
|
|
1882
|
-
|
|
1883
|
-
try {
|
|
1884
|
-
const res = await fetch(`/api/sessions/${sessionId}/model`, {
|
|
1885
|
-
method: "PATCH",
|
|
1886
|
-
headers: { "Content-Type": "application/json" },
|
|
1887
|
-
body: JSON.stringify({ model_id: modelId })
|
|
1888
|
-
});
|
|
1889
|
-
|
|
1890
|
-
const data = await res.json();
|
|
1891
|
-
|
|
1892
|
-
if (!res.ok) {
|
|
1893
|
-
throw new Error(data.error || "Unknown error");
|
|
1894
|
-
}
|
|
1895
|
-
|
|
1896
|
-
// Update UI optimistically (will be confirmed by session_update broadcast)
|
|
1897
|
-
const sibModel = $("sib-model");
|
|
1898
|
-
if (sibModel) sibModel.textContent = modelName;
|
|
1899
|
-
|
|
1900
|
-
console.log(`Switched session ${sessionId} to model ${modelName} (${modelId})`);
|
|
1901
|
-
} catch (e) {
|
|
1902
|
-
console.error("Failed to switch model:", e);
|
|
1903
|
-
alert("Failed to switch model: " + e.message);
|
|
1904
|
-
}
|
|
1905
|
-
}
|
|
1906
|
-
})();
|
|
1907
|
-
|
|
1908
|
-
// ── Session Info Bar Working Directory Switcher ───────────────────────────
|
|
1909
|
-
(function() {
|
|
1910
|
-
// Handle click on working directory
|
|
1911
|
-
document.addEventListener("click", async (e) => {
|
|
1912
|
-
const dirEl = e.target.closest("#sib-dir");
|
|
1913
|
-
if (dirEl) {
|
|
1914
|
-
e.stopPropagation();
|
|
1915
|
-
const sessionId = dirEl.dataset.sessionId;
|
|
1916
|
-
const currentDir = dirEl.title.replace(" (click to change)", "");
|
|
1917
|
-
|
|
1918
|
-
const newDir = await Modal.prompt("Change working directory:", currentDir);
|
|
1919
|
-
if (newDir && newDir !== currentDir) {
|
|
1920
|
-
_changeWorkingDirectory(sessionId, newDir);
|
|
1921
|
-
}
|
|
1922
|
-
}
|
|
1923
|
-
|
|
1924
|
-
// Handle click on session ID — toggles a small actions dropdown with
|
|
1925
|
-
// items like "Download session files (for debugging)". Designed to be
|
|
1926
|
-
// extensible (more session-level actions can be added here later).
|
|
1927
|
-
const sibIdEl = e.target.closest("#sib-id");
|
|
1928
|
-
if (sibIdEl) {
|
|
1929
|
-
e.stopPropagation();
|
|
1930
|
-
const sessionId = sibIdEl.dataset.sessionId;
|
|
1931
|
-
if (!sessionId) return;
|
|
1932
|
-
_toggleSessionActionsDropdown(sibIdEl, sessionId);
|
|
1933
|
-
return;
|
|
1934
|
-
}
|
|
1935
|
-
|
|
1936
|
-
// Handle click on an item inside the actions dropdown.
|
|
1937
|
-
const actionItem = e.target.closest(".sib-actions-item");
|
|
1938
|
-
if (actionItem) {
|
|
1939
|
-
e.stopPropagation();
|
|
1940
|
-
const action = actionItem.dataset.action;
|
|
1941
|
-
const sessionId = actionItem.dataset.sessionId;
|
|
1942
|
-
_closeSessionActionsDropdown();
|
|
1943
|
-
if (action === "download" && sessionId) {
|
|
1944
|
-
_downloadSessionBundle(sessionId, actionItem);
|
|
1945
|
-
}
|
|
1946
|
-
return;
|
|
1947
|
-
}
|
|
1948
|
-
|
|
1949
|
-
// Click outside — close the actions dropdown if open.
|
|
1950
|
-
if (!e.target.closest("#sib-actions-dropdown")) {
|
|
1951
|
-
_closeSessionActionsDropdown();
|
|
1952
|
-
}
|
|
1953
|
-
});
|
|
1954
|
-
|
|
1955
|
-
// Close dropdown on Escape.
|
|
1956
|
-
document.addEventListener("keydown", (e) => {
|
|
1957
|
-
if (e.key === "Escape") _closeSessionActionsDropdown();
|
|
1958
|
-
});
|
|
1959
|
-
|
|
1960
|
-
function _closeSessionActionsDropdown() {
|
|
1961
|
-
const dd = $("sib-actions-dropdown");
|
|
1962
|
-
if (dd && dd.style.display !== "none") dd.style.display = "none";
|
|
1963
|
-
}
|
|
518
|
+
// Session Info Bar (model switcher + working-directory switcher) moved to sessions.js
|
|
1964
519
|
|
|
1965
|
-
function _toggleSessionActionsDropdown(anchorEl, sessionId) {
|
|
1966
|
-
const dd = $("sib-actions-dropdown");
|
|
1967
|
-
if (!dd) return;
|
|
1968
|
-
|
|
1969
|
-
// If already open for this session, close it (toggle behaviour).
|
|
1970
|
-
if (dd.style.display !== "none" && dd.dataset.sessionId === sessionId) {
|
|
1971
|
-
dd.style.display = "none";
|
|
1972
|
-
return;
|
|
1973
|
-
}
|
|
1974
|
-
|
|
1975
|
-
_populateSessionActionsDropdown(dd, sessionId);
|
|
1976
|
-
dd.dataset.sessionId = sessionId;
|
|
1977
|
-
|
|
1978
|
-
// Position the dropdown above the session ID element (same pattern as
|
|
1979
|
-
// the model switcher — fixed positioning, centered horizontally).
|
|
1980
|
-
const rect = anchorEl.getBoundingClientRect();
|
|
1981
|
-
dd.style.left = `${rect.left + rect.width / 2}px`;
|
|
1982
|
-
dd.style.top = `${rect.top - 6}px`;
|
|
1983
|
-
dd.style.transform = "translate(-50%, -100%)";
|
|
1984
|
-
dd.style.display = "block";
|
|
1985
|
-
}
|
|
1986
|
-
|
|
1987
|
-
function _populateSessionActionsDropdown(dd, sessionId) {
|
|
1988
|
-
const t = (key, fallback) => (window.I18n && I18n.t(key)) || fallback;
|
|
1989
|
-
dd.innerHTML = "";
|
|
1990
|
-
|
|
1991
|
-
// Download item
|
|
1992
|
-
const item = document.createElement("div");
|
|
1993
|
-
item.className = "sib-actions-item";
|
|
1994
|
-
item.setAttribute("role", "menuitem");
|
|
1995
|
-
item.dataset.action = "download";
|
|
1996
|
-
item.dataset.sessionId = sessionId;
|
|
1997
|
-
|
|
1998
|
-
const icon = document.createElement("span");
|
|
1999
|
-
icon.className = "sib-actions-icon";
|
|
2000
|
-
icon.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`;
|
|
2001
|
-
|
|
2002
|
-
const label = document.createElement("span");
|
|
2003
|
-
label.className = "sib-actions-label";
|
|
2004
|
-
label.textContent = t("sessions.actions.download", "Download session files");
|
|
2005
|
-
|
|
2006
|
-
const hint = document.createElement("span");
|
|
2007
|
-
hint.className = "sib-actions-hint";
|
|
2008
|
-
hint.textContent = t("sessions.actions.downloadHint", "for debugging");
|
|
2009
|
-
|
|
2010
|
-
item.appendChild(icon);
|
|
2011
|
-
item.appendChild(label);
|
|
2012
|
-
item.appendChild(hint);
|
|
2013
|
-
dd.appendChild(item);
|
|
2014
|
-
}
|
|
2015
|
-
|
|
2016
|
-
async function _downloadSessionBundle(sessionId, btnEl) {
|
|
2017
|
-
// btnEl may be a <button> (legacy) or a menu item <div> — guard accordingly.
|
|
2018
|
-
const wasDisabled = btnEl && btnEl.disabled;
|
|
2019
|
-
if (btnEl) {
|
|
2020
|
-
try { btnEl.disabled = true; } catch (_) {}
|
|
2021
|
-
btnEl.classList && btnEl.classList.add("is-loading");
|
|
2022
|
-
}
|
|
2023
|
-
try {
|
|
2024
|
-
const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/export`);
|
|
2025
|
-
if (!res.ok) {
|
|
2026
|
-
let msg = `HTTP ${res.status}`;
|
|
2027
|
-
try { const data = await res.json(); if (data.error) msg = data.error; } catch (_) {}
|
|
2028
|
-
alert((window.I18n && I18n.t("sessions.export.failed")) || "Failed to download session: " + msg);
|
|
2029
|
-
return;
|
|
2030
|
-
}
|
|
2031
|
-
const blob = await res.blob();
|
|
2032
|
-
|
|
2033
|
-
// Derive filename from Content-Disposition header, fall back to short id.
|
|
2034
|
-
let filename = `clacky-session-${sessionId.slice(0, 8)}.zip`;
|
|
2035
|
-
const cd = res.headers.get("Content-Disposition") || "";
|
|
2036
|
-
const m = cd.match(/filename="?([^"]+)"?/i);
|
|
2037
|
-
if (m) filename = m[1];
|
|
2038
|
-
|
|
2039
|
-
const url = URL.createObjectURL(blob);
|
|
2040
|
-
const a = document.createElement("a");
|
|
2041
|
-
a.href = url;
|
|
2042
|
-
a.download = filename;
|
|
2043
|
-
document.body.appendChild(a);
|
|
2044
|
-
a.click();
|
|
2045
|
-
a.remove();
|
|
2046
|
-
// Revoke on next tick so the browser has a chance to start the download.
|
|
2047
|
-
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
2048
|
-
} catch (err) {
|
|
2049
|
-
console.error("Session export failed:", err);
|
|
2050
|
-
alert(((window.I18n && I18n.t("sessions.export.failed")) || "Failed to download session") + ": " + err.message);
|
|
2051
|
-
} finally {
|
|
2052
|
-
if (btnEl) {
|
|
2053
|
-
try { btnEl.disabled = wasDisabled; } catch (_) {}
|
|
2054
|
-
btnEl.classList && btnEl.classList.remove("is-loading");
|
|
2055
|
-
}
|
|
2056
|
-
}
|
|
2057
|
-
}
|
|
2058
|
-
|
|
2059
|
-
// Change working directory via backend API
|
|
2060
|
-
async function _changeWorkingDirectory(sessionId, newDir) {
|
|
2061
|
-
try {
|
|
2062
|
-
const res = await fetch(`/api/sessions/${sessionId}/working_dir`, {
|
|
2063
|
-
method: "PATCH",
|
|
2064
|
-
headers: { "Content-Type": "application/json" },
|
|
2065
|
-
body: JSON.stringify({ working_dir: newDir })
|
|
2066
|
-
});
|
|
2067
|
-
|
|
2068
|
-
const data = await res.json();
|
|
2069
|
-
|
|
2070
|
-
if (!res.ok) {
|
|
2071
|
-
throw new Error(data.error || "Unknown error");
|
|
2072
|
-
}
|
|
2073
|
-
|
|
2074
|
-
// Update UI optimistically (will be confirmed by session_update broadcast)
|
|
2075
|
-
const sibDir = $("sib-dir");
|
|
2076
|
-
if (sibDir) {
|
|
2077
|
-
sibDir.textContent = newDir;
|
|
2078
|
-
sibDir.title = newDir + " (click to change)";
|
|
2079
|
-
}
|
|
2080
|
-
|
|
2081
|
-
console.log(`Changed session ${sessionId} directory to ${newDir}`);
|
|
2082
|
-
} catch (e) {
|
|
2083
|
-
console.error("Failed to change directory:", e);
|
|
2084
|
-
alert("Failed to change directory: " + e.message);
|
|
2085
|
-
}
|
|
2086
|
-
}
|
|
2087
|
-
})();
|