openclacky 1.2.8 → 1.2.10
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 +35 -0
- data/lib/clacky/agent/llm_caller.rb +3 -0
- data/lib/clacky/agent/message_compressor_helper.rb +6 -5
- data/lib/clacky/agent/session_serializer.rb +4 -0
- data/lib/clacky/agent.rb +9 -0
- data/lib/clacky/agent_config.rb +111 -8
- data/lib/clacky/brand_config.rb +1 -0
- data/lib/clacky/cli.rb +49 -22
- data/lib/clacky/client.rb +6 -2
- data/lib/clacky/default_skills/channel-manager/SKILL.md +33 -110
- data/lib/clacky/default_skills/media-gen/SKILL.md +128 -0
- data/lib/clacky/idle_compression_timer.rb +38 -15
- data/lib/clacky/media/base.rb +68 -0
- data/lib/clacky/media/gemini.rb +36 -0
- data/lib/clacky/media/generator.rb +78 -0
- data/lib/clacky/media/openai_compat.rb +168 -0
- data/lib/clacky/providers.rb +89 -2
- data/lib/clacky/rich_ui_controller.rb +1549 -0
- data/lib/clacky/server/channel/adapters/weixin/adapter.rb +24 -2
- data/lib/clacky/server/channel/channel_manager.rb +89 -2
- data/lib/clacky/server/http_server.rb +334 -29
- data/lib/clacky/session_manager.rb +9 -8
- data/lib/clacky/telemetry.rb +26 -6
- data/lib/clacky/ui2/layout_manager.rb +11 -7
- data/lib/clacky/ui2/ui_controller.rb +2 -2
- data/lib/clacky/ui_interface.rb +1 -1
- data/lib/clacky/utils/model_pricing.rb +75 -53
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +393 -14
- data/lib/clacky/web/billing.js +1 -1
- data/lib/clacky/web/i18n.js +86 -4
- data/lib/clacky/web/index.html +23 -3
- data/lib/clacky/web/model-tester.js +58 -0
- data/lib/clacky/web/onboard.js +17 -30
- data/lib/clacky/web/sessions.js +443 -2
- data/lib/clacky/web/settings.js +372 -97
- data/lib/clacky/web/workspace.js +9 -1
- data/lib/clacky.rb +3 -0
- data/scripts/build/lib/network.sh +61 -30
- data/scripts/install.ps1 +16 -4
- data/scripts/install.sh +61 -30
- data/scripts/install_browser.sh +61 -30
- data/scripts/install_full.sh +61 -30
- data/scripts/install_rails_deps.sh +61 -30
- data/scripts/install_system_deps.sh +61 -30
- metadata +12 -3
- data/lib/clacky/default_skills/channel-manager/feishu_setup.rb +0 -574
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Shared helpers for the model config UI flows.
|
|
2
|
+
// Used by both the onboarding wizard and the settings model modal.
|
|
3
|
+
window.ModelTester = (function () {
|
|
4
|
+
// Test a model connection.
|
|
5
|
+
// Returns one of:
|
|
6
|
+
// { ok: true, base_url, message } — connected, no rewrite
|
|
7
|
+
// { ok: true, base_url, message, rewrote: true } — connected, base_url auto-corrected (/v1 appended)
|
|
8
|
+
// { ok: false, message } — failed (server-reported or network)
|
|
9
|
+
async function testConnection({ model, base_url, api_key, anthropic_format, index } = {}) {
|
|
10
|
+
const body = { model, base_url, api_key };
|
|
11
|
+
if (typeof index === "number") body.index = index;
|
|
12
|
+
if (anthropic_format) body.anthropic_format = true;
|
|
13
|
+
|
|
14
|
+
let data;
|
|
15
|
+
try {
|
|
16
|
+
const res = await fetch("/api/config/test", {
|
|
17
|
+
method: "POST",
|
|
18
|
+
headers: { "Content-Type": "application/json" },
|
|
19
|
+
body: JSON.stringify(body)
|
|
20
|
+
});
|
|
21
|
+
data = await res.json();
|
|
22
|
+
} catch (e) {
|
|
23
|
+
return { ok: false, message: e.message };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (!data.ok) return { ok: false, message: data.message || "" };
|
|
27
|
+
|
|
28
|
+
if (data.effective_base_url && data.effective_base_url !== base_url) {
|
|
29
|
+
return { ok: true, base_url: data.effective_base_url, message: data.message || "", rewrote: true };
|
|
30
|
+
}
|
|
31
|
+
return { ok: true, base_url, message: data.message || "" };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Persist a model config (create or update).
|
|
35
|
+
// existingId === null/undefined → POST /api/config/models (create).
|
|
36
|
+
// existingId === string → PATCH /api/config/models/:id (update).
|
|
37
|
+
// Returns { ok: bool, error? }.
|
|
38
|
+
async function saveModel(payload, { existingId } = {}) {
|
|
39
|
+
const url = existingId
|
|
40
|
+
? `/api/config/models/${encodeURIComponent(existingId)}`
|
|
41
|
+
: "/api/config/models";
|
|
42
|
+
const method = existingId ? "PATCH" : "POST";
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const res = await fetch(url, {
|
|
46
|
+
method,
|
|
47
|
+
headers: { "Content-Type": "application/json" },
|
|
48
|
+
body: JSON.stringify(payload)
|
|
49
|
+
});
|
|
50
|
+
const data = await res.json();
|
|
51
|
+
return data.ok ? { ok: true } : { ok: false, error: data.error || "" };
|
|
52
|
+
} catch (e) {
|
|
53
|
+
return { ok: false, error: e.message };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { testConnection, saveModel };
|
|
58
|
+
})();
|
data/lib/clacky/web/onboard.js
CHANGED
|
@@ -417,43 +417,30 @@ const Onboard = (() => {
|
|
|
417
417
|
_setResult(null, "");
|
|
418
418
|
|
|
419
419
|
// Step 1: test connection
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
const data = await res.json();
|
|
427
|
-
if (!data.ok) {
|
|
428
|
-
_setResult(false, data.message || (zh ? "连接失败。" : "Connection failed."));
|
|
429
|
-
btn.disabled = false;
|
|
430
|
-
btn.textContent = I18n.t("onboard.key.btn.test");
|
|
431
|
-
return;
|
|
432
|
-
}
|
|
433
|
-
} catch (e) {
|
|
434
|
-
_setResult(false, e.message);
|
|
420
|
+
const testResult = await ModelTester.testConnection({
|
|
421
|
+
model, base_url: baseUrl, api_key: apiKey, index: 0
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
if (!testResult.ok) {
|
|
425
|
+
_setResult(false, testResult.message || (zh ? "连接失败。" : "Connection failed."));
|
|
435
426
|
btn.disabled = false;
|
|
436
427
|
btn.textContent = I18n.t("onboard.key.btn.test");
|
|
437
428
|
return;
|
|
438
429
|
}
|
|
439
430
|
|
|
431
|
+
let effectiveBaseUrl = testResult.base_url;
|
|
432
|
+
if (testResult.rewrote) {
|
|
433
|
+
const baseInput = document.getElementById("setup-base-url");
|
|
434
|
+
if (baseInput) baseInput.value = effectiveBaseUrl;
|
|
435
|
+
}
|
|
436
|
+
|
|
440
437
|
// Step 2: save config
|
|
441
438
|
btn.textContent = I18n.t("onboard.key.saving");
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
});
|
|
448
|
-
const data = await res.json();
|
|
449
|
-
if (!data.ok) {
|
|
450
|
-
_setResult(false, data.error || (zh ? "保存失败。" : "Save failed."));
|
|
451
|
-
btn.disabled = false;
|
|
452
|
-
btn.textContent = I18n.t("onboard.key.btn.test");
|
|
453
|
-
return;
|
|
454
|
-
}
|
|
455
|
-
} catch (e) {
|
|
456
|
-
_setResult(false, e.message);
|
|
439
|
+
const saveResult = await ModelTester.saveModel({
|
|
440
|
+
type: "default", model, base_url: effectiveBaseUrl, api_key: apiKey
|
|
441
|
+
});
|
|
442
|
+
if (!saveResult.ok) {
|
|
443
|
+
_setResult(false, saveResult.error || (zh ? "保存失败。" : "Save failed."));
|
|
457
444
|
btn.disabled = false;
|
|
458
445
|
btn.textContent = I18n.t("onboard.key.btn.test");
|
|
459
446
|
return;
|
data/lib/clacky/web/sessions.js
CHANGED
|
@@ -3804,6 +3804,448 @@ const Sessions = (() => {
|
|
|
3804
3804
|
|
|
3805
3805
|
// ── Session Info Bar Working Directory Switcher ───────────────────────────
|
|
3806
3806
|
(function() {
|
|
3807
|
+
// Directory picker with predefined list
|
|
3808
|
+
// ── Tree-based directory picker ─────────────────────────────────────────
|
|
3809
|
+
const ICON_FOLDER_SVG = '<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>';
|
|
3810
|
+
const ICON_CARET_SVG = '<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>';
|
|
3811
|
+
function showDirectoryPicker(currentDir, sessionId) {
|
|
3812
|
+
return new Promise((resolve) => {
|
|
3813
|
+
const t = (key, fallback) => {
|
|
3814
|
+
const s = I18n.t(key);
|
|
3815
|
+
return (s && s !== key) ? s : fallback;
|
|
3816
|
+
};
|
|
3817
|
+
|
|
3818
|
+
let selectedPath = currentDir;
|
|
3819
|
+
let rootDir = ""; // absolute path of the session's working directory
|
|
3820
|
+
|
|
3821
|
+
// Fetch directory entries from API, returns dirs with absolute paths
|
|
3822
|
+
async function fetchDirs(relPath, absolute = false) {
|
|
3823
|
+
let url = `/api/sessions/${encodeURIComponent(sessionId)}/files?path=${encodeURIComponent(relPath || "")}`;
|
|
3824
|
+
if (absolute) url += "&absolute=true";
|
|
3825
|
+
const resp = await fetch(url);
|
|
3826
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
3827
|
+
const data = await resp.json();
|
|
3828
|
+
// Only update rootDir in relative mode; absolute mode would overwrite it with "/"
|
|
3829
|
+
if (!absolute) rootDir = data.root || rootDir;
|
|
3830
|
+
const dirs = (data.entries || []).filter(e => e.type === "dir");
|
|
3831
|
+
// Convert relative paths to absolute
|
|
3832
|
+
dirs.forEach(d => {
|
|
3833
|
+
// Strip leading slashes from path to avoid double slashes
|
|
3834
|
+
const cleanPath = d.path.replace(/^\/+/, "");
|
|
3835
|
+
d.absPath = absolute ? ("/" + cleanPath) : (rootDir.replace(/\/+$/, "") + "/" + cleanPath);
|
|
3836
|
+
d.absolute = absolute; // Store absolute flag for child expansion
|
|
3837
|
+
}); return dirs;
|
|
3838
|
+
}
|
|
3839
|
+
|
|
3840
|
+
// Build a tree node for a directory entry
|
|
3841
|
+
function buildDirNode(entry, depth) {
|
|
3842
|
+
const node = document.createElement("div");
|
|
3843
|
+
node.className = "dp-node";
|
|
3844
|
+
node.dataset.depth = depth; // Store depth for child expansion
|
|
3845
|
+
|
|
3846
|
+
const row = document.createElement("div");
|
|
3847
|
+
row.className = "dp-row";
|
|
3848
|
+
row.style.paddingLeft = `${depth * 16 + 8}px`;
|
|
3849
|
+
const caret = document.createElement("span");
|
|
3850
|
+
caret.className = "dp-caret";
|
|
3851
|
+
caret.innerHTML = ICON_CARET_SVG;
|
|
3852
|
+
|
|
3853
|
+
const icon = document.createElement("span");
|
|
3854
|
+
icon.className = "dp-icon";
|
|
3855
|
+
icon.innerHTML = ICON_FOLDER_SVG;
|
|
3856
|
+
|
|
3857
|
+
const name = document.createElement("span");
|
|
3858
|
+
name.className = "dp-name";
|
|
3859
|
+
name.textContent = entry.name;
|
|
3860
|
+
|
|
3861
|
+
row.appendChild(caret);
|
|
3862
|
+
row.appendChild(icon);
|
|
3863
|
+
row.appendChild(name);
|
|
3864
|
+
node.appendChild(row);
|
|
3865
|
+
|
|
3866
|
+
const children = document.createElement("div");
|
|
3867
|
+
children.className = "dp-children";
|
|
3868
|
+
children.style.display = "none";
|
|
3869
|
+
node.appendChild(children);
|
|
3870
|
+
|
|
3871
|
+
// Single-click: select directory (show path) + expand/collapse
|
|
3872
|
+
let clickTimer = null;
|
|
3873
|
+
row.addEventListener("click", (e) => {
|
|
3874
|
+
e.stopPropagation();
|
|
3875
|
+
if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; }
|
|
3876
|
+
clickTimer = setTimeout(() => {
|
|
3877
|
+
clickTimer = null;
|
|
3878
|
+
// Select this directory
|
|
3879
|
+
modal.querySelectorAll(".dp-row.selected").forEach(el => el.classList.remove("selected"));
|
|
3880
|
+
row.classList.add("selected");
|
|
3881
|
+
selectedPath = entry.absPath;
|
|
3882
|
+
pathInput.value = entry.absPath;
|
|
3883
|
+
// Also expand/collapse
|
|
3884
|
+
toggleExpand(entry, caret, children);
|
|
3885
|
+
}, 250);
|
|
3886
|
+
});
|
|
3887
|
+
|
|
3888
|
+
// Double-click: enter directory (navigate into it)
|
|
3889
|
+
row.addEventListener("dblclick", (e) => {
|
|
3890
|
+
e.stopPropagation();
|
|
3891
|
+
if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; }
|
|
3892
|
+
// Enter this directory - reload tree with this as root
|
|
3893
|
+
loadTreeForPath(entry.absPath, entry.absolute);
|
|
3894
|
+
});
|
|
3895
|
+
return node;
|
|
3896
|
+
}
|
|
3897
|
+
|
|
3898
|
+
async function toggleExpand(entry, caret, children) {
|
|
3899
|
+
const isOpen = caret.classList.contains("open");
|
|
3900
|
+
if (isOpen) {
|
|
3901
|
+
caret.classList.remove("open");
|
|
3902
|
+
children.style.display = "none";
|
|
3903
|
+
return;
|
|
3904
|
+
}
|
|
3905
|
+
caret.classList.add("open");
|
|
3906
|
+
children.style.display = "flex";
|
|
3907
|
+
if (children.dataset.loaded === "1") return;
|
|
3908
|
+
children.innerHTML = `<div class="dp-loading">${t("sib.dir.loading", "加载中...")}</div>`;
|
|
3909
|
+
try {
|
|
3910
|
+
const dirs = await fetchDirs(entry.path, entry.absolute);
|
|
3911
|
+
children.innerHTML = "";
|
|
3912
|
+
if (dirs.length === 0) {
|
|
3913
|
+
children.innerHTML = `<div class="dp-empty">${t("sib.dir.empty", "空目录")}</div>`;
|
|
3914
|
+
} else {
|
|
3915
|
+
const parentNode = children.parentElement;
|
|
3916
|
+
const parentDepth = parseInt(parentNode?.dataset?.depth) || 0;
|
|
3917
|
+
const childDepth = parentDepth + 1;
|
|
3918
|
+
const frag = document.createDocumentFragment();
|
|
3919
|
+
dirs.forEach(d => frag.appendChild(buildDirNode(d, childDepth)));
|
|
3920
|
+
children.appendChild(frag);
|
|
3921
|
+
} children.dataset.loaded = "1";
|
|
3922
|
+
} catch (err) {
|
|
3923
|
+
console.error("dir picker load failed:", err);
|
|
3924
|
+
children.innerHTML = `<div class="dp-error">${t("sib.dir.loadError", "加载失败")}</div>`;
|
|
3925
|
+
}
|
|
3926
|
+
}
|
|
3927
|
+
|
|
3928
|
+
// Create modal overlay
|
|
3929
|
+
const overlay = document.createElement("div");
|
|
3930
|
+
overlay.className = "modal-overlay";
|
|
3931
|
+
|
|
3932
|
+
// Create modal content
|
|
3933
|
+
const modal = document.createElement("div");
|
|
3934
|
+
modal.className = "modal-content";
|
|
3935
|
+
modal.style.maxWidth = "520px";
|
|
3936
|
+
modal.style.maxHeight = "80vh";
|
|
3937
|
+
modal.style.display = "flex";
|
|
3938
|
+
modal.style.flexDirection = "column";
|
|
3939
|
+
|
|
3940
|
+
// Title
|
|
3941
|
+
const title = document.createElement("div");
|
|
3942
|
+
title.className = "modal-title";
|
|
3943
|
+
title.textContent = t("sib.dir.changePrompt", "切换工作目录");
|
|
3944
|
+
modal.appendChild(title);
|
|
3945
|
+
|
|
3946
|
+
// Modal body
|
|
3947
|
+
const body = document.createElement("div");
|
|
3948
|
+
body.className = "modal-body";
|
|
3949
|
+
body.style.flex = "1";
|
|
3950
|
+
body.style.overflow = "hidden";
|
|
3951
|
+
body.style.display = "flex";
|
|
3952
|
+
body.style.flexDirection = "column";
|
|
3953
|
+
body.style.gap = "8px";
|
|
3954
|
+
|
|
3955
|
+
// Quick presets (will be populated with absolute paths after first API call)
|
|
3956
|
+
const presets = document.createElement("div");
|
|
3957
|
+
presets.className = "dp-presets";
|
|
3958
|
+
body.appendChild(presets);
|
|
3959
|
+
|
|
3960
|
+
function setupPresets() {
|
|
3961
|
+
presets.innerHTML = "";
|
|
3962
|
+
const presetDirs = [
|
|
3963
|
+
{ value: rootDir, text: t("sib.dir.current", "当前工作目录"), absolute: false },
|
|
3964
|
+
{ value: "/", text: t("sib.dir.root", "根目录"), absolute: true }
|
|
3965
|
+
]; presetDirs.forEach(p => {
|
|
3966
|
+
const btn = document.createElement("button");
|
|
3967
|
+
btn.className = "btn btn-secondary btn-sm";
|
|
3968
|
+
btn.textContent = p.text;
|
|
3969
|
+
btn.addEventListener("click", () => {
|
|
3970
|
+
selectedPath = p.value;
|
|
3971
|
+
pathInput.value = p.value;
|
|
3972
|
+
modal.querySelectorAll(".dp-row.selected").forEach(el => el.classList.remove("selected"));
|
|
3973
|
+
loadTreeForPath(p.value, p.absolute);
|
|
3974
|
+
});
|
|
3975
|
+
presets.appendChild(btn);
|
|
3976
|
+
});
|
|
3977
|
+
}
|
|
3978
|
+
|
|
3979
|
+
// Path input
|
|
3980
|
+
const pathContainer = document.createElement("div");
|
|
3981
|
+
pathContainer.className = "dp-path-container";
|
|
3982
|
+
const pathInput = document.createElement("input");
|
|
3983
|
+
pathInput.type = "text";
|
|
3984
|
+
pathInput.className = "dir-picker-input";
|
|
3985
|
+
pathInput.value = currentDir;
|
|
3986
|
+
pathInput.placeholder = t("sib.dir.inputPlaceholder", "输入或选择目录路径");
|
|
3987
|
+
pathContainer.appendChild(pathInput);
|
|
3988
|
+
|
|
3989
|
+
// Autocomplete dropdown
|
|
3990
|
+
const autocomplete = document.createElement("div");
|
|
3991
|
+
autocomplete.className = "dp-autocomplete";
|
|
3992
|
+
autocomplete.style.display = "none";
|
|
3993
|
+
pathContainer.appendChild(autocomplete);
|
|
3994
|
+
|
|
3995
|
+
body.appendChild(pathContainer);
|
|
3996
|
+
// Tree container
|
|
3997
|
+
const treeContainer = document.createElement("div");
|
|
3998
|
+
treeContainer.className = "dp-tree";
|
|
3999
|
+
treeContainer.style.flex = "1";
|
|
4000
|
+
treeContainer.style.overflow = "auto";
|
|
4001
|
+
treeContainer.innerHTML = `<div class="dp-loading">${t("sib.dir.loading", "加载中...")}</div>`;
|
|
4002
|
+
body.appendChild(treeContainer);
|
|
4003
|
+
|
|
4004
|
+
modal.appendChild(body);
|
|
4005
|
+
|
|
4006
|
+
// Buttons
|
|
4007
|
+
const buttonContainer = document.createElement("div");
|
|
4008
|
+
buttonContainer.className = "modal-buttons";
|
|
4009
|
+
|
|
4010
|
+
const cancelButton = document.createElement("button");
|
|
4011
|
+
cancelButton.className = "btn btn-secondary";
|
|
4012
|
+
cancelButton.textContent = t("sib.dir.cancel", "取消");
|
|
4013
|
+
cancelButton.onclick = () => {
|
|
4014
|
+
overlay.remove();
|
|
4015
|
+
resolve(null);
|
|
4016
|
+
};
|
|
4017
|
+
|
|
4018
|
+
const confirmButton = document.createElement("button");
|
|
4019
|
+
confirmButton.className = "btn btn-primary";
|
|
4020
|
+
confirmButton.textContent = t("sib.dir.confirm", "确认");
|
|
4021
|
+
confirmButton.onclick = () => {
|
|
4022
|
+
const dir = pathInput.value.trim();
|
|
4023
|
+
overlay.remove();
|
|
4024
|
+
resolve(dir || null);
|
|
4025
|
+
};
|
|
4026
|
+
|
|
4027
|
+
buttonContainer.appendChild(cancelButton);
|
|
4028
|
+
buttonContainer.appendChild(confirmButton);
|
|
4029
|
+
modal.appendChild(buttonContainer);
|
|
4030
|
+
|
|
4031
|
+
overlay.appendChild(modal);
|
|
4032
|
+
document.body.appendChild(overlay);
|
|
4033
|
+
|
|
4034
|
+
// Sync pathInput changes to selectedPath
|
|
4035
|
+
pathInput.addEventListener("input", () => {
|
|
4036
|
+
selectedPath = pathInput.value;
|
|
4037
|
+
modal.querySelectorAll(".dp-row.selected").forEach(el => el.classList.remove("selected"));
|
|
4038
|
+
});
|
|
4039
|
+
|
|
4040
|
+
// Load tree for a given path
|
|
4041
|
+
async function loadTreeForPath(dirPath, absolute = false) {
|
|
4042
|
+
treeContainer.innerHTML = `<div class="dp-loading">${t("sib.dir.loading", "加载中...")}</div>`;
|
|
4043
|
+
// Auto-detect absolute mode: path is absolute and outside working directory
|
|
4044
|
+
const useAbsolute = absolute || (dirPath.startsWith("/") && (!rootDir || !dirPath.startsWith(rootDir)));
|
|
4045
|
+
// Convert absolute path to relative path for API
|
|
4046
|
+
let relPath = dirPath;
|
|
4047
|
+
if (!useAbsolute && rootDir && dirPath.startsWith(rootDir)) {
|
|
4048
|
+
relPath = dirPath.substring(rootDir.length).replace(/^\/+/, "");
|
|
4049
|
+
}
|
|
4050
|
+
try {
|
|
4051
|
+
const dirs = await fetchDirs(relPath, useAbsolute);
|
|
4052
|
+
// Update presets and pathInput with absolute paths after first API call
|
|
4053
|
+
if (rootDir && presets.children.length === 0) {
|
|
4054
|
+
setupPresets();
|
|
4055
|
+
// Update pathInput to show absolute path
|
|
4056
|
+
if (rootDir !== currentDir && !currentDir.startsWith(rootDir)) {
|
|
4057
|
+
pathInput.value = rootDir;
|
|
4058
|
+
selectedPath = rootDir;
|
|
4059
|
+
}
|
|
4060
|
+
}
|
|
4061
|
+
treeContainer.innerHTML = "";
|
|
4062
|
+
if (dirs.length === 0) {
|
|
4063
|
+
treeContainer.innerHTML = `<div class="dp-empty">${t("sib.dir.empty", "空目录")}</div>`;
|
|
4064
|
+
} else {
|
|
4065
|
+
const frag = document.createDocumentFragment();
|
|
4066
|
+
dirs.forEach(d => frag.appendChild(buildDirNode(d, 0)));
|
|
4067
|
+
treeContainer.appendChild(frag);
|
|
4068
|
+
}
|
|
4069
|
+
} catch (err) {
|
|
4070
|
+
console.error("dir picker load failed:", err);
|
|
4071
|
+
treeContainer.innerHTML = `<div class="dp-error">${t("sib.dir.loadError", "加载失败")}</div>`;
|
|
4072
|
+
}
|
|
4073
|
+
}
|
|
4074
|
+
loadTreeForPath("");
|
|
4075
|
+
|
|
4076
|
+
// ── Autocomplete logic ──────────────────────────────────────────────
|
|
4077
|
+
let autocompleteTimer = null;
|
|
4078
|
+
let activeIndex = -1;
|
|
4079
|
+
|
|
4080
|
+
function hideAutocomplete() {
|
|
4081
|
+
autocomplete.style.display = "none";
|
|
4082
|
+
autocomplete.innerHTML = "";
|
|
4083
|
+
activeIndex = -1;
|
|
4084
|
+
}
|
|
4085
|
+
|
|
4086
|
+
function showAutocomplete(items) {
|
|
4087
|
+
autocomplete.innerHTML = "";
|
|
4088
|
+
if (!items.length) { hideAutocomplete(); return; }
|
|
4089
|
+
|
|
4090
|
+
items.forEach((item, i) => {
|
|
4091
|
+
const row = document.createElement("div");
|
|
4092
|
+
row.className = "dp-ac-item";
|
|
4093
|
+
if (i === activeIndex) row.classList.add("active");
|
|
4094
|
+
|
|
4095
|
+
const icon = document.createElement("span");
|
|
4096
|
+
icon.className = "dp-ac-icon";
|
|
4097
|
+
icon.innerHTML = ICON_FOLDER_SVG;
|
|
4098
|
+
|
|
4099
|
+
const name = document.createElement("span");
|
|
4100
|
+
name.className = "dp-ac-name";
|
|
4101
|
+
name.textContent = item.name;
|
|
4102
|
+
|
|
4103
|
+
row.appendChild(icon);
|
|
4104
|
+
row.appendChild(name);
|
|
4105
|
+
|
|
4106
|
+
row.addEventListener("mousedown", (e) => {
|
|
4107
|
+
e.preventDefault(); // prevent blur
|
|
4108
|
+
// Construct full path: keep parent path from input, append selected item name
|
|
4109
|
+
const inputVal = pathInput.value;
|
|
4110
|
+
const lastSlash = inputVal.lastIndexOf("/");
|
|
4111
|
+
const fullPath = lastSlash >= 0
|
|
4112
|
+
? inputVal.substring(0, lastSlash + 1) + item.name
|
|
4113
|
+
: item.name;
|
|
4114
|
+
pathInput.value = fullPath;
|
|
4115
|
+
selectedPath = fullPath;
|
|
4116
|
+
hideAutocomplete();
|
|
4117
|
+
loadTreeForPath(fullPath);
|
|
4118
|
+
});
|
|
4119
|
+
row.addEventListener("mouseenter", () => {
|
|
4120
|
+
activeIndex = i;
|
|
4121
|
+
autocomplete.querySelectorAll(".dp-ac-item").forEach((el, j) => {
|
|
4122
|
+
el.classList.toggle("active", j === i);
|
|
4123
|
+
});
|
|
4124
|
+
});
|
|
4125
|
+
|
|
4126
|
+
autocomplete.appendChild(row);
|
|
4127
|
+
});
|
|
4128
|
+
autocomplete.style.display = "";
|
|
4129
|
+
}
|
|
4130
|
+
|
|
4131
|
+
async function fetchSuggestions(inputVal) {
|
|
4132
|
+
if (!inputVal) { hideAutocomplete(); return; }
|
|
4133
|
+
|
|
4134
|
+
// Determine parent dir and prefix
|
|
4135
|
+
const lastSlash = inputVal.lastIndexOf("/");
|
|
4136
|
+
let parentPath, prefix;
|
|
4137
|
+
if (lastSlash > 0) {
|
|
4138
|
+
parentPath = inputVal.substring(0, lastSlash);
|
|
4139
|
+
prefix = inputVal.substring(lastSlash + 1).toLowerCase();
|
|
4140
|
+
} else if (lastSlash === 0) {
|
|
4141
|
+
parentPath = "/";
|
|
4142
|
+
prefix = inputVal.substring(1).toLowerCase();
|
|
4143
|
+
} else {
|
|
4144
|
+
parentPath = "";
|
|
4145
|
+
prefix = inputVal.toLowerCase();
|
|
4146
|
+
}
|
|
4147
|
+
|
|
4148
|
+
// Determine if we need absolute mode (path outside working directory)
|
|
4149
|
+
const isAbsolute = parentPath.startsWith("/") && (!rootDir || !parentPath.startsWith(rootDir));
|
|
4150
|
+
let relPath = parentPath;
|
|
4151
|
+
if (!isAbsolute && rootDir && parentPath.startsWith(rootDir)) {
|
|
4152
|
+
relPath = parentPath.substring(rootDir.length).replace(/^\/+/, "");
|
|
4153
|
+
}
|
|
4154
|
+
|
|
4155
|
+
try {
|
|
4156
|
+
const dirs = await fetchDirs(relPath, isAbsolute);
|
|
4157
|
+
const filtered = prefix
|
|
4158
|
+
? dirs.filter(d => d.name.toLowerCase().startsWith(prefix))
|
|
4159
|
+
: dirs;
|
|
4160
|
+
showAutocomplete(filtered.slice(0, 15)); // limit to 15 items
|
|
4161
|
+
} catch (_) {
|
|
4162
|
+
hideAutocomplete();
|
|
4163
|
+
}
|
|
4164
|
+
}
|
|
4165
|
+
|
|
4166
|
+
pathInput.addEventListener("input", () => {
|
|
4167
|
+
selectedPath = pathInput.value;
|
|
4168
|
+
modal.querySelectorAll(".dp-row.selected").forEach(el => el.classList.remove("selected"));
|
|
4169
|
+
clearTimeout(autocompleteTimer);
|
|
4170
|
+
autocompleteTimer = setTimeout(() => fetchSuggestions(pathInput.value.trim()), 200);
|
|
4171
|
+
});
|
|
4172
|
+
|
|
4173
|
+
pathInput.addEventListener("blur", () => {
|
|
4174
|
+
// Delay to allow mousedown on suggestion
|
|
4175
|
+
setTimeout(hideAutocomplete, 150);
|
|
4176
|
+
});
|
|
4177
|
+
|
|
4178
|
+
pathInput.addEventListener("focus", () => {
|
|
4179
|
+
if (pathInput.value.trim()) fetchSuggestions(pathInput.value.trim());
|
|
4180
|
+
});
|
|
4181
|
+
|
|
4182
|
+
// Keyboard navigation in autocomplete
|
|
4183
|
+
pathInput.addEventListener("keydown", (e) => {
|
|
4184
|
+
const items = autocomplete.querySelectorAll(".dp-ac-item");
|
|
4185
|
+
if (!items.length || autocomplete.style.display === "none") {
|
|
4186
|
+
if (e.key === "Enter") {
|
|
4187
|
+
e.preventDefault();
|
|
4188
|
+
// Navigate to typed path without closing modal
|
|
4189
|
+
const dir = pathInput.value.trim();
|
|
4190
|
+
if (dir) {
|
|
4191
|
+
selectedPath = dir;
|
|
4192
|
+
hideAutocomplete();
|
|
4193
|
+
loadTreeForPath(dir);
|
|
4194
|
+
}
|
|
4195
|
+
}
|
|
4196
|
+
return;
|
|
4197
|
+
}
|
|
4198
|
+
|
|
4199
|
+
if (e.key === "ArrowDown") {
|
|
4200
|
+
e.preventDefault();
|
|
4201
|
+
activeIndex = Math.min(activeIndex + 1, items.length - 1);
|
|
4202
|
+
items.forEach((el, i) => el.classList.toggle("active", i === activeIndex));
|
|
4203
|
+
items[activeIndex]?.scrollIntoView({ block: "nearest" });
|
|
4204
|
+
} else if (e.key === "ArrowUp") {
|
|
4205
|
+
e.preventDefault();
|
|
4206
|
+
activeIndex = Math.max(activeIndex - 1, 0);
|
|
4207
|
+
items.forEach((el, i) => el.classList.toggle("active", i === activeIndex));
|
|
4208
|
+
items[activeIndex]?.scrollIntoView({ block: "nearest" });
|
|
4209
|
+
} else if (e.key === "Enter") {
|
|
4210
|
+
e.preventDefault();
|
|
4211
|
+
if (activeIndex >= 0 && items[activeIndex]) {
|
|
4212
|
+
// Select the highlighted suggestion
|
|
4213
|
+
const evt = new MouseEvent("mousedown", { bubbles: true });
|
|
4214
|
+
items[activeIndex].dispatchEvent(evt);
|
|
4215
|
+
} else {
|
|
4216
|
+
// Navigate to typed path without closing modal
|
|
4217
|
+
const dir = pathInput.value.trim();
|
|
4218
|
+
if (dir) {
|
|
4219
|
+
selectedPath = dir;
|
|
4220
|
+
hideAutocomplete();
|
|
4221
|
+
loadTreeForPath(dir);
|
|
4222
|
+
}
|
|
4223
|
+
}
|
|
4224
|
+
} else if (e.key === "Escape") {
|
|
4225
|
+
hideAutocomplete();
|
|
4226
|
+
}
|
|
4227
|
+
});
|
|
4228
|
+
|
|
4229
|
+
overlay.addEventListener("keydown", (e) => {
|
|
4230
|
+
if (e.key === "Escape") {
|
|
4231
|
+
if (autocomplete.style.display !== "none") {
|
|
4232
|
+
hideAutocomplete();
|
|
4233
|
+
} else {
|
|
4234
|
+
cancelButton.click();
|
|
4235
|
+
}
|
|
4236
|
+
}
|
|
4237
|
+
});
|
|
4238
|
+
|
|
4239
|
+
overlay.addEventListener("click", (e) => {
|
|
4240
|
+
if (e.target === overlay) cancelButton.click();
|
|
4241
|
+
});
|
|
4242
|
+
|
|
4243
|
+
// Focus path input
|
|
4244
|
+
pathInput.focus();
|
|
4245
|
+
pathInput.select();
|
|
4246
|
+
});
|
|
4247
|
+
}
|
|
4248
|
+
|
|
3807
4249
|
// Handle click on working directory
|
|
3808
4250
|
document.addEventListener("click", async (e) => {
|
|
3809
4251
|
const dirEl = e.target.closest("#sib-dir");
|
|
@@ -3812,12 +4254,11 @@ const Sessions = (() => {
|
|
|
3812
4254
|
const sessionId = dirEl.dataset.sessionId;
|
|
3813
4255
|
const currentDir = dirEl.dataset.workingDir || dirEl.textContent;
|
|
3814
4256
|
|
|
3815
|
-
const newDir = await
|
|
4257
|
+
const newDir = await showDirectoryPicker(currentDir, sessionId);
|
|
3816
4258
|
if (newDir && newDir !== currentDir) {
|
|
3817
4259
|
_changeWorkingDirectory(sessionId, newDir);
|
|
3818
4260
|
}
|
|
3819
4261
|
}
|
|
3820
|
-
|
|
3821
4262
|
// Handle click on session ID — toggles a small actions dropdown with
|
|
3822
4263
|
// items like "Download session files (for debugging)". Designed to be
|
|
3823
4264
|
// extensible (more session-level actions can be added here later).
|