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/sessions.js
CHANGED
|
@@ -304,6 +304,571 @@ const Sessions = (() => {
|
|
|
304
304
|
});
|
|
305
305
|
}
|
|
306
306
|
|
|
307
|
+
// ── New session controls (split button + welcome + modal) ──────────────
|
|
308
|
+
//
|
|
309
|
+
// Wires up every button/interaction that kicks off session creation:
|
|
310
|
+
// - "+ New Session" inline split-button (quick create)
|
|
311
|
+
// - "▾" arrow button (opens dropdown → advanced options modal)
|
|
312
|
+
// - "+ New Session" big button on the welcome screen
|
|
313
|
+
// - New Session Modal: close / cancel / create / overlay click / browse
|
|
314
|
+
// - Load-more button (rendered dynamically by renderList)
|
|
315
|
+
//
|
|
316
|
+
// All elements below are static in index.html and therefore must exist —
|
|
317
|
+
// we call addEventListener directly (no ?. / no `if` guards). If any is
|
|
318
|
+
// missing, it means HTML and JS drifted and we want the loud error.
|
|
319
|
+
function _initNewSessionControls() {
|
|
320
|
+
// Split button: main (quick create)
|
|
321
|
+
document.getElementById("btn-new-session-inline")
|
|
322
|
+
.addEventListener("click", () => Sessions.create("general"));
|
|
323
|
+
|
|
324
|
+
// Split button: arrow (toggle dropdown)
|
|
325
|
+
document.getElementById("btn-new-session-arrow")
|
|
326
|
+
.addEventListener("click", (e) => {
|
|
327
|
+
e.stopPropagation();
|
|
328
|
+
const dd = document.getElementById("new-session-dropdown");
|
|
329
|
+
dd.hidden = !dd.hidden;
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
// Dropdown item "Advanced Options…" — delegated because the dropdown
|
|
333
|
+
// panel may be re-rendered; this keeps the binding stable.
|
|
334
|
+
document.addEventListener("click", (e) => {
|
|
335
|
+
if (e.target && e.target.id === "btn-new-session-modal") {
|
|
336
|
+
e.stopPropagation();
|
|
337
|
+
document.getElementById("new-session-dropdown").hidden = true;
|
|
338
|
+
Sessions.openNewSessionModal();
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
// Close dropdown when clicking anywhere else
|
|
343
|
+
document.addEventListener("click", () => {
|
|
344
|
+
const dd = document.getElementById("new-session-dropdown");
|
|
345
|
+
if (dd && !dd.hidden) dd.hidden = true;
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
// Welcome screen "+ New Session" button
|
|
349
|
+
document.getElementById("btn-welcome-new")
|
|
350
|
+
.addEventListener("click", () => Sessions.create("general"));
|
|
351
|
+
|
|
352
|
+
// Modal: close / cancel / create / overlay click
|
|
353
|
+
document.getElementById("new-session-modal-close")
|
|
354
|
+
.addEventListener("click", () => Sessions.closeNewSessionModal());
|
|
355
|
+
document.getElementById("new-session-cancel")
|
|
356
|
+
.addEventListener("click", () => Sessions.closeNewSessionModal());
|
|
357
|
+
document.getElementById("new-session-create")
|
|
358
|
+
.addEventListener("click", () => Sessions.createFromModal());
|
|
359
|
+
document.getElementById("new-session-modal")
|
|
360
|
+
.addEventListener("click", (e) => {
|
|
361
|
+
// Only close when the click lands on the overlay itself, not on
|
|
362
|
+
// the inner dialog panel.
|
|
363
|
+
if (e.target.id === "new-session-modal") {
|
|
364
|
+
Sessions.closeNewSessionModal();
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// (Removed dead binding for `new-session-browse-btn` — no such element
|
|
369
|
+
// exists in index.html. Originally guarded by `if ($(...))`; deleting the
|
|
370
|
+
// defense exposed that it never ran. Native file-browser picker is not
|
|
371
|
+
// implemented on the web UI — users type a path directly.)
|
|
372
|
+
|
|
373
|
+
// Load-more sessions button is rendered dynamically by renderList(),
|
|
374
|
+
// so we listen via event delegation.
|
|
375
|
+
document.addEventListener("click", (e) => {
|
|
376
|
+
if (e.target && e.target.id === "btn-load-more-sessions") {
|
|
377
|
+
Sessions.loadMore();
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ── Composer: attachments, send button, and sendMessage ────────────────
|
|
383
|
+
//
|
|
384
|
+
// Everything below is the "composer" — the input box at the bottom of
|
|
385
|
+
// the chat panel and the user-attached image/file pipeline. It owns:
|
|
386
|
+
// - In-memory staging buffers for pending images and files (_pendingImages / _pendingFiles)
|
|
387
|
+
// - Client-side image compression (scale down + progressive JPEG quality)
|
|
388
|
+
// - File upload via POST /api/upload (documents only, not images)
|
|
389
|
+
// - Preview strip rendering (image thumbnails + file cards)
|
|
390
|
+
// - Drag-drop, paste, and "+ attach" button → file pipeline
|
|
391
|
+
// - sendMessage() — assembles content + files and dispatches over WS
|
|
392
|
+
//
|
|
393
|
+
// Scope: everything here is strictly session-scoped. The pending buffers
|
|
394
|
+
// are cleared on each send. There is no "draft" persistence across sessions.
|
|
395
|
+
//
|
|
396
|
+
// Bindings set up by _initComposer() — wired in Sessions.init() below.
|
|
397
|
+
|
|
398
|
+
const _pendingImages = [];
|
|
399
|
+
const _pendingFiles = [];
|
|
400
|
+
const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5 MB — hard reject before compression
|
|
401
|
+
const MAX_IMAGE_BYTES_SEND = 512 * 1024; // 512 KB — target after compression
|
|
402
|
+
const MAX_IMAGE_LONG_EDGE = 1920; // px — scale down if larger
|
|
403
|
+
const MAX_FILE_BYTES = 32 * 1024 * 1024; // 32 MB
|
|
404
|
+
const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
|
405
|
+
const ACCEPTED_DOC_TYPES = [
|
|
406
|
+
"application/pdf",
|
|
407
|
+
"application/zip",
|
|
408
|
+
"application/x-zip-compressed",
|
|
409
|
+
"application/gzip",
|
|
410
|
+
"application/x-gzip",
|
|
411
|
+
"application/x-tar",
|
|
412
|
+
"application/x-compressed-tar",
|
|
413
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", // .docx
|
|
414
|
+
"application/msword", // .doc
|
|
415
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", // .xlsx
|
|
416
|
+
"application/vnd.ms-excel", // .xls
|
|
417
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation", // .pptx
|
|
418
|
+
"application/vnd.ms-powerpoint", // .ppt
|
|
419
|
+
"text/csv", // .csv
|
|
420
|
+
"application/csv", // .csv (some browsers)
|
|
421
|
+
"text/markdown", // .md
|
|
422
|
+
"text/x-markdown", // .md (some browsers)
|
|
423
|
+
"text/plain", // .md / .txt (many browsers report this)
|
|
424
|
+
];
|
|
425
|
+
|
|
426
|
+
// Extension-based fallback for files whose MIME type is missing or unreliable.
|
|
427
|
+
// Browsers frequently report "" or "application/octet-stream" for .md / .tar.gz.
|
|
428
|
+
const ACCEPTED_DOC_EXTENSIONS = [
|
|
429
|
+
".pdf", ".zip",
|
|
430
|
+
".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
|
|
431
|
+
".csv",
|
|
432
|
+
".md", ".markdown", ".txt", ".log",
|
|
433
|
+
".tar", ".gz", ".tgz", ".tar.gz", ".rar", ".7z"
|
|
434
|
+
];
|
|
435
|
+
|
|
436
|
+
function _hasAcceptedDocExt(filename) {
|
|
437
|
+
const lower = (filename || "").toLowerCase();
|
|
438
|
+
return ACCEPTED_DOC_EXTENSIONS.some(ext => lower.endsWith(ext));
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function _isAcceptedDoc(file) {
|
|
442
|
+
if (!file) return false;
|
|
443
|
+
if (file.type && ACCEPTED_DOC_TYPES.includes(file.type)) return true;
|
|
444
|
+
return _hasAcceptedDocExt(file.name);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function _isAcceptedImage(file) {
|
|
448
|
+
if (!file) return false;
|
|
449
|
+
return ACCEPTED_IMAGE_TYPES.includes(file.type);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function _isAcceptedFile(file) {
|
|
453
|
+
return _isAcceptedImage(file) || _isAcceptedDoc(file);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function _docTypeIcon(mimeType, filename) {
|
|
457
|
+
const lower = (filename || "").toLowerCase();
|
|
458
|
+
if (mimeType === "application/pdf" || lower.endsWith(".pdf")) return "📄";
|
|
459
|
+
if (mimeType === "application/zip" || mimeType === "application/x-zip-compressed" || lower.endsWith(".zip")) return "🗜️";
|
|
460
|
+
if (mimeType === "application/gzip" || mimeType === "application/x-gzip" ||
|
|
461
|
+
mimeType === "application/x-tar" || mimeType === "application/x-compressed-tar" ||
|
|
462
|
+
lower.endsWith(".tar") || lower.endsWith(".gz") || lower.endsWith(".tgz") || lower.endsWith(".tar.gz") ||
|
|
463
|
+
lower.endsWith(".rar") || lower.endsWith(".7z")) return "🗜️";
|
|
464
|
+
if ((mimeType && mimeType.includes("wordprocessingml")) || mimeType === "application/msword" ||
|
|
465
|
+
lower.endsWith(".doc") || lower.endsWith(".docx")) return "📝";
|
|
466
|
+
if ((mimeType && mimeType.includes("spreadsheetml")) || mimeType === "application/vnd.ms-excel" ||
|
|
467
|
+
lower.endsWith(".xls") || lower.endsWith(".xlsx")) return "📊";
|
|
468
|
+
if ((mimeType && mimeType.includes("presentationml")) || mimeType === "application/vnd.ms-powerpoint" ||
|
|
469
|
+
lower.endsWith(".ppt") || lower.endsWith(".pptx")) return "📋";
|
|
470
|
+
if (mimeType === "text/csv" || mimeType === "application/csv" || lower.endsWith(".csv")) return "📊";
|
|
471
|
+
if (mimeType === "text/markdown" || mimeType === "text/x-markdown" ||
|
|
472
|
+
lower.endsWith(".md") || lower.endsWith(".markdown")) return "📝";
|
|
473
|
+
if (mimeType === "text/plain" || lower.endsWith(".txt") || lower.endsWith(".log")) return "📄";
|
|
474
|
+
return "📎";
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Compress an image File/Blob to a data URL within MAX_IMAGE_BYTES_SEND.
|
|
478
|
+
// Strategy: scale down to MAX_IMAGE_LONG_EDGE, then reduce JPEG quality until small enough.
|
|
479
|
+
// GIF is not compressible via Canvas — rendered as JPEG (LLMs only see first frame anyway).
|
|
480
|
+
function _compressImage(file) {
|
|
481
|
+
return new Promise((resolve, reject) => {
|
|
482
|
+
const reader = new FileReader();
|
|
483
|
+
reader.onerror = () => reject(new Error("Failed to read image"));
|
|
484
|
+
reader.onload = e => {
|
|
485
|
+
const img = new Image();
|
|
486
|
+
img.onerror = () => reject(new Error("Failed to decode image"));
|
|
487
|
+
img.onload = () => {
|
|
488
|
+
// Scale down if needed
|
|
489
|
+
let { width, height } = img;
|
|
490
|
+
if (width > MAX_IMAGE_LONG_EDGE || height > MAX_IMAGE_LONG_EDGE) {
|
|
491
|
+
const ratio = Math.min(MAX_IMAGE_LONG_EDGE / width, MAX_IMAGE_LONG_EDGE / height);
|
|
492
|
+
width = Math.round(width * ratio);
|
|
493
|
+
height = Math.round(height * ratio);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const canvas = document.createElement("canvas");
|
|
497
|
+
canvas.width = width;
|
|
498
|
+
canvas.height = height;
|
|
499
|
+
const ctx = canvas.getContext("2d");
|
|
500
|
+
ctx.drawImage(img, 0, 0, width, height);
|
|
501
|
+
|
|
502
|
+
// Try decreasing quality until under limit
|
|
503
|
+
let quality = 0.85;
|
|
504
|
+
let dataUrl = canvas.toDataURL("image/jpeg", quality);
|
|
505
|
+
while (dataUrl.length * 0.75 > MAX_IMAGE_BYTES_SEND && quality > 0.2) {
|
|
506
|
+
quality -= 0.1;
|
|
507
|
+
dataUrl = canvas.toDataURL("image/jpeg", quality);
|
|
508
|
+
}
|
|
509
|
+
resolve(dataUrl);
|
|
510
|
+
};
|
|
511
|
+
img.src = e.target.result;
|
|
512
|
+
};
|
|
513
|
+
reader.readAsDataURL(file);
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function _addImageFile(file) {
|
|
518
|
+
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
|
|
519
|
+
alert(`Unsupported image type: ${file.type}\nSupported: PNG, JPEG, GIF, WEBP`);
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
if (file.size > MAX_IMAGE_SIZE) {
|
|
523
|
+
alert(`Image too large: ${file.name} (max 5 MB)`);
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
_compressImage(file)
|
|
527
|
+
.then(dataUrl => {
|
|
528
|
+
_pendingImages.push({ dataUrl, name: file.name, mimeType: "image/jpeg" });
|
|
529
|
+
_renderAttachmentPreviews();
|
|
530
|
+
})
|
|
531
|
+
.catch(err => alert(`Image processing failed: ${err.message}`));
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function _addGenericFile(file) {
|
|
535
|
+
if (file.size > MAX_FILE_BYTES) {
|
|
536
|
+
alert(`File too large: ${file.name} (max 32 MB)`);
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
// Upload file to server via HTTP — only the path is returned, no base64 in memory
|
|
540
|
+
const formData = new FormData();
|
|
541
|
+
formData.append("file", file);
|
|
542
|
+
fetch("/api/upload", { method: "POST", body: formData })
|
|
543
|
+
.then(r => r.json())
|
|
544
|
+
.then(data => {
|
|
545
|
+
if (!data.ok) { alert(`Upload failed: ${data.error}`); return; }
|
|
546
|
+
_pendingFiles.push({
|
|
547
|
+
name: data.name,
|
|
548
|
+
path: data.path,
|
|
549
|
+
mime_type: file.type
|
|
550
|
+
});
|
|
551
|
+
_renderAttachmentPreviews();
|
|
552
|
+
setTimeout(() => $("user-input").focus(), 100);
|
|
553
|
+
})
|
|
554
|
+
.catch(err => alert(`Upload error: ${err.message}`));
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function _addAttachmentFile(file) {
|
|
558
|
+
// Route by content category. Images must match known image MIME types
|
|
559
|
+
// (MIME is reliable for images). Documents fall back to extension-based
|
|
560
|
+
// detection because browsers frequently report "" or "application/octet-stream"
|
|
561
|
+
// for .md / .tar.gz files.
|
|
562
|
+
if (_isAcceptedImage(file)) {
|
|
563
|
+
_addImageFile(file);
|
|
564
|
+
} else if (_isAcceptedDoc(file)) {
|
|
565
|
+
_addGenericFile(file);
|
|
566
|
+
} else {
|
|
567
|
+
alert(`Unsupported file: ${file.name}\nSupported: images (PNG/JPG/GIF/WEBP), PDF, Office (DOC/XLS/PPT), ZIP, TAR, TAR.GZ, MD, TXT, CSV`);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function _renderAttachmentPreviews() {
|
|
572
|
+
const strip = $("image-preview-strip");
|
|
573
|
+
strip.innerHTML = "";
|
|
574
|
+
const hasContent = _pendingImages.length > 0 || _pendingFiles.length > 0;
|
|
575
|
+
if (!hasContent) {
|
|
576
|
+
strip.style.display = "none";
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
strip.style.display = "flex";
|
|
580
|
+
|
|
581
|
+
// Render image thumbnails
|
|
582
|
+
_pendingImages.forEach((img, idx) => {
|
|
583
|
+
const item = document.createElement("div");
|
|
584
|
+
item.className = "img-preview-item";
|
|
585
|
+
item.title = img.name;
|
|
586
|
+
const thumbnail = document.createElement("img");
|
|
587
|
+
thumbnail.src = img.dataUrl;
|
|
588
|
+
thumbnail.alt = img.name;
|
|
589
|
+
const removeBtn = document.createElement("button");
|
|
590
|
+
removeBtn.className = "img-preview-remove";
|
|
591
|
+
removeBtn.textContent = "✕";
|
|
592
|
+
removeBtn.title = "Remove";
|
|
593
|
+
removeBtn.addEventListener("click", () => {
|
|
594
|
+
_pendingImages.splice(idx, 1);
|
|
595
|
+
_renderAttachmentPreviews();
|
|
596
|
+
});
|
|
597
|
+
item.appendChild(thumbnail);
|
|
598
|
+
item.appendChild(removeBtn);
|
|
599
|
+
strip.appendChild(item);
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
// Render file cards (PDF, ZIP, DOC, XLS, PPT, etc.)
|
|
603
|
+
_pendingFiles.forEach((f, idx) => {
|
|
604
|
+
const item = document.createElement("div");
|
|
605
|
+
item.className = "pdf-preview-item";
|
|
606
|
+
item.title = f.name;
|
|
607
|
+
|
|
608
|
+
const icon = document.createElement("div");
|
|
609
|
+
icon.className = "pdf-preview-icon";
|
|
610
|
+
icon.textContent = _docTypeIcon(f.mime_type, f.name);
|
|
611
|
+
|
|
612
|
+
const info = document.createElement("div");
|
|
613
|
+
info.className = "pdf-preview-info";
|
|
614
|
+
|
|
615
|
+
const name = document.createElement("div");
|
|
616
|
+
name.className = "pdf-preview-name";
|
|
617
|
+
name.textContent = f.name;
|
|
618
|
+
|
|
619
|
+
const typeLabel = document.createElement("div");
|
|
620
|
+
typeLabel.className = "pdf-preview-type";
|
|
621
|
+
const _lowerName = (f.name || "").toLowerCase();
|
|
622
|
+
typeLabel.textContent = _lowerName.endsWith(".tar.gz")
|
|
623
|
+
? "TAR.GZ"
|
|
624
|
+
: (f.name.split(".").pop() || "file").toUpperCase();
|
|
625
|
+
|
|
626
|
+
info.appendChild(name);
|
|
627
|
+
info.appendChild(typeLabel);
|
|
628
|
+
|
|
629
|
+
const removeBtn = document.createElement("button");
|
|
630
|
+
removeBtn.className = "pdf-preview-remove";
|
|
631
|
+
removeBtn.textContent = "✕";
|
|
632
|
+
removeBtn.title = "Remove";
|
|
633
|
+
removeBtn.addEventListener("click", () => {
|
|
634
|
+
_pendingFiles.splice(idx, 1);
|
|
635
|
+
_renderAttachmentPreviews();
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
item.appendChild(icon);
|
|
639
|
+
item.appendChild(info);
|
|
640
|
+
item.appendChild(removeBtn);
|
|
641
|
+
strip.appendChild(item);
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// ── sendMessage ────────────────────────────────────────────────────────
|
|
646
|
+
let _sending = false;
|
|
647
|
+
|
|
648
|
+
function _sendMessage() {
|
|
649
|
+
if (_sending) return;
|
|
650
|
+
const input = $("user-input");
|
|
651
|
+
const content = input.value.trim();
|
|
652
|
+
if (!content && _pendingImages.length === 0 && _pendingFiles.length === 0) return;
|
|
653
|
+
if (!Sessions.activeId) return;
|
|
654
|
+
|
|
655
|
+
if (!WS.ready) {
|
|
656
|
+
const hint = $("ws-disconnect-hint");
|
|
657
|
+
if (hint) {
|
|
658
|
+
hint.textContent = I18n.t("chat.disconnected.hint");
|
|
659
|
+
hint.style.display = "block";
|
|
660
|
+
hint.style.opacity = "1";
|
|
661
|
+
clearTimeout(hint._hideTimer);
|
|
662
|
+
hint._hideTimer = setTimeout(() => {
|
|
663
|
+
hint.style.opacity = "0";
|
|
664
|
+
setTimeout(() => { hint.style.display = "none"; }, 400);
|
|
665
|
+
}, 2000);
|
|
666
|
+
}
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
_sending = true;
|
|
671
|
+
|
|
672
|
+
let bubbleHtml = content ? escapeHtml(content) : "";
|
|
673
|
+
if (_pendingImages.length > 0) {
|
|
674
|
+
const thumbs = _pendingImages
|
|
675
|
+
.map(img => `<img src="${img.dataUrl}" alt="${escapeHtml(img.name)}" class="msg-image-thumb">`)
|
|
676
|
+
.join("");
|
|
677
|
+
bubbleHtml = thumbs + (bubbleHtml ? "<br>" + bubbleHtml : "");
|
|
678
|
+
}
|
|
679
|
+
if (_pendingFiles.length > 0) {
|
|
680
|
+
const badges = _pendingFiles.map(f => {
|
|
681
|
+
const icon = _docTypeIcon(f.mime_type);
|
|
682
|
+
const ext = (f.name.split(".").pop() || "file").toUpperCase();
|
|
683
|
+
return `<span class="msg-pdf-badge">` +
|
|
684
|
+
`<span class="msg-pdf-badge-icon">${icon}</span>` +
|
|
685
|
+
`<span class="msg-pdf-badge-info">` +
|
|
686
|
+
`<span class="msg-pdf-badge-name">${escapeHtml(f.name)}</span>` +
|
|
687
|
+
`<span class="msg-pdf-badge-type">${escapeHtml(ext)}</span>` +
|
|
688
|
+
`</span>` +
|
|
689
|
+
`</span>`;
|
|
690
|
+
}).join(" ");
|
|
691
|
+
bubbleHtml = badges + (bubbleHtml ? "<br>" + bubbleHtml : "");
|
|
692
|
+
}
|
|
693
|
+
Sessions.appendMsg("user", bubbleHtml, { time: new Date() });
|
|
694
|
+
|
|
695
|
+
// Merge images and files into unified files array for WS payload.
|
|
696
|
+
const files = [
|
|
697
|
+
..._pendingImages.map(img => ({
|
|
698
|
+
name: img.name,
|
|
699
|
+
mime_type: img.mimeType || "image/jpeg",
|
|
700
|
+
data_url: img.dataUrl
|
|
701
|
+
})),
|
|
702
|
+
..._pendingFiles.map(f => ({
|
|
703
|
+
name: f.name,
|
|
704
|
+
path: f.path,
|
|
705
|
+
mime_type: f.mime_type
|
|
706
|
+
}))
|
|
707
|
+
];
|
|
708
|
+
_pendingImages.length = 0;
|
|
709
|
+
_pendingFiles.length = 0;
|
|
710
|
+
_renderAttachmentPreviews();
|
|
711
|
+
|
|
712
|
+
WS.send({ type: "message", session_id: Sessions.activeId, content, files });
|
|
713
|
+
input.value = "";
|
|
714
|
+
input.style.height = "auto";
|
|
715
|
+
setTimeout(() => { _sending = false; }, 300);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// ── Composer bindings ──────────────────────────────────────────────────
|
|
719
|
+
// Wires up the send button, attach button, file picker, drag-drop, paste,
|
|
720
|
+
// and IME composition tracking. All targets are static in index.html.
|
|
721
|
+
function _initComposer() {
|
|
722
|
+
// Send & attach buttons
|
|
723
|
+
document.getElementById("btn-send").addEventListener("click", _sendMessage);
|
|
724
|
+
document.getElementById("btn-attach")
|
|
725
|
+
.addEventListener("click", () => document.getElementById("image-file-input").click());
|
|
726
|
+
|
|
727
|
+
// Hidden <input type="file"> — triggered by btn-attach.
|
|
728
|
+
document.getElementById("image-file-input").addEventListener("change", (e) => {
|
|
729
|
+
Array.from(e.target.files).forEach(_addAttachmentFile);
|
|
730
|
+
e.target.value = "";
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
// Drag-drop onto the whole input area.
|
|
734
|
+
const inputArea = document.getElementById("input-area");
|
|
735
|
+
inputArea.addEventListener("dragover", (e) => {
|
|
736
|
+
e.preventDefault();
|
|
737
|
+
inputArea.classList.add("drag-over");
|
|
738
|
+
});
|
|
739
|
+
inputArea.addEventListener("dragleave", (e) => {
|
|
740
|
+
if (!inputArea.contains(e.relatedTarget)) inputArea.classList.remove("drag-over");
|
|
741
|
+
});
|
|
742
|
+
inputArea.addEventListener("drop", (e) => {
|
|
743
|
+
e.preventDefault();
|
|
744
|
+
inputArea.classList.remove("drag-over");
|
|
745
|
+
const files = Array.from(e.dataTransfer.files).filter(_isAcceptedFile);
|
|
746
|
+
if (files.length === 0) return;
|
|
747
|
+
files.forEach(_addAttachmentFile);
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
// Paste handler — images and accepted docs from the clipboard.
|
|
751
|
+
document.getElementById("user-input").addEventListener("paste", (e) => {
|
|
752
|
+
const items = Array.from(e.clipboardData?.items || []);
|
|
753
|
+
// Any file-kind item that's an image, or a document whose type/name
|
|
754
|
+
// passes our doc filter. Must check name via getAsFile() for .md/.tar.gz
|
|
755
|
+
// (browsers often leave item.type empty for these).
|
|
756
|
+
const attachItems = items.filter(it => {
|
|
757
|
+
if (it.kind !== "file") return false;
|
|
758
|
+
if (ACCEPTED_IMAGE_TYPES.includes(it.type)) return true;
|
|
759
|
+
if (ACCEPTED_DOC_TYPES.includes(it.type)) return true;
|
|
760
|
+
const f = it.getAsFile && it.getAsFile();
|
|
761
|
+
return f ? _hasAcceptedDocExt(f.name) : false;
|
|
762
|
+
});
|
|
763
|
+
if (attachItems.length === 0) return;
|
|
764
|
+
e.preventDefault();
|
|
765
|
+
attachItems.forEach(it => _addAttachmentFile(it.getAsFile()));
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// ── Search bar bindings ────────────────────────────────────────────────
|
|
770
|
+
//
|
|
771
|
+
// All search-related interactions. The search UI lives in the sessions
|
|
772
|
+
// sidebar: a magnifier toggle button, the search panel (q input, type
|
|
773
|
+
// <select>, date <input>), inline ✕ clear, and "clear all filters" button.
|
|
774
|
+
//
|
|
775
|
+
// Everything uses event delegation because some elements (e.g. the clear
|
|
776
|
+
// buttons) are re-rendered as filter state changes.
|
|
777
|
+
function _initSearch() {
|
|
778
|
+
// Magnifier toggle button
|
|
779
|
+
document.addEventListener("click", (e) => {
|
|
780
|
+
if (e.target && e.target.closest("#btn-session-search-toggle")) {
|
|
781
|
+
Sessions.toggleSearch();
|
|
782
|
+
}
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
// Close button inside panel
|
|
786
|
+
document.addEventListener("click", (e) => {
|
|
787
|
+
if (e.target && e.target.id === "btn-session-search-close") {
|
|
788
|
+
if (Sessions.searchOpen) Sessions.toggleSearch();
|
|
789
|
+
}
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
// Enter key → commit search (fires whichever input has focus)
|
|
793
|
+
document.addEventListener("keydown", (e) => {
|
|
794
|
+
if (e.key === "Enter" && e.target && e.target.id === "session-search-q") {
|
|
795
|
+
e.preventDefault();
|
|
796
|
+
Sessions.commitSearch();
|
|
797
|
+
}
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
// Inline ✕ button — clear the q input and re-fetch
|
|
801
|
+
document.addEventListener("click", (e) => {
|
|
802
|
+
if (e.target && e.target.id === "btn-search-q-clear") {
|
|
803
|
+
const qEl = document.getElementById("session-search-q");
|
|
804
|
+
if (qEl) qEl.value = "";
|
|
805
|
+
Sessions.clearFilter("q");
|
|
806
|
+
}
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
// "Clear all filters" button — resets type + date and re-fetches once
|
|
810
|
+
document.addEventListener("click", (e) => {
|
|
811
|
+
if (e.target && e.target.id === "btn-search-clear-all") {
|
|
812
|
+
const typeEl = document.getElementById("session-search-type");
|
|
813
|
+
const dateEl = document.getElementById("session-search-date");
|
|
814
|
+
if (typeEl) typeEl.value = "";
|
|
815
|
+
if (dateEl) dateEl.value = "";
|
|
816
|
+
Sessions.commitSearch();
|
|
817
|
+
}
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
// Show/hide inline ✕ as user types in search input
|
|
821
|
+
document.addEventListener("input", (e) => {
|
|
822
|
+
if (e.target && e.target.id === "session-search-q") {
|
|
823
|
+
const btn = document.getElementById("btn-search-q-clear");
|
|
824
|
+
if (btn) btn.hidden = !e.target.value;
|
|
825
|
+
}
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
// Type <select> and date <input> — commit immediately on change
|
|
829
|
+
// (explicit choices, not free-text typing, so no debouncing needed).
|
|
830
|
+
document.addEventListener("change", (e) => {
|
|
831
|
+
if (e.target && (e.target.id === "session-search-type" || e.target.id === "session-search-date")) {
|
|
832
|
+
Sessions.commitSearch();
|
|
833
|
+
}
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// ── Message history bindings ───────────────────────────────────────────
|
|
838
|
+
//
|
|
839
|
+
// Session-scoped interactions inside the chat panel (not tied to a
|
|
840
|
+
// specific session id at bind time — they look up Sessions.activeId
|
|
841
|
+
// dynamically):
|
|
842
|
+
// - Scroll-to-top on #messages → load older history
|
|
843
|
+
// - #btn-interrupt → WS interrupt
|
|
844
|
+
// - #btn-delete-session → delete current session (legacy — the
|
|
845
|
+
// chat-header was removed; the button is now absent in fresh HTML
|
|
846
|
+
// but kept here in case some brand / template still renders it).
|
|
847
|
+
function _initMessageHistory() {
|
|
848
|
+
// Infinite-scroll older history when the user reaches the top.
|
|
849
|
+
document.getElementById("messages").addEventListener("scroll", () => {
|
|
850
|
+
const messages = document.getElementById("messages");
|
|
851
|
+
if (messages.scrollTop < 80 && Sessions.activeId && Sessions.hasMoreHistory(Sessions.activeId)) {
|
|
852
|
+
Sessions.loadMoreHistory(Sessions.activeId);
|
|
853
|
+
}
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
// Interrupt button — tells the backend to stop the current task.
|
|
857
|
+
document.getElementById("btn-interrupt").addEventListener("click", () => {
|
|
858
|
+
WS.send({ type: "interrupt", session_id: Sessions.activeId });
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
// Legacy delete button (removed from the chat header long ago). Keep a
|
|
862
|
+
// guarded binding so that custom brand/templates rendering the old
|
|
863
|
+
// element still work. In the default HTML this is a no-op.
|
|
864
|
+
const btnDelete = document.getElementById("btn-delete-session");
|
|
865
|
+
if (btnDelete) {
|
|
866
|
+
btnDelete.addEventListener("click", () => {
|
|
867
|
+
if (Sessions.activeId) Sessions.deleteSession(Sessions.activeId);
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
307
872
|
// ── Tool group helpers ─────────────────────────────────────────────────
|
|
308
873
|
//
|
|
309
874
|
// A "tool group" is a collapsible <div class="tool-group"> that contains
|
|
@@ -976,10 +1541,18 @@ const Sessions = (() => {
|
|
|
976
1541
|
get searchOpen() { return _searchOpen; },
|
|
977
1542
|
find: id => _sessions.find(s => s.id === id),
|
|
978
1543
|
|
|
1544
|
+
// Composer entry point — called by Skill autocomplete keydown handler
|
|
1545
|
+
// (in app.js) when the user presses Enter without an active completion.
|
|
1546
|
+
// Will be internalised once the Skill autocomplete moves into skills.js.
|
|
1547
|
+
sendMessage: _sendMessage,
|
|
979
1548
|
// ── Init ──────────────────────────────────────────────────────────────
|
|
980
1549
|
init() {
|
|
981
1550
|
_initNewMessageBanner();
|
|
982
1551
|
_initEmptyHint();
|
|
1552
|
+
_initNewSessionControls();
|
|
1553
|
+
_initComposer();
|
|
1554
|
+
_initSearch();
|
|
1555
|
+
_initMessageHistory();
|
|
983
1556
|
// Re-render session list (badges/labels) when the user switches language
|
|
984
1557
|
document.addEventListener("langchange", () => Sessions.renderList());
|
|
985
1558
|
// Browsers block file:// navigation from http:// pages. Intercept clicks on
|
|
@@ -1911,14 +2484,14 @@ const Sessions = (() => {
|
|
|
1911
2484
|
|
|
1912
2485
|
card.innerHTML = cardHtml;
|
|
1913
2486
|
|
|
1914
|
-
// Click → disable card + submit immediately via
|
|
2487
|
+
// Click → disable card + submit immediately via _sendMessage()
|
|
1915
2488
|
card.querySelectorAll(".feedback-option-btn").forEach(btn => {
|
|
1916
2489
|
btn.onclick = () => {
|
|
1917
2490
|
card.querySelectorAll(".feedback-option-btn").forEach(b => b.disabled = true);
|
|
1918
2491
|
card.classList.add("feedback-card--submitted");
|
|
1919
2492
|
const input = $("user-input");
|
|
1920
2493
|
if (input) input.value = btn.textContent.trim();
|
|
1921
|
-
|
|
2494
|
+
_sendMessage();
|
|
1922
2495
|
};
|
|
1923
2496
|
});
|
|
1924
2497
|
|
|
@@ -2376,3 +2949,462 @@ const Sessions = (() => {
|
|
|
2376
2949
|
|
|
2377
2950
|
return Sessions;
|
|
2378
2951
|
})();
|
|
2952
|
+
|
|
2953
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
2954
|
+
// Session Info Bar interactions (model switcher + working-directory switcher
|
|
2955
|
+
// + session-actions dropdown). Two self-contained IIFEs that bind themselves
|
|
2956
|
+
// on document (event delegation), so no explicit init() call is needed —
|
|
2957
|
+
// they just work once this file is loaded.
|
|
2958
|
+
//
|
|
2959
|
+
// Moved here from app.js verbatim; kept as IIFEs to preserve private state
|
|
2960
|
+
// (benchmark cache, open/closed flags) without polluting the Sessions closure.
|
|
2961
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
2962
|
+
|
|
2963
|
+
// ── Session Info Bar Model Switcher ───────────────────────────────────────
|
|
2964
|
+
(function() {
|
|
2965
|
+
let _isOpen = false;
|
|
2966
|
+
// Cache of the most recent benchmark results, keyed by model_id. Kept at
|
|
2967
|
+
// closure scope so the numbers survive closing & reopening the dropdown —
|
|
2968
|
+
// the user shouldn't have to re-run the test just to peek at results. We
|
|
2969
|
+
// intentionally do NOT persist this to disk: latency is a point-in-time
|
|
2970
|
+
// measurement, and yesterday's numbers are misleading.
|
|
2971
|
+
let _benchCache = {}; // { [model_id]: { ttft_ms, ok, error, ts } }
|
|
2972
|
+
let _benchInFlight = false; // prevent double-click spam
|
|
2973
|
+
|
|
2974
|
+
// Toggle model dropdown when clicking on model name
|
|
2975
|
+
document.addEventListener("click", async (e) => {
|
|
2976
|
+
const modelEl = e.target.closest("#sib-model");
|
|
2977
|
+
if (modelEl) {
|
|
2978
|
+
e.stopPropagation();
|
|
2979
|
+
const dropdown = $("sib-model-dropdown");
|
|
2980
|
+
if (!dropdown) return;
|
|
2981
|
+
|
|
2982
|
+
if (_isOpen) {
|
|
2983
|
+
dropdown.style.display = "none";
|
|
2984
|
+
_isOpen = false;
|
|
2985
|
+
} else {
|
|
2986
|
+
await _populateModelDropdown(modelEl.dataset.sessionId, modelEl.textContent.trim());
|
|
2987
|
+
|
|
2988
|
+
// Calculate position relative to the model element (fixed positioning)
|
|
2989
|
+
const rect = modelEl.getBoundingClientRect();
|
|
2990
|
+
dropdown.style.left = `${rect.left + rect.width / 2}px`;
|
|
2991
|
+
dropdown.style.top = `${rect.top - 6}px`; // 6px above the element
|
|
2992
|
+
dropdown.style.transform = "translate(-50%, -100%)"; // Center horizontally, move up by its own height
|
|
2993
|
+
|
|
2994
|
+
dropdown.style.display = "block";
|
|
2995
|
+
_isOpen = true;
|
|
2996
|
+
}
|
|
2997
|
+
return;
|
|
2998
|
+
}
|
|
2999
|
+
|
|
3000
|
+
// Close dropdown when clicking outside
|
|
3001
|
+
if (_isOpen && !e.target.closest(".sib-model-dropdown")) {
|
|
3002
|
+
const dropdown = $("sib-model-dropdown");
|
|
3003
|
+
if (dropdown) dropdown.style.display = "none";
|
|
3004
|
+
_isOpen = false;
|
|
3005
|
+
}
|
|
3006
|
+
});
|
|
3007
|
+
|
|
3008
|
+
// Populate dropdown with available models
|
|
3009
|
+
async function _populateModelDropdown(sessionId, currentModel) {
|
|
3010
|
+
const dropdown = $("sib-model-dropdown");
|
|
3011
|
+
if (!dropdown) return;
|
|
3012
|
+
|
|
3013
|
+
try {
|
|
3014
|
+
console.log("[Model Switcher] Fetching /api/config...");
|
|
3015
|
+
const res = await fetch("/api/config");
|
|
3016
|
+
const data = await res.json();
|
|
3017
|
+
console.log("[Model Switcher] Received data:", data);
|
|
3018
|
+
const models = data.models || [];
|
|
3019
|
+
console.log("[Model Switcher] Models count:", models.length);
|
|
3020
|
+
|
|
3021
|
+
if (models.length === 0) {
|
|
3022
|
+
dropdown.innerHTML = '<div style="padding:12px;text-align:center;color:var(--color-text-secondary);font-size:11px;">No models configured</div>';
|
|
3023
|
+
return;
|
|
3024
|
+
}
|
|
3025
|
+
|
|
3026
|
+
dropdown.innerHTML = "";
|
|
3027
|
+
|
|
3028
|
+
// ── Benchmark floating button (top-right of dropdown) ──────────────
|
|
3029
|
+
// Tiny ⚡ button pinned to the dropdown's top-right corner. Runs one
|
|
3030
|
+
// concurrent request per model and back-fills each row's latency cell.
|
|
3031
|
+
// We deliberately avoid a full-width banner — it ate visual space that
|
|
3032
|
+
// the model list needs, and most users open the dropdown to SWITCH,
|
|
3033
|
+
// not to benchmark. The floating button is discoverable but unobtrusive.
|
|
3034
|
+
const bench = document.createElement("div");
|
|
3035
|
+
bench.className = "sib-model-bench";
|
|
3036
|
+
const btnLabel = (typeof I18n !== "undefined") ? I18n.t("sib.bench.btn") : "Benchmark";
|
|
3037
|
+
const btnTooltip = (typeof I18n !== "undefined") ? I18n.t("sib.bench.tooltip") : "Test response latency for every configured model";
|
|
3038
|
+
bench.innerHTML = `
|
|
3039
|
+
<button type="button" class="sib-bench-btn" title="${btnTooltip}">⚡ <span class="sib-bench-label">${btnLabel}</span></button>
|
|
3040
|
+
<span class="sib-bench-hint"></span>
|
|
3041
|
+
`;
|
|
3042
|
+
dropdown.appendChild(bench);
|
|
3043
|
+
|
|
3044
|
+
const benchBtn = bench.querySelector(".sib-bench-btn");
|
|
3045
|
+
const benchLabel = bench.querySelector(".sib-bench-label");
|
|
3046
|
+
const benchHint = bench.querySelector(".sib-bench-hint");
|
|
3047
|
+
benchBtn.addEventListener("click", (ev) => {
|
|
3048
|
+
ev.stopPropagation();
|
|
3049
|
+
_runBenchmark(sessionId, dropdown, benchBtn, benchLabel, benchHint);
|
|
3050
|
+
});
|
|
3051
|
+
|
|
3052
|
+
// ── Model rows ─────────────────────────────────────────────────────
|
|
3053
|
+
models.forEach(m => {
|
|
3054
|
+
console.log("[Model Switcher] Adding model:", m.model, "id:", m.id, "current:", currentModel);
|
|
3055
|
+
const opt = document.createElement("div");
|
|
3056
|
+
opt.className = "sib-model-option";
|
|
3057
|
+
opt.dataset.modelId = m.id;
|
|
3058
|
+
if (m.model === currentModel) opt.classList.add("current");
|
|
3059
|
+
|
|
3060
|
+
const left = document.createElement("span");
|
|
3061
|
+
left.className = "sib-model-name";
|
|
3062
|
+
left.textContent = m.model;
|
|
3063
|
+
opt.appendChild(left);
|
|
3064
|
+
|
|
3065
|
+
const right = document.createElement("span");
|
|
3066
|
+
right.className = "sib-model-right";
|
|
3067
|
+
|
|
3068
|
+
if (m.type === "default") {
|
|
3069
|
+
const badge = document.createElement("span");
|
|
3070
|
+
badge.className = `model-badge ${m.type}`;
|
|
3071
|
+
badge.textContent = m.type;
|
|
3072
|
+
right.appendChild(badge);
|
|
3073
|
+
}
|
|
3074
|
+
|
|
3075
|
+
// Latency cell — populated from _benchCache on open, updated live
|
|
3076
|
+
// when a benchmark run completes. Empty slot keeps row heights stable
|
|
3077
|
+
// so the list doesn't visually jump mid-benchmark.
|
|
3078
|
+
const lat = document.createElement("span");
|
|
3079
|
+
lat.className = "sib-model-latency";
|
|
3080
|
+
_fillLatencyCell(lat, _benchCache[m.id]);
|
|
3081
|
+
right.appendChild(lat);
|
|
3082
|
+
|
|
3083
|
+
opt.appendChild(right);
|
|
3084
|
+
|
|
3085
|
+
// Switch by id (stable across reorders/edits). Keep model name for UI update.
|
|
3086
|
+
opt.addEventListener("click", () => _switchModel(sessionId, m.id, m.model));
|
|
3087
|
+
dropdown.appendChild(opt);
|
|
3088
|
+
});
|
|
3089
|
+
console.log("[Model Switcher] Dropdown populated, children count:", dropdown.children.length);
|
|
3090
|
+
} catch (e) {
|
|
3091
|
+
console.error("Failed to load models:", e);
|
|
3092
|
+
dropdown.innerHTML = '<div style="padding:12px;text-align:center;color:var(--color-error);font-size:11px;">Error loading models</div>';
|
|
3093
|
+
}
|
|
3094
|
+
}
|
|
3095
|
+
|
|
3096
|
+
// Render one latency cell based on a cached result.
|
|
3097
|
+
// undefined → empty slot (never tested / in-flight starts from here)
|
|
3098
|
+
// { ok:true } → "812ms" in green/amber/red per threshold
|
|
3099
|
+
// { ok:false } → "✕" with error in tooltip
|
|
3100
|
+
// { pending:true } → "…" spinner-ish marker
|
|
3101
|
+
function _fillLatencyCell(el, entry) {
|
|
3102
|
+
el.className = "sib-model-latency";
|
|
3103
|
+
el.textContent = "";
|
|
3104
|
+
el.removeAttribute("title");
|
|
3105
|
+
if (!entry) return;
|
|
3106
|
+
if (entry.pending) {
|
|
3107
|
+
el.textContent = "…";
|
|
3108
|
+
el.classList.add("is-pending");
|
|
3109
|
+
return;
|
|
3110
|
+
}
|
|
3111
|
+
if (!entry.ok) {
|
|
3112
|
+
el.textContent = "✕";
|
|
3113
|
+
el.classList.add("is-err");
|
|
3114
|
+
el.title = entry.error || "failed";
|
|
3115
|
+
return;
|
|
3116
|
+
}
|
|
3117
|
+
const ms = entry.ttft_ms;
|
|
3118
|
+
// Same thresholds as the sib-signal status bar — keep them aligned so
|
|
3119
|
+
// "3 bars in the status bar" ≈ "green number in the picker".
|
|
3120
|
+
// We measure full non-streaming response time (not real TTFT), so ≤60s is
|
|
3121
|
+
// normal, ≤120s is slow, beyond is bad. ≤2s still gets the "feels instant"
|
|
3122
|
+
// green treatment like the 4-bar signal.
|
|
3123
|
+
let cls = "is-bad";
|
|
3124
|
+
if (ms <= 2000) cls = "is-ok";
|
|
3125
|
+
else if (ms <= 60000) cls = "is-ok";
|
|
3126
|
+
else if (ms <= 120000) cls = "is-warn";
|
|
3127
|
+
el.classList.add(cls);
|
|
3128
|
+
el.textContent = ms >= 1000 ? (ms / 1000).toFixed(1) + "s" : ms + "ms";
|
|
3129
|
+
if (typeof I18n !== "undefined") {
|
|
3130
|
+
el.title = I18n.t("sib.bench.latencyTooltip", {
|
|
3131
|
+
ttft: el.textContent,
|
|
3132
|
+
time: new Date(entry.ts).toLocaleTimeString(),
|
|
3133
|
+
});
|
|
3134
|
+
} else {
|
|
3135
|
+
el.title = `TTFT ${el.textContent} · tested ${new Date(entry.ts).toLocaleTimeString()}`;
|
|
3136
|
+
}
|
|
3137
|
+
}
|
|
3138
|
+
|
|
3139
|
+
async function _runBenchmark(sessionId, dropdown, btn, label, hint) {
|
|
3140
|
+
if (_benchInFlight) return;
|
|
3141
|
+
_benchInFlight = true;
|
|
3142
|
+
btn.disabled = true;
|
|
3143
|
+
const origLabel = label.textContent;
|
|
3144
|
+
const _t = (key, vars) => (typeof I18n !== "undefined") ? I18n.t(key, vars) : key;
|
|
3145
|
+
label.textContent = _t("sib.bench.running");
|
|
3146
|
+
hint.textContent = "";
|
|
3147
|
+
|
|
3148
|
+
// Mark every row as pending so the user sees instant feedback instead of
|
|
3149
|
+
// a silent button. _fillLatencyCell handles the visual treatment.
|
|
3150
|
+
dropdown.querySelectorAll(".sib-model-option").forEach(opt => {
|
|
3151
|
+
const id = opt.dataset.modelId;
|
|
3152
|
+
if (!id) return;
|
|
3153
|
+
_benchCache[id] = { pending: true };
|
|
3154
|
+
_fillLatencyCell(opt.querySelector(".sib-model-latency"), _benchCache[id]);
|
|
3155
|
+
});
|
|
3156
|
+
|
|
3157
|
+
const t0 = performance.now();
|
|
3158
|
+
try {
|
|
3159
|
+
const res = await fetch(`/api/sessions/${sessionId}/benchmark`, { method: "POST" });
|
|
3160
|
+
const data = await res.json();
|
|
3161
|
+
if (!res.ok || !data.ok) throw new Error(data.error || "benchmark failed");
|
|
3162
|
+
|
|
3163
|
+
const now = Date.now();
|
|
3164
|
+
(data.results || []).forEach(r => {
|
|
3165
|
+
_benchCache[r.model_id] = {
|
|
3166
|
+
ok: !!r.ok,
|
|
3167
|
+
ttft_ms: r.ttft_ms,
|
|
3168
|
+
error: r.error,
|
|
3169
|
+
ts: now,
|
|
3170
|
+
};
|
|
3171
|
+
const opt = dropdown.querySelector(`.sib-model-option[data-model-id="${CSS.escape(r.model_id)}"]`);
|
|
3172
|
+
if (opt) _fillLatencyCell(opt.querySelector(".sib-model-latency"), _benchCache[r.model_id]);
|
|
3173
|
+
});
|
|
3174
|
+
|
|
3175
|
+
const elapsed = ((performance.now() - t0) / 1000).toFixed(1);
|
|
3176
|
+
hint.textContent = _t("sib.bench.done", { t: elapsed });
|
|
3177
|
+
} catch (e) {
|
|
3178
|
+
console.error("Benchmark failed:", e);
|
|
3179
|
+
hint.textContent = _t("sib.bench.failed", { msg: e.message });
|
|
3180
|
+
// Clear pending markers so rows don't stay stuck on "…"
|
|
3181
|
+
dropdown.querySelectorAll(".sib-model-option").forEach(opt => {
|
|
3182
|
+
const id = opt.dataset.modelId;
|
|
3183
|
+
if (id && _benchCache[id]?.pending) {
|
|
3184
|
+
_benchCache[id] = undefined;
|
|
3185
|
+
_fillLatencyCell(opt.querySelector(".sib-model-latency"), undefined);
|
|
3186
|
+
}
|
|
3187
|
+
});
|
|
3188
|
+
} finally {
|
|
3189
|
+
_benchInFlight = false;
|
|
3190
|
+
btn.disabled = false;
|
|
3191
|
+
label.textContent = origLabel;
|
|
3192
|
+
}
|
|
3193
|
+
}
|
|
3194
|
+
|
|
3195
|
+
// Switch session model via API
|
|
3196
|
+
// modelId — stable runtime id (required by backend)
|
|
3197
|
+
// modelName — display name, used for optimistic UI update
|
|
3198
|
+
async function _switchModel(sessionId, modelId, modelName) {
|
|
3199
|
+
const dropdown = $("sib-model-dropdown");
|
|
3200
|
+
if (dropdown) {
|
|
3201
|
+
dropdown.style.display = "none";
|
|
3202
|
+
_isOpen = false;
|
|
3203
|
+
}
|
|
3204
|
+
|
|
3205
|
+
try {
|
|
3206
|
+
const res = await fetch(`/api/sessions/${sessionId}/model`, {
|
|
3207
|
+
method: "PATCH",
|
|
3208
|
+
headers: { "Content-Type": "application/json" },
|
|
3209
|
+
body: JSON.stringify({ model_id: modelId })
|
|
3210
|
+
});
|
|
3211
|
+
|
|
3212
|
+
const data = await res.json();
|
|
3213
|
+
|
|
3214
|
+
if (!res.ok) {
|
|
3215
|
+
throw new Error(data.error || "Unknown error");
|
|
3216
|
+
}
|
|
3217
|
+
|
|
3218
|
+
// Update UI optimistically (will be confirmed by session_update broadcast)
|
|
3219
|
+
const sibModel = $("sib-model");
|
|
3220
|
+
if (sibModel) sibModel.textContent = modelName;
|
|
3221
|
+
|
|
3222
|
+
console.log(`Switched session ${sessionId} to model ${modelName} (${modelId})`);
|
|
3223
|
+
} catch (e) {
|
|
3224
|
+
console.error("Failed to switch model:", e);
|
|
3225
|
+
alert("Failed to switch model: " + e.message);
|
|
3226
|
+
}
|
|
3227
|
+
}
|
|
3228
|
+
})();
|
|
3229
|
+
|
|
3230
|
+
// ── Session Info Bar Working Directory Switcher ───────────────────────────
|
|
3231
|
+
(function() {
|
|
3232
|
+
// Handle click on working directory
|
|
3233
|
+
document.addEventListener("click", async (e) => {
|
|
3234
|
+
const dirEl = e.target.closest("#sib-dir");
|
|
3235
|
+
if (dirEl) {
|
|
3236
|
+
e.stopPropagation();
|
|
3237
|
+
const sessionId = dirEl.dataset.sessionId;
|
|
3238
|
+
const currentDir = dirEl.title.replace(" (click to change)", "");
|
|
3239
|
+
|
|
3240
|
+
const newDir = await Modal.prompt("Change working directory:", currentDir);
|
|
3241
|
+
if (newDir && newDir !== currentDir) {
|
|
3242
|
+
_changeWorkingDirectory(sessionId, newDir);
|
|
3243
|
+
}
|
|
3244
|
+
}
|
|
3245
|
+
|
|
3246
|
+
// Handle click on session ID — toggles a small actions dropdown with
|
|
3247
|
+
// items like "Download session files (for debugging)". Designed to be
|
|
3248
|
+
// extensible (more session-level actions can be added here later).
|
|
3249
|
+
const sibIdEl = e.target.closest("#sib-id");
|
|
3250
|
+
if (sibIdEl) {
|
|
3251
|
+
e.stopPropagation();
|
|
3252
|
+
const sessionId = sibIdEl.dataset.sessionId;
|
|
3253
|
+
if (!sessionId) return;
|
|
3254
|
+
_toggleSessionActionsDropdown(sibIdEl, sessionId);
|
|
3255
|
+
return;
|
|
3256
|
+
}
|
|
3257
|
+
|
|
3258
|
+
// Handle click on an item inside the actions dropdown.
|
|
3259
|
+
const actionItem = e.target.closest(".sib-actions-item");
|
|
3260
|
+
if (actionItem) {
|
|
3261
|
+
e.stopPropagation();
|
|
3262
|
+
const action = actionItem.dataset.action;
|
|
3263
|
+
const sessionId = actionItem.dataset.sessionId;
|
|
3264
|
+
_closeSessionActionsDropdown();
|
|
3265
|
+
if (action === "download" && sessionId) {
|
|
3266
|
+
_downloadSessionBundle(sessionId, actionItem);
|
|
3267
|
+
}
|
|
3268
|
+
return;
|
|
3269
|
+
}
|
|
3270
|
+
|
|
3271
|
+
// Click outside — close the actions dropdown if open.
|
|
3272
|
+
if (!e.target.closest("#sib-actions-dropdown")) {
|
|
3273
|
+
_closeSessionActionsDropdown();
|
|
3274
|
+
}
|
|
3275
|
+
});
|
|
3276
|
+
|
|
3277
|
+
// Close dropdown on Escape.
|
|
3278
|
+
document.addEventListener("keydown", (e) => {
|
|
3279
|
+
if (e.key === "Escape") _closeSessionActionsDropdown();
|
|
3280
|
+
});
|
|
3281
|
+
|
|
3282
|
+
function _closeSessionActionsDropdown() {
|
|
3283
|
+
const dd = $("sib-actions-dropdown");
|
|
3284
|
+
if (dd && dd.style.display !== "none") dd.style.display = "none";
|
|
3285
|
+
}
|
|
3286
|
+
|
|
3287
|
+
function _toggleSessionActionsDropdown(anchorEl, sessionId) {
|
|
3288
|
+
const dd = $("sib-actions-dropdown");
|
|
3289
|
+
if (!dd) return;
|
|
3290
|
+
|
|
3291
|
+
// If already open for this session, close it (toggle behaviour).
|
|
3292
|
+
if (dd.style.display !== "none" && dd.dataset.sessionId === sessionId) {
|
|
3293
|
+
dd.style.display = "none";
|
|
3294
|
+
return;
|
|
3295
|
+
}
|
|
3296
|
+
|
|
3297
|
+
_populateSessionActionsDropdown(dd, sessionId);
|
|
3298
|
+
dd.dataset.sessionId = sessionId;
|
|
3299
|
+
|
|
3300
|
+
// Position the dropdown above the session ID element (same pattern as
|
|
3301
|
+
// the model switcher — fixed positioning, centered horizontally).
|
|
3302
|
+
const rect = anchorEl.getBoundingClientRect();
|
|
3303
|
+
dd.style.left = `${rect.left + rect.width / 2}px`;
|
|
3304
|
+
dd.style.top = `${rect.top - 6}px`;
|
|
3305
|
+
dd.style.transform = "translate(-50%, -100%)";
|
|
3306
|
+
dd.style.display = "block";
|
|
3307
|
+
}
|
|
3308
|
+
|
|
3309
|
+
function _populateSessionActionsDropdown(dd, sessionId) {
|
|
3310
|
+
const t = (key, fallback) => (window.I18n && I18n.t(key)) || fallback;
|
|
3311
|
+
dd.innerHTML = "";
|
|
3312
|
+
|
|
3313
|
+
// Download item
|
|
3314
|
+
const item = document.createElement("div");
|
|
3315
|
+
item.className = "sib-actions-item";
|
|
3316
|
+
item.setAttribute("role", "menuitem");
|
|
3317
|
+
item.dataset.action = "download";
|
|
3318
|
+
item.dataset.sessionId = sessionId;
|
|
3319
|
+
|
|
3320
|
+
const icon = document.createElement("span");
|
|
3321
|
+
icon.className = "sib-actions-icon";
|
|
3322
|
+
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>`;
|
|
3323
|
+
|
|
3324
|
+
const label = document.createElement("span");
|
|
3325
|
+
label.className = "sib-actions-label";
|
|
3326
|
+
label.textContent = t("sessions.actions.download", "Download session files");
|
|
3327
|
+
|
|
3328
|
+
const hint = document.createElement("span");
|
|
3329
|
+
hint.className = "sib-actions-hint";
|
|
3330
|
+
hint.textContent = t("sessions.actions.downloadHint", "for debugging");
|
|
3331
|
+
|
|
3332
|
+
item.appendChild(icon);
|
|
3333
|
+
item.appendChild(label);
|
|
3334
|
+
item.appendChild(hint);
|
|
3335
|
+
dd.appendChild(item);
|
|
3336
|
+
}
|
|
3337
|
+
|
|
3338
|
+
async function _downloadSessionBundle(sessionId, btnEl) {
|
|
3339
|
+
// btnEl may be a <button> (legacy) or a menu item <div> — guard accordingly.
|
|
3340
|
+
const wasDisabled = btnEl && btnEl.disabled;
|
|
3341
|
+
if (btnEl) {
|
|
3342
|
+
try { btnEl.disabled = true; } catch (_) {}
|
|
3343
|
+
btnEl.classList && btnEl.classList.add("is-loading");
|
|
3344
|
+
}
|
|
3345
|
+
try {
|
|
3346
|
+
const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/export`);
|
|
3347
|
+
if (!res.ok) {
|
|
3348
|
+
let msg = `HTTP ${res.status}`;
|
|
3349
|
+
try { const data = await res.json(); if (data.error) msg = data.error; } catch (_) {}
|
|
3350
|
+
alert((window.I18n && I18n.t("sessions.export.failed")) || "Failed to download session: " + msg);
|
|
3351
|
+
return;
|
|
3352
|
+
}
|
|
3353
|
+
const blob = await res.blob();
|
|
3354
|
+
|
|
3355
|
+
// Derive filename from Content-Disposition header, fall back to short id.
|
|
3356
|
+
let filename = `clacky-session-${sessionId.slice(0, 8)}.zip`;
|
|
3357
|
+
const cd = res.headers.get("Content-Disposition") || "";
|
|
3358
|
+
const m = cd.match(/filename="?([^"]+)"?/i);
|
|
3359
|
+
if (m) filename = m[1];
|
|
3360
|
+
|
|
3361
|
+
const url = URL.createObjectURL(blob);
|
|
3362
|
+
const a = document.createElement("a");
|
|
3363
|
+
a.href = url;
|
|
3364
|
+
a.download = filename;
|
|
3365
|
+
document.body.appendChild(a);
|
|
3366
|
+
a.click();
|
|
3367
|
+
a.remove();
|
|
3368
|
+
// Revoke on next tick so the browser has a chance to start the download.
|
|
3369
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
3370
|
+
} catch (err) {
|
|
3371
|
+
console.error("Session export failed:", err);
|
|
3372
|
+
alert(((window.I18n && I18n.t("sessions.export.failed")) || "Failed to download session") + ": " + err.message);
|
|
3373
|
+
} finally {
|
|
3374
|
+
if (btnEl) {
|
|
3375
|
+
try { btnEl.disabled = wasDisabled; } catch (_) {}
|
|
3376
|
+
btnEl.classList && btnEl.classList.remove("is-loading");
|
|
3377
|
+
}
|
|
3378
|
+
}
|
|
3379
|
+
}
|
|
3380
|
+
|
|
3381
|
+
// Change working directory via backend API
|
|
3382
|
+
async function _changeWorkingDirectory(sessionId, newDir) {
|
|
3383
|
+
try {
|
|
3384
|
+
const res = await fetch(`/api/sessions/${sessionId}/working_dir`, {
|
|
3385
|
+
method: "PATCH",
|
|
3386
|
+
headers: { "Content-Type": "application/json" },
|
|
3387
|
+
body: JSON.stringify({ working_dir: newDir })
|
|
3388
|
+
});
|
|
3389
|
+
|
|
3390
|
+
const data = await res.json();
|
|
3391
|
+
|
|
3392
|
+
if (!res.ok) {
|
|
3393
|
+
throw new Error(data.error || "Unknown error");
|
|
3394
|
+
}
|
|
3395
|
+
|
|
3396
|
+
// Update UI optimistically (will be confirmed by session_update broadcast)
|
|
3397
|
+
const sibDir = $("sib-dir");
|
|
3398
|
+
if (sibDir) {
|
|
3399
|
+
sibDir.textContent = newDir;
|
|
3400
|
+
sibDir.title = newDir + " (click to change)";
|
|
3401
|
+
}
|
|
3402
|
+
|
|
3403
|
+
console.log(`Changed session ${sessionId} directory to ${newDir}`);
|
|
3404
|
+
} catch (e) {
|
|
3405
|
+
console.error("Failed to change directory:", e);
|
|
3406
|
+
alert("Failed to change directory: " + e.message);
|
|
3407
|
+
}
|
|
3408
|
+
}
|
|
3409
|
+
|
|
3410
|
+
})();
|