openclacky 1.2.6 → 1.2.7
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 +20 -0
- data/README.md +34 -0
- data/README_CN.md +34 -0
- data/lib/clacky/agent/cost_tracker.rb +7 -1
- data/lib/clacky/agent/message_compressor.rb +2 -1
- data/lib/clacky/agent/message_compressor_helper.rb +6 -2
- data/lib/clacky/agent/session_serializer.rb +23 -4
- data/lib/clacky/agent.rb +43 -2
- data/lib/clacky/agent_config.rb +54 -6
- data/lib/clacky/brand_config.rb +0 -6
- data/lib/clacky/cli.rb +2 -1
- data/lib/clacky/client.rb +24 -3
- data/lib/clacky/default_skills/onboard/SKILL.md +2 -2
- data/lib/clacky/json_ui_controller.rb +5 -2
- data/lib/clacky/plain_ui_controller.rb +1 -1
- data/lib/clacky/providers.rb +11 -2
- data/lib/clacky/server/channel/channel_manager.rb +148 -12
- data/lib/clacky/server/channel/channel_ui_controller.rb +4 -2
- data/lib/clacky/server/http_server.rb +109 -9
- data/lib/clacky/server/session_registry.rb +30 -4
- data/lib/clacky/server/web_ui_controller.rb +6 -3
- data/lib/clacky/tools/terminal.rb +22 -26
- data/lib/clacky/ui2/ui_controller.rb +1 -1
- data/lib/clacky/ui_interface.rb +1 -1
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +237 -1
- data/lib/clacky/web/app.js +0 -1
- data/lib/clacky/web/i18n.js +24 -0
- data/lib/clacky/web/index.html +33 -0
- data/lib/clacky/web/sessions.js +203 -14
- data/lib/clacky/web/settings.js +59 -17
- data/lib/clacky/web/workspace.js +204 -0
- data/lib/clacky/web/ws-dispatcher.js +19 -3
- data/lib/clacky.rb +9 -0
- metadata +3 -2
data/lib/clacky/web/sessions.js
CHANGED
|
@@ -1396,8 +1396,15 @@ const Sessions = (() => {
|
|
|
1396
1396
|
// showProgress() with the authoritative started_at, which is the
|
|
1397
1397
|
// single source of truth for first-visit sessions (no cached state).
|
|
1398
1398
|
} else if (session.status === "error" && session.error) {
|
|
1399
|
-
|
|
1400
|
-
|
|
1399
|
+
if (window.renderErrorEvent) {
|
|
1400
|
+
window.renderErrorEvent({
|
|
1401
|
+
code: session.error_code,
|
|
1402
|
+
message: session.error,
|
|
1403
|
+
top_up_url: session.top_up_url,
|
|
1404
|
+
});
|
|
1405
|
+
} else {
|
|
1406
|
+
Sessions.appendMsg("error", session.error);
|
|
1407
|
+
}
|
|
1401
1408
|
}
|
|
1402
1409
|
}
|
|
1403
1410
|
}
|
|
@@ -2335,6 +2342,7 @@ const Sessions = (() => {
|
|
|
2335
2342
|
/** Update the session info bar below the chat header with current session metadata. */
|
|
2336
2343
|
updateInfoBar(s) {
|
|
2337
2344
|
this._lastSession = s;
|
|
2345
|
+
if (window.Workspace) Workspace.onSession(s);
|
|
2338
2346
|
if (!s) {
|
|
2339
2347
|
// Hide all spans when no session
|
|
2340
2348
|
["sib-id", "sib-status", "sib-dir", "sib-mode", "sib-model", "sib-reasoning", "sib-tasks", "sib-cost"].forEach(id => {
|
|
@@ -2393,15 +2401,21 @@ const Sessions = (() => {
|
|
|
2393
2401
|
const sibModelWrap = $("sib-model-wrap");
|
|
2394
2402
|
const sibModel = $("sib-model");
|
|
2395
2403
|
if (sibModel) {
|
|
2396
|
-
|
|
2397
|
-
|
|
2404
|
+
const subModel = s.sub_model;
|
|
2405
|
+
const cardModel = s.card_model;
|
|
2406
|
+
const display = subModel
|
|
2407
|
+
? `${subModel}`
|
|
2408
|
+
: (s.model || "");
|
|
2409
|
+
sibModel.textContent = display;
|
|
2398
2410
|
sibModel.dataset.sessionId = s.id;
|
|
2399
2411
|
if (s.model_id) {
|
|
2400
2412
|
sibModel.dataset.modelId = s.model_id;
|
|
2401
2413
|
} else {
|
|
2402
2414
|
delete sibModel.dataset.modelId;
|
|
2403
2415
|
}
|
|
2404
|
-
|
|
2416
|
+
if (cardModel) sibModel.dataset.cardModel = cardModel; else delete sibModel.dataset.cardModel;
|
|
2417
|
+
if (subModel) sibModel.dataset.subModel = subModel; else delete sibModel.dataset.subModel;
|
|
2418
|
+
sibModel.dataset.subModelOptions = JSON.stringify(s.sub_model_options || []);
|
|
2405
2419
|
const busy = s.status === "running";
|
|
2406
2420
|
sibModel.classList.toggle("sib-model-disabled", busy);
|
|
2407
2421
|
sibModel.title = busy
|
|
@@ -3343,8 +3357,16 @@ const Sessions = (() => {
|
|
|
3343
3357
|
if (_isOpen) {
|
|
3344
3358
|
dropdown.style.display = "none";
|
|
3345
3359
|
_isOpen = false;
|
|
3360
|
+
_closeSubmodelPanel();
|
|
3346
3361
|
} else {
|
|
3347
|
-
|
|
3362
|
+
let subOptions = [];
|
|
3363
|
+
try { subOptions = JSON.parse(modelEl.dataset.subModelOptions || "[]"); } catch (_) {}
|
|
3364
|
+
const subInfo = {
|
|
3365
|
+
options: Array.isArray(subOptions) ? subOptions : [],
|
|
3366
|
+
current: modelEl.dataset.subModel || null,
|
|
3367
|
+
cardModel: modelEl.dataset.cardModel || null
|
|
3368
|
+
};
|
|
3369
|
+
await _populateModelDropdown(modelEl.dataset.sessionId, modelEl.dataset.modelId || null, subInfo);
|
|
3348
3370
|
|
|
3349
3371
|
// Calculate position relative to the model element (fixed positioning)
|
|
3350
3372
|
const rect = modelEl.getBoundingClientRect();
|
|
@@ -3359,15 +3381,17 @@ const Sessions = (() => {
|
|
|
3359
3381
|
}
|
|
3360
3382
|
|
|
3361
3383
|
// Close dropdown when clicking outside
|
|
3362
|
-
if (_isOpen && !e.target.closest(".sib-model-dropdown")) {
|
|
3384
|
+
if (_isOpen && !e.target.closest(".sib-model-dropdown") && !e.target.closest(".sib-submodel-panel")) {
|
|
3363
3385
|
const dropdown = $("sib-model-dropdown");
|
|
3364
3386
|
if (dropdown) dropdown.style.display = "none";
|
|
3365
3387
|
_isOpen = false;
|
|
3388
|
+
_closeSubmodelPanel();
|
|
3366
3389
|
}
|
|
3367
3390
|
});
|
|
3368
3391
|
|
|
3369
3392
|
// Populate dropdown with available models
|
|
3370
|
-
async function _populateModelDropdown(sessionId, currentModelId) {
|
|
3393
|
+
async function _populateModelDropdown(sessionId, currentModelId, subInfo) {
|
|
3394
|
+
subInfo = subInfo || { options: [], current: null, cardModel: null };
|
|
3371
3395
|
const dropdown = $("sib-model-dropdown");
|
|
3372
3396
|
if (!dropdown) return;
|
|
3373
3397
|
|
|
@@ -3431,6 +3455,13 @@ const Sessions = (() => {
|
|
|
3431
3455
|
nameLine.textContent = m.model;
|
|
3432
3456
|
left.appendChild(nameLine);
|
|
3433
3457
|
|
|
3458
|
+
if (m.id === currentModelId && subInfo.current && subInfo.current !== subInfo.cardModel) {
|
|
3459
|
+
const overrideLine = document.createElement("span");
|
|
3460
|
+
overrideLine.className = "sib-model-name-override";
|
|
3461
|
+
overrideLine.textContent = `→ ${subInfo.current}`;
|
|
3462
|
+
left.appendChild(overrideLine);
|
|
3463
|
+
}
|
|
3464
|
+
|
|
3434
3465
|
if (_nameCounts[m.model] > 1) {
|
|
3435
3466
|
left.classList.add("has-sub");
|
|
3436
3467
|
const host = (() => {
|
|
@@ -3458,17 +3489,36 @@ const Sessions = (() => {
|
|
|
3458
3489
|
right.appendChild(badge);
|
|
3459
3490
|
}
|
|
3460
3491
|
|
|
3461
|
-
// Latency cell — populated from _benchCache on open, updated live
|
|
3462
|
-
// when a benchmark run completes. Empty slot keeps row heights stable
|
|
3463
|
-
// so the list doesn't visually jump mid-benchmark.
|
|
3464
3492
|
const lat = document.createElement("span");
|
|
3465
3493
|
lat.className = "sib-model-latency";
|
|
3466
3494
|
_fillLatencyCell(lat, _benchCache[m.id]);
|
|
3467
3495
|
right.appendChild(lat);
|
|
3468
3496
|
|
|
3497
|
+
const hasSubModels =
|
|
3498
|
+
m.id === currentModelId &&
|
|
3499
|
+
subInfo.options &&
|
|
3500
|
+
subInfo.options.length > 1;
|
|
3501
|
+
|
|
3502
|
+
if (hasSubModels) {
|
|
3503
|
+
const toggleBtn = document.createElement("button");
|
|
3504
|
+
toggleBtn.type = "button";
|
|
3505
|
+
toggleBtn.className = "sib-submodel-toggle";
|
|
3506
|
+
toggleBtn.title = "Switch sub-model";
|
|
3507
|
+
toggleBtn.setAttribute("aria-expanded", "false");
|
|
3508
|
+
toggleBtn.innerHTML =
|
|
3509
|
+
'<svg viewBox="0 0 16 16" width="11" height="11" aria-hidden="true">' +
|
|
3510
|
+
'<path d="M6 3.5L10.5 8 6 12.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>' +
|
|
3511
|
+
'</svg>';
|
|
3512
|
+
right.appendChild(toggleBtn);
|
|
3513
|
+
|
|
3514
|
+
toggleBtn.addEventListener("click", (ev) => {
|
|
3515
|
+
ev.stopPropagation();
|
|
3516
|
+
_toggleSubmodelPanel(opt, toggleBtn, sessionId, subInfo);
|
|
3517
|
+
});
|
|
3518
|
+
}
|
|
3519
|
+
|
|
3469
3520
|
opt.appendChild(right);
|
|
3470
3521
|
|
|
3471
|
-
// Switch by id (stable across reorders/edits). Keep model name for UI update.
|
|
3472
3522
|
opt.addEventListener("click", () => _switchModel(sessionId, m.id, m.model));
|
|
3473
3523
|
dropdown.appendChild(opt);
|
|
3474
3524
|
});
|
|
@@ -3579,14 +3629,15 @@ const Sessions = (() => {
|
|
|
3579
3629
|
}
|
|
3580
3630
|
|
|
3581
3631
|
// Switch session model via API
|
|
3582
|
-
// modelId
|
|
3583
|
-
// modelName
|
|
3632
|
+
// Switch the session's current card. modelId is the stable runtime id,
|
|
3633
|
+
// modelName is for optimistic display.
|
|
3584
3634
|
async function _switchModel(sessionId, modelId, modelName) {
|
|
3585
3635
|
const dropdown = $("sib-model-dropdown");
|
|
3586
3636
|
if (dropdown) {
|
|
3587
3637
|
dropdown.style.display = "none";
|
|
3588
3638
|
_isOpen = false;
|
|
3589
3639
|
}
|
|
3640
|
+
_closeSubmodelPanel();
|
|
3590
3641
|
|
|
3591
3642
|
try {
|
|
3592
3643
|
const res = await fetch(`/api/sessions/${sessionId}/model`, {
|
|
@@ -3611,6 +3662,144 @@ const Sessions = (() => {
|
|
|
3611
3662
|
alert("Failed to switch model: " + e.message);
|
|
3612
3663
|
}
|
|
3613
3664
|
}
|
|
3665
|
+
|
|
3666
|
+
// Pin (or clear) the session's sub-model. Pass modelName=null to clear.
|
|
3667
|
+
// displayName is what we optimistically show in the status bar.
|
|
3668
|
+
async function _switchSubModel(sessionId, modelName, displayName) {
|
|
3669
|
+
const dropdown = $("sib-model-dropdown");
|
|
3670
|
+
if (dropdown) {
|
|
3671
|
+
dropdown.style.display = "none";
|
|
3672
|
+
_isOpen = false;
|
|
3673
|
+
}
|
|
3674
|
+
_closeSubmodelPanel();
|
|
3675
|
+
|
|
3676
|
+
try {
|
|
3677
|
+
const res = await fetch(`/api/sessions/${sessionId}/submodel`, {
|
|
3678
|
+
method: "PATCH",
|
|
3679
|
+
headers: { "Content-Type": "application/json" },
|
|
3680
|
+
body: JSON.stringify({ model_name: modelName })
|
|
3681
|
+
});
|
|
3682
|
+
const data = await res.json();
|
|
3683
|
+
if (!res.ok) throw new Error(data.error || "Unknown error");
|
|
3684
|
+
|
|
3685
|
+
const sibModel = $("sib-model");
|
|
3686
|
+
if (sibModel) {
|
|
3687
|
+
sibModel.textContent = displayName || "";
|
|
3688
|
+
sibModel.dataset.subModel = modelName || "";
|
|
3689
|
+
}
|
|
3690
|
+
} catch (e) {
|
|
3691
|
+
console.error("Failed to switch sub-model:", e);
|
|
3692
|
+
alert("Failed to switch sub-model: " + e.message);
|
|
3693
|
+
}
|
|
3694
|
+
}
|
|
3695
|
+
|
|
3696
|
+
let _activeSubmodelAnchor = null;
|
|
3697
|
+
|
|
3698
|
+
function _closeSubmodelPanel() {
|
|
3699
|
+
const panel = $("sib-submodel-panel");
|
|
3700
|
+
if (panel) panel.style.display = "none";
|
|
3701
|
+
if (_activeSubmodelAnchor) {
|
|
3702
|
+
const btn = _activeSubmodelAnchor.querySelector(".sib-submodel-toggle");
|
|
3703
|
+
if (btn) btn.setAttribute("aria-expanded", "false");
|
|
3704
|
+
_activeSubmodelAnchor.classList.remove("submodel-open");
|
|
3705
|
+
_activeSubmodelAnchor = null;
|
|
3706
|
+
}
|
|
3707
|
+
}
|
|
3708
|
+
|
|
3709
|
+
function _toggleSubmodelPanel(anchorRow, btn, sessionId, subInfo) {
|
|
3710
|
+
const panel = $("sib-submodel-panel");
|
|
3711
|
+
const dropdown = $("sib-model-dropdown");
|
|
3712
|
+
if (!panel || !dropdown) return;
|
|
3713
|
+
|
|
3714
|
+
if (panel.parentElement !== document.body) {
|
|
3715
|
+
document.body.appendChild(panel);
|
|
3716
|
+
}
|
|
3717
|
+
|
|
3718
|
+
const isOpen = panel.style.display !== "none" && _activeSubmodelAnchor === anchorRow;
|
|
3719
|
+
if (isOpen) {
|
|
3720
|
+
_closeSubmodelPanel();
|
|
3721
|
+
return;
|
|
3722
|
+
}
|
|
3723
|
+
|
|
3724
|
+
_renderSubmodelPanel(panel, sessionId, subInfo);
|
|
3725
|
+
|
|
3726
|
+
// Reset any prior position so measurements are accurate.
|
|
3727
|
+
panel.style.left = "0px";
|
|
3728
|
+
panel.style.top = "0px";
|
|
3729
|
+
panel.style.display = "block";
|
|
3730
|
+
panel.style.visibility = "hidden";
|
|
3731
|
+
|
|
3732
|
+
const dropRect = dropdown.getBoundingClientRect();
|
|
3733
|
+
const btnRect = btn.getBoundingClientRect();
|
|
3734
|
+
const panelRect = panel.getBoundingClientRect();
|
|
3735
|
+
const gap = 6;
|
|
3736
|
+
const margin = 8;
|
|
3737
|
+
const vw = window.innerWidth;
|
|
3738
|
+
const vh = window.innerHeight;
|
|
3739
|
+
|
|
3740
|
+
// Prefer right of dropdown; flip to left if we'd overflow viewport.
|
|
3741
|
+
let left = dropRect.right + gap;
|
|
3742
|
+
if (left + panelRect.width > vw - margin) {
|
|
3743
|
+
left = dropRect.left - panelRect.width - gap;
|
|
3744
|
+
}
|
|
3745
|
+
// If still off-screen on the left, clamp inside viewport.
|
|
3746
|
+
if (left < margin) left = margin;
|
|
3747
|
+
|
|
3748
|
+
// Vertically align to the chevron button, but clamp inside viewport.
|
|
3749
|
+
let top = btnRect.top - 6;
|
|
3750
|
+
if (top + panelRect.height > vh - margin) {
|
|
3751
|
+
top = vh - margin - panelRect.height;
|
|
3752
|
+
}
|
|
3753
|
+
if (top < margin) top = margin;
|
|
3754
|
+
|
|
3755
|
+
panel.style.left = `${left}px`;
|
|
3756
|
+
panel.style.top = `${top}px`;
|
|
3757
|
+
panel.style.visibility = "";
|
|
3758
|
+
|
|
3759
|
+
_activeSubmodelAnchor = anchorRow;
|
|
3760
|
+
anchorRow.classList.add("submodel-open");
|
|
3761
|
+
btn.setAttribute("aria-expanded", "true");
|
|
3762
|
+
}
|
|
3763
|
+
|
|
3764
|
+
function _renderSubmodelPanel(panel, sessionId, subInfo) {
|
|
3765
|
+
panel.innerHTML = "";
|
|
3766
|
+
|
|
3767
|
+
const header = document.createElement("div");
|
|
3768
|
+
header.className = "sib-submodel-panel-header";
|
|
3769
|
+
header.textContent = "Sub-model";
|
|
3770
|
+
panel.appendChild(header);
|
|
3771
|
+
|
|
3772
|
+
const cardDefault = subInfo.cardModel;
|
|
3773
|
+
subInfo.options.forEach(name => {
|
|
3774
|
+
const row = document.createElement("div");
|
|
3775
|
+
row.className = "sib-submodel-row";
|
|
3776
|
+
row.dataset.subModel = name;
|
|
3777
|
+
|
|
3778
|
+
const isActive = subInfo.current
|
|
3779
|
+
? name === subInfo.current
|
|
3780
|
+
: name === cardDefault;
|
|
3781
|
+
if (isActive) row.classList.add("current");
|
|
3782
|
+
|
|
3783
|
+
const nameEl = document.createElement("span");
|
|
3784
|
+
nameEl.className = "sib-submodel-row-name";
|
|
3785
|
+
nameEl.textContent = name;
|
|
3786
|
+
row.appendChild(nameEl);
|
|
3787
|
+
|
|
3788
|
+
if (name === cardDefault) {
|
|
3789
|
+
const tag = document.createElement("span");
|
|
3790
|
+
tag.className = "sib-submodel-default-tag";
|
|
3791
|
+
tag.textContent = "default";
|
|
3792
|
+
row.appendChild(tag);
|
|
3793
|
+
}
|
|
3794
|
+
|
|
3795
|
+
row.addEventListener("click", (ev) => {
|
|
3796
|
+
ev.stopPropagation();
|
|
3797
|
+
const passName = (name === cardDefault) ? null : name;
|
|
3798
|
+
_switchSubModel(sessionId, passName, name);
|
|
3799
|
+
});
|
|
3800
|
+
panel.appendChild(row);
|
|
3801
|
+
});
|
|
3802
|
+
}
|
|
3614
3803
|
})();
|
|
3615
3804
|
|
|
3616
3805
|
// ── Session Info Bar Working Directory Switcher ───────────────────────────
|
data/lib/clacky/web/settings.js
CHANGED
|
@@ -6,6 +6,11 @@ const Settings = (() => {
|
|
|
6
6
|
let _models = [];
|
|
7
7
|
// Provider presets loaded from server
|
|
8
8
|
let _providers = [];
|
|
9
|
+
// Provider id selected in the model edit modal (null when "Custom" or unset).
|
|
10
|
+
// Used to opt the request into anthropic_format=true when the user picks
|
|
11
|
+
// the Anthropic provider; other providers leave the flag unset and let the
|
|
12
|
+
// backend's runtime inference decide.
|
|
13
|
+
let _modalSelectedProviderId = null;
|
|
9
14
|
|
|
10
15
|
// ── Public API ──────────────────────────────────────────────────────────────
|
|
11
16
|
|
|
@@ -52,9 +57,14 @@ const Settings = (() => {
|
|
|
52
57
|
}
|
|
53
58
|
|
|
54
59
|
function _getProviderName(model) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
60
|
+
const p = _findProviderByBaseUrl(model.base_url);
|
|
61
|
+
return p ? p.name : I18n.t("settings.models.provider.custom");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function _findProviderByBaseUrl(baseUrl) {
|
|
65
|
+
if (!baseUrl) return null;
|
|
66
|
+
const url = String(baseUrl).trim().replace(/\/+$/, "");
|
|
67
|
+
return _providers.find(p => {
|
|
58
68
|
const candidates = [p.base_url].concat(
|
|
59
69
|
Array.isArray(p.endpoint_variants) ? p.endpoint_variants.map(v => v.base_url) : []
|
|
60
70
|
).filter(Boolean);
|
|
@@ -62,14 +72,15 @@ const Settings = (() => {
|
|
|
62
72
|
const norm = String(c).replace(/\/+$/, "");
|
|
63
73
|
return url === norm || url.startsWith(norm + "/");
|
|
64
74
|
});
|
|
65
|
-
});
|
|
66
|
-
return provider ? provider.name : I18n.t("settings.models.provider.custom");
|
|
75
|
+
}) || null;
|
|
67
76
|
}
|
|
68
77
|
|
|
69
78
|
function _renderCard(container, model, index) {
|
|
70
79
|
const isDefault = model.type === "default";
|
|
71
80
|
const isLite = model.type === "lite";
|
|
72
|
-
const
|
|
81
|
+
const provider = _findProviderByBaseUrl(model.base_url);
|
|
82
|
+
const providerName = provider ? provider.name : I18n.t("settings.models.provider.custom");
|
|
83
|
+
const websiteUrl = provider && provider.website_url;
|
|
73
84
|
const displayName = model.model || I18n.t("settings.models.unnamed");
|
|
74
85
|
|
|
75
86
|
const card = document.createElement("div");
|
|
@@ -84,7 +95,7 @@ const Settings = (() => {
|
|
|
84
95
|
${isLite ? `<span class="badge badge-lite">${I18n.t("settings.models.badge.lite")}</span>` : ""}
|
|
85
96
|
</div>
|
|
86
97
|
<div class="model-card-grid-provider">${_esc(providerName)}</div>
|
|
87
|
-
${model.
|
|
98
|
+
${model.api_key_masked ? `<div class="model-card-grid-model">${_esc(model.api_key_masked)}</div>` : ""}
|
|
88
99
|
<div class="model-card-grid-status">
|
|
89
100
|
<span class="model-test-result" data-index="${index}"></span>
|
|
90
101
|
</div>
|
|
@@ -107,6 +118,12 @@ const Settings = (() => {
|
|
|
107
118
|
<span>${I18n.t("settings.models.btn.delete")}</span>
|
|
108
119
|
</button>` : ""}
|
|
109
120
|
</div>
|
|
121
|
+
${websiteUrl ? `<div class="model-card-grid-footer">
|
|
122
|
+
<a class="model-card-grid-link" href="${_esc(websiteUrl)}" target="_blank" rel="noopener noreferrer">
|
|
123
|
+
${I18n.t("settings.models.link.topUp")}
|
|
124
|
+
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M7 17L17 7"/><path d="M8 7h9v9"/></svg>
|
|
125
|
+
</a>
|
|
126
|
+
</div>` : ""}
|
|
110
127
|
`;
|
|
111
128
|
|
|
112
129
|
container.appendChild(card);
|
|
@@ -150,7 +167,11 @@ const Settings = (() => {
|
|
|
150
167
|
document.getElementById("model-modal-default-field").style.display = "none";
|
|
151
168
|
|
|
152
169
|
// Set provider dropdown value
|
|
153
|
-
const
|
|
170
|
+
const matched = _findProviderByBaseUrl(model.base_url);
|
|
171
|
+
// Preserve an explicit anthropic_format=true even if base_url is custom:
|
|
172
|
+
// the user may have configured a self-hosted Anthropic-compatible proxy.
|
|
173
|
+
_modalSelectedProviderId = matched ? matched.id : (model.anthropic_format ? "anthropic" : null);
|
|
174
|
+
const providerName = matched ? matched.name : I18n.t("settings.models.provider.custom");
|
|
154
175
|
const providerValue = document.getElementById("model-modal-provider-value");
|
|
155
176
|
providerValue.textContent = providerName;
|
|
156
177
|
providerValue.classList.remove("placeholder");
|
|
@@ -164,6 +185,7 @@ const Settings = (() => {
|
|
|
164
185
|
document.getElementById("model-modal-set-default").checked = true;
|
|
165
186
|
|
|
166
187
|
// Reset provider dropdown
|
|
188
|
+
_modalSelectedProviderId = null;
|
|
167
189
|
const providerValue = document.getElementById("model-modal-provider-value");
|
|
168
190
|
providerValue.textContent = I18n.t("settings.models.placeholder.provider");
|
|
169
191
|
providerValue.classList.add("placeholder");
|
|
@@ -208,6 +230,10 @@ const Settings = (() => {
|
|
|
208
230
|
const value = option.dataset.value;
|
|
209
231
|
const text = option.dataset.label || option.textContent.trim();
|
|
210
232
|
|
|
233
|
+
// Track the picked provider so test/save can flag anthropic_format=true
|
|
234
|
+
// when the user explicitly picks Anthropic. Empty / "custom" → null.
|
|
235
|
+
_modalSelectedProviderId = (value && value !== "custom") ? value : null;
|
|
236
|
+
|
|
211
237
|
const providerValue = document.getElementById("model-modal-provider-value");
|
|
212
238
|
providerValue.textContent = text;
|
|
213
239
|
providerValue.classList.toggle("placeholder", !value);
|
|
@@ -256,15 +282,18 @@ const Settings = (() => {
|
|
|
256
282
|
_showTestResult(index, null, "");
|
|
257
283
|
|
|
258
284
|
try {
|
|
285
|
+
const body = {
|
|
286
|
+
model: model.model,
|
|
287
|
+
base_url: model.base_url,
|
|
288
|
+
api_key: model.api_key_masked,
|
|
289
|
+
index
|
|
290
|
+
};
|
|
291
|
+
if (model.anthropic_format) body.anthropic_format = true;
|
|
292
|
+
|
|
259
293
|
const testRes = await fetch("/api/config/test", {
|
|
260
294
|
method: "POST",
|
|
261
295
|
headers: { "Content-Type": "application/json" },
|
|
262
|
-
body: JSON.stringify(
|
|
263
|
-
model: model.model,
|
|
264
|
-
base_url: model.base_url,
|
|
265
|
-
api_key: model.api_key_masked,
|
|
266
|
-
index
|
|
267
|
-
})
|
|
296
|
+
body: JSON.stringify(body)
|
|
268
297
|
});
|
|
269
298
|
const testData = await testRes.json();
|
|
270
299
|
_showTestResult(index, testData.ok, testData.message);
|
|
@@ -285,15 +314,25 @@ const Settings = (() => {
|
|
|
285
314
|
|
|
286
315
|
saveBtn.disabled = true;
|
|
287
316
|
|
|
317
|
+
// Anthropic protocol is opted in only when the user picks the Anthropic
|
|
318
|
+
// provider in the modal. Other providers leave the flag absent so the
|
|
319
|
+
// backend's runtime inference (provider preset + model api overrides)
|
|
320
|
+
// decides — preserving e.g. OpenRouter's per-model anthropic-messages
|
|
321
|
+
// routing for Claude sub-models.
|
|
322
|
+
const anthropic_format = _modalSelectedProviderId === "anthropic";
|
|
323
|
+
|
|
288
324
|
// Step 1: Test first
|
|
289
325
|
saveBtn.textContent = I18n.t("settings.models.btn.testing");
|
|
290
326
|
_showModalTestResult(null, "");
|
|
291
327
|
|
|
292
328
|
try {
|
|
329
|
+
const testBody = { model, base_url, api_key, index };
|
|
330
|
+
if (anthropic_format) testBody.anthropic_format = true;
|
|
331
|
+
|
|
293
332
|
const testRes = await fetch("/api/config/test", {
|
|
294
333
|
method: "POST",
|
|
295
334
|
headers: { "Content-Type": "application/json" },
|
|
296
|
-
body: JSON.stringify(
|
|
335
|
+
body: JSON.stringify(testBody)
|
|
297
336
|
});
|
|
298
337
|
const testData = await testRes.json();
|
|
299
338
|
_showModalTestResult(testData.ok, testData.message);
|
|
@@ -317,7 +356,7 @@ const Settings = (() => {
|
|
|
317
356
|
const existing = isNew ? {} : (_models[index] || {});
|
|
318
357
|
const hasId = !!existing.id;
|
|
319
358
|
|
|
320
|
-
const payload = { model, base_url, anthropic_format
|
|
359
|
+
const payload = { model, base_url, anthropic_format };
|
|
321
360
|
if (isNew) {
|
|
322
361
|
if (document.getElementById("model-modal-set-default").checked) {
|
|
323
362
|
payload.type = "default";
|
|
@@ -886,7 +925,10 @@ const Settings = (() => {
|
|
|
886
925
|
model: card.querySelector(`[data-key="model"]`).value.trim(),
|
|
887
926
|
base_url: card.querySelector(`[data-key="base_url"]`).value.trim(),
|
|
888
927
|
api_key: card.querySelector(`[data-key="api_key"]`).value.trim(),
|
|
889
|
-
|
|
928
|
+
// The inline card form has no provider picker — preserve whatever the
|
|
929
|
+
// model was saved with. The modal flow is the only place where the
|
|
930
|
+
// user can flip this flag.
|
|
931
|
+
anthropic_format: !!_models[index]?.anthropic_format,
|
|
890
932
|
type: _models[index]?.type ?? null
|
|
891
933
|
};
|
|
892
934
|
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// Workspace panel — lazy file tree for the active session's working directory.
|
|
2
|
+
// Lists one directory level at a time via GET /api/sessions/:id/files,
|
|
3
|
+
// expands/collapses folders in place, and downloads files on click via
|
|
4
|
+
// POST /api/file-action.
|
|
5
|
+
"use strict";
|
|
6
|
+
|
|
7
|
+
const Workspace = (() => {
|
|
8
|
+
const STORAGE_KEY = "clacky.workspace.open";
|
|
9
|
+
|
|
10
|
+
let _sessionId = null;
|
|
11
|
+
let _workingDir = null;
|
|
12
|
+
let _open = false;
|
|
13
|
+
|
|
14
|
+
const $ = (id) => document.getElementById(id);
|
|
15
|
+
const t = (key) => (typeof I18n !== "undefined" ? I18n.t(key) : key);
|
|
16
|
+
|
|
17
|
+
const ICON_FOLDER = '<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="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
|
|
18
|
+
const ICON_FILE = '<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="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>';
|
|
19
|
+
const ICON_CARET = '<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>';
|
|
20
|
+
|
|
21
|
+
function formatSize(bytes) {
|
|
22
|
+
if (bytes == null) return "";
|
|
23
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
24
|
+
const units = ["KB", "MB", "GB", "TB"];
|
|
25
|
+
let n = bytes / 1024, i = 0;
|
|
26
|
+
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
|
|
27
|
+
return `${n < 10 ? n.toFixed(1) : Math.round(n)} ${units[i]}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function fetchEntries(relPath) {
|
|
31
|
+
const url = `/api/sessions/${encodeURIComponent(_sessionId)}/files?path=${encodeURIComponent(relPath || "")}`;
|
|
32
|
+
const resp = await fetch(url);
|
|
33
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
34
|
+
const data = await resp.json();
|
|
35
|
+
return data.entries || [];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function renderEntries(entries) {
|
|
39
|
+
const frag = document.createDocumentFragment();
|
|
40
|
+
if (!entries.length) {
|
|
41
|
+
const empty = document.createElement("div");
|
|
42
|
+
empty.className = "wt-empty";
|
|
43
|
+
empty.textContent = t("workspace.empty");
|
|
44
|
+
frag.appendChild(empty);
|
|
45
|
+
return frag;
|
|
46
|
+
}
|
|
47
|
+
for (const entry of entries) {
|
|
48
|
+
frag.appendChild(buildNode(entry));
|
|
49
|
+
}
|
|
50
|
+
return frag;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function buildNode(entry) {
|
|
54
|
+
const node = document.createElement("div");
|
|
55
|
+
node.className = "wt-node";
|
|
56
|
+
|
|
57
|
+
const row = document.createElement("div");
|
|
58
|
+
row.className = "wt-row";
|
|
59
|
+
row.title = entry.name;
|
|
60
|
+
|
|
61
|
+
const caret = document.createElement("span");
|
|
62
|
+
caret.className = "wt-caret" + (entry.type === "dir" ? "" : " leaf");
|
|
63
|
+
if (entry.type === "dir") caret.innerHTML = ICON_CARET;
|
|
64
|
+
|
|
65
|
+
const icon = document.createElement("span");
|
|
66
|
+
icon.className = "wt-icon";
|
|
67
|
+
icon.innerHTML = entry.type === "dir" ? ICON_FOLDER : ICON_FILE;
|
|
68
|
+
|
|
69
|
+
const name = document.createElement("span");
|
|
70
|
+
name.className = "wt-name";
|
|
71
|
+
name.textContent = entry.name;
|
|
72
|
+
|
|
73
|
+
row.appendChild(caret);
|
|
74
|
+
row.appendChild(icon);
|
|
75
|
+
row.appendChild(name);
|
|
76
|
+
|
|
77
|
+
if (entry.type === "file") {
|
|
78
|
+
const size = document.createElement("span");
|
|
79
|
+
size.className = "wt-size";
|
|
80
|
+
size.textContent = formatSize(entry.size);
|
|
81
|
+
row.appendChild(size);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
node.appendChild(row);
|
|
85
|
+
|
|
86
|
+
if (entry.type === "dir") {
|
|
87
|
+
const children = document.createElement("div");
|
|
88
|
+
children.className = "wt-children";
|
|
89
|
+
children.style.display = "none";
|
|
90
|
+
node.appendChild(children);
|
|
91
|
+
row.addEventListener("click", () => toggleDir(entry, caret, children));
|
|
92
|
+
} else {
|
|
93
|
+
row.addEventListener("click", () => downloadFile(entry));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return node;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function toggleDir(entry, caret, children) {
|
|
100
|
+
const isOpen = caret.classList.contains("open");
|
|
101
|
+
if (isOpen) {
|
|
102
|
+
caret.classList.remove("open");
|
|
103
|
+
children.style.display = "none";
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
caret.classList.add("open");
|
|
107
|
+
children.style.display = "";
|
|
108
|
+
if (children.dataset.loaded === "1") return;
|
|
109
|
+
|
|
110
|
+
children.innerHTML = `<div class="wt-loading">${t("workspace.loading")}</div>`;
|
|
111
|
+
try {
|
|
112
|
+
const entries = await fetchEntries(entry.path);
|
|
113
|
+
children.innerHTML = "";
|
|
114
|
+
children.appendChild(renderEntries(entries));
|
|
115
|
+
children.dataset.loaded = "1";
|
|
116
|
+
} catch (err) {
|
|
117
|
+
console.error("workspace load failed:", err);
|
|
118
|
+
children.innerHTML = `<div class="wt-error">${t("workspace.error")}</div>`;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function downloadFile(entry) {
|
|
123
|
+
const fullPath = _workingDir.replace(/\/+$/, "") + "/" + entry.path;
|
|
124
|
+
try {
|
|
125
|
+
const resp = await fetch("/api/file-action", {
|
|
126
|
+
method: "POST",
|
|
127
|
+
headers: { "Content-Type": "application/json" },
|
|
128
|
+
body: JSON.stringify({ path: fullPath, action: "download" })
|
|
129
|
+
});
|
|
130
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
131
|
+
const blob = await resp.blob();
|
|
132
|
+
const url = URL.createObjectURL(blob);
|
|
133
|
+
const a = document.createElement("a");
|
|
134
|
+
a.href = url;
|
|
135
|
+
a.download = entry.name;
|
|
136
|
+
document.body.appendChild(a);
|
|
137
|
+
a.click();
|
|
138
|
+
a.remove();
|
|
139
|
+
URL.revokeObjectURL(url);
|
|
140
|
+
} catch (err) {
|
|
141
|
+
console.error("download failed:", err);
|
|
142
|
+
if (typeof Modal !== "undefined") Modal.toast(t("workspace.downloadFailed"), "error");
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function loadRoot() {
|
|
147
|
+
const tree = $("workspace-tree");
|
|
148
|
+
if (!tree || !_sessionId) return;
|
|
149
|
+
tree.innerHTML = `<div class="wt-loading">${t("workspace.loading")}</div>`;
|
|
150
|
+
try {
|
|
151
|
+
const entries = await fetchEntries("");
|
|
152
|
+
tree.innerHTML = "";
|
|
153
|
+
tree.appendChild(renderEntries(entries));
|
|
154
|
+
} catch (err) {
|
|
155
|
+
console.error("workspace load failed:", err);
|
|
156
|
+
tree.innerHTML = `<div class="wt-error">${t("workspace.error")}</div>`;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function applyOpenState() {
|
|
161
|
+
const panel = $("workspace-panel");
|
|
162
|
+
const opener = $("btn-workspace-open");
|
|
163
|
+
if (!panel) return;
|
|
164
|
+
const hasSession = !!_sessionId;
|
|
165
|
+
panel.classList.toggle("collapsed", !(_open && hasSession));
|
|
166
|
+
if (opener) opener.style.display = (!_open && hasSession) ? "" : "none";
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function setOpen(open) {
|
|
170
|
+
_open = open;
|
|
171
|
+
try { localStorage.setItem(STORAGE_KEY, open ? "1" : "0"); } catch (_) {}
|
|
172
|
+
applyOpenState();
|
|
173
|
+
if (open) loadRoot();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
init() {
|
|
178
|
+
try { _open = localStorage.getItem(STORAGE_KEY) === "1"; } catch (_) { _open = false; }
|
|
179
|
+
|
|
180
|
+
const close = $("btn-workspace-close");
|
|
181
|
+
const opener = $("btn-workspace-open");
|
|
182
|
+
const refresh = $("btn-workspace-refresh");
|
|
183
|
+
if (close) close.addEventListener("click", () => setOpen(false));
|
|
184
|
+
if (opener) opener.addEventListener("click", () => setOpen(true));
|
|
185
|
+
if (refresh) refresh.addEventListener("click", () => loadRoot());
|
|
186
|
+
|
|
187
|
+
applyOpenState();
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
// Called from Sessions.updateInfoBar whenever the active session changes.
|
|
191
|
+
onSession(session) {
|
|
192
|
+
const newId = session ? session.id : null;
|
|
193
|
+
const newDir = session ? session.working_dir : null;
|
|
194
|
+
const changed = newId !== _sessionId || newDir !== _workingDir;
|
|
195
|
+
_sessionId = newId;
|
|
196
|
+
_workingDir = newDir;
|
|
197
|
+
applyOpenState();
|
|
198
|
+
if (changed && _open && _sessionId) loadRoot();
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
})();
|
|
202
|
+
|
|
203
|
+
document.addEventListener("DOMContentLoaded", () => Workspace.init());
|
|
204
|
+
window.Workspace = Workspace;
|