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/settings.js
CHANGED
|
@@ -110,8 +110,17 @@ const Settings = (() => {
|
|
|
110
110
|
</label>
|
|
111
111
|
<label class="model-field">
|
|
112
112
|
<span class="field-label">${I18n.t("settings.models.field.baseurl")}</span>
|
|
113
|
-
<
|
|
114
|
-
|
|
113
|
+
<div class="base-url-combobox" data-index="${index}">
|
|
114
|
+
<input type="text" class="field-input base-url-input" data-key="base_url" data-index="${index}"
|
|
115
|
+
placeholder="${I18n.t("settings.models.placeholder.baseurl")}" value="${_esc(model.base_url)}"
|
|
116
|
+
autocomplete="off">
|
|
117
|
+
<button class="base-url-dropdown-btn" type="button" title="Select preset endpoint">
|
|
118
|
+
<svg width="12" height="12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
119
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
|
120
|
+
</svg>
|
|
121
|
+
</button>
|
|
122
|
+
<div class="base-url-dropdown" style="display:none"></div>
|
|
123
|
+
</div>
|
|
115
124
|
</label>
|
|
116
125
|
<label class="model-field">
|
|
117
126
|
<span class="field-label">
|
|
@@ -267,10 +276,21 @@ const Settings = (() => {
|
|
|
267
276
|
// Build model list from current base_url's provider
|
|
268
277
|
const _updateModelDropdown = () => {
|
|
269
278
|
const baseUrlInput = card.querySelector(`[data-key="base_url"]`);
|
|
270
|
-
const baseUrl = baseUrlInput ? baseUrlInput.value.trim() : "";
|
|
271
|
-
|
|
272
|
-
// Find provider by base_url
|
|
273
|
-
|
|
279
|
+
const baseUrl = baseUrlInput ? baseUrlInput.value.trim().replace(/\/+$/, "") : "";
|
|
280
|
+
|
|
281
|
+
// Find provider by matching base_url against BOTH the canonical
|
|
282
|
+
// preset.base_url AND every endpoint_variants[].base_url — otherwise
|
|
283
|
+
// picking e.g. GLM's Coding-Plan variant would wipe the model list
|
|
284
|
+
// because only the canonical URL would match.
|
|
285
|
+
const provider = _providers.find(p => {
|
|
286
|
+
const candidates = [p.base_url].concat(
|
|
287
|
+
Array.isArray(p.endpoint_variants) ? p.endpoint_variants.map(v => v.base_url) : []
|
|
288
|
+
).filter(Boolean);
|
|
289
|
+
return candidates.some(c => {
|
|
290
|
+
const norm = String(c).replace(/\/+$/, "");
|
|
291
|
+
return baseUrl === norm || baseUrl.startsWith(norm + "/");
|
|
292
|
+
});
|
|
293
|
+
});
|
|
274
294
|
const models = provider?.models || [];
|
|
275
295
|
|
|
276
296
|
if (models.length === 0) {
|
|
@@ -322,6 +342,107 @@ const Settings = (() => {
|
|
|
322
342
|
_updateModelDropdown();
|
|
323
343
|
});
|
|
324
344
|
}
|
|
345
|
+
|
|
346
|
+
// Base URL combobox: dropdown button + endpoint_variants list.
|
|
347
|
+
//
|
|
348
|
+
// Rationale: some providers (GLM on Zhipu/Z.ai, MiniMax on .com/.io) run
|
|
349
|
+
// multiple regional / billing-plan endpoints under a single identity.
|
|
350
|
+
// Listing every variant lets the user pick the right one instead of
|
|
351
|
+
// hand-editing the URL, while still allowing free-form input for
|
|
352
|
+
// unknown / self-hosted proxies. Mirrors the model-name combobox.
|
|
353
|
+
//
|
|
354
|
+
// Data source: the endpoint_variants[] field on each provider preset,
|
|
355
|
+
// resolved by matching the currently-entered base_url against every
|
|
356
|
+
// preset's {base_url + endpoint_variants[].base_url}. When no variants
|
|
357
|
+
// are declared for the matched provider (single-endpoint providers like
|
|
358
|
+
// Anthropic, OpenClacky), the dropdown shows an "empty" hint.
|
|
359
|
+
const baseUrlCombobox = card.querySelector(".base-url-combobox");
|
|
360
|
+
const baseUrlDropdownBtn = baseUrlCombobox.querySelector(".base-url-dropdown-btn");
|
|
361
|
+
const baseUrlDropdown = baseUrlCombobox.querySelector(".base-url-dropdown");
|
|
362
|
+
|
|
363
|
+
// Resolve the "active" provider preset from the current form values:
|
|
364
|
+
// 1. If the Quick Setup select points at a known provider, use that
|
|
365
|
+
// (even before the base_url input is typed into).
|
|
366
|
+
// 2. Otherwise fall back to matching the current base_url against all
|
|
367
|
+
// preset base_url + endpoint_variants. Unknown URLs → null.
|
|
368
|
+
const _currentProvider = () => {
|
|
369
|
+
const selected = card.querySelector(".custom-select-option.selected");
|
|
370
|
+
const selectedId = selected?.dataset.value;
|
|
371
|
+
if (selectedId && selectedId !== "custom") {
|
|
372
|
+
const byId = _providers.find(p => p.id === selectedId);
|
|
373
|
+
if (byId) return byId;
|
|
374
|
+
}
|
|
375
|
+
const url = (baseUrlInput?.value || "").trim().replace(/\/+$/, "");
|
|
376
|
+
if (!url) return null;
|
|
377
|
+
return _providers.find(p => {
|
|
378
|
+
const candidates = [p.base_url].concat(
|
|
379
|
+
Array.isArray(p.endpoint_variants) ? p.endpoint_variants.map(v => v.base_url) : []
|
|
380
|
+
).filter(Boolean);
|
|
381
|
+
return candidates.some(c => {
|
|
382
|
+
const norm = String(c).replace(/\/+$/, "");
|
|
383
|
+
return url === norm || url.startsWith(norm + "/");
|
|
384
|
+
});
|
|
385
|
+
}) || null;
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
const _renderBaseUrlDropdown = () => {
|
|
389
|
+
const provider = _currentProvider();
|
|
390
|
+
const variants = provider && Array.isArray(provider.endpoint_variants)
|
|
391
|
+
? provider.endpoint_variants
|
|
392
|
+
: [];
|
|
393
|
+
|
|
394
|
+
if (variants.length === 0) {
|
|
395
|
+
baseUrlDropdown.innerHTML =
|
|
396
|
+
`<div class="model-dropdown-empty">${I18n.t("settings.models.baseurl.noVariants")}</div>`;
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
baseUrlDropdown.innerHTML = variants.map(v => {
|
|
401
|
+
// Prefer i18n key (localised per UI language); fall back to literal
|
|
402
|
+
// `label` (shipped English copy) and finally to base_url for safety.
|
|
403
|
+
// Pattern: _translateVariant(v) -> "大陆 · 按量付费" in zh, "Mainland · Pay-as-you-go" in en.
|
|
404
|
+
const translated = v.label_key ? I18n.t(v.label_key) : null;
|
|
405
|
+
// I18n.t typically returns the key itself when missing — treat that as a miss.
|
|
406
|
+
const labelText = (translated && translated !== v.label_key) ? translated : (v.label || v.base_url);
|
|
407
|
+
const label = _esc(labelText);
|
|
408
|
+
const url = _esc(v.base_url);
|
|
409
|
+
return `
|
|
410
|
+
<div class="model-dropdown-option base-url-dropdown-option" data-value="${url}">
|
|
411
|
+
<div class="base-url-dropdown-label">${label}</div>
|
|
412
|
+
<div class="base-url-dropdown-url">${url}</div>
|
|
413
|
+
</div>`;
|
|
414
|
+
}).join("");
|
|
415
|
+
|
|
416
|
+
baseUrlDropdown.querySelectorAll(".base-url-dropdown-option").forEach(opt => {
|
|
417
|
+
opt.addEventListener("click", (e) => {
|
|
418
|
+
e.stopPropagation();
|
|
419
|
+
if (baseUrlInput) {
|
|
420
|
+
baseUrlInput.value = opt.dataset.value;
|
|
421
|
+
// Trigger model-list refresh since base_url just changed.
|
|
422
|
+
_updateModelDropdown();
|
|
423
|
+
}
|
|
424
|
+
baseUrlDropdown.style.display = "none";
|
|
425
|
+
});
|
|
426
|
+
});
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
baseUrlDropdownBtn.addEventListener("click", (e) => {
|
|
430
|
+
e.stopPropagation();
|
|
431
|
+
const isOpen = baseUrlDropdown.style.display === "block";
|
|
432
|
+
// Close sibling dropdowns (model-name + other base-url) to avoid overlap.
|
|
433
|
+
document.querySelectorAll(".model-name-dropdown, .base-url-dropdown").forEach(d => {
|
|
434
|
+
d.style.display = "none";
|
|
435
|
+
});
|
|
436
|
+
if (!isOpen) {
|
|
437
|
+
_renderBaseUrlDropdown();
|
|
438
|
+
baseUrlDropdown.style.display = "block";
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
// Close dropdown when clicking outside
|
|
443
|
+
document.addEventListener("click", () => {
|
|
444
|
+
baseUrlDropdown.style.display = "none";
|
|
445
|
+
});
|
|
325
446
|
}
|
|
326
447
|
|
|
327
448
|
// ── Read form values from a card ────────────────────────────────────────────
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// ── sidebar.js — Left sidebar navigation ──────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Owns ONLY the left-rail navigation buttons whose sole job is to switch
|
|
4
|
+
// the main router view. Any "business action" button that happens to live
|
|
5
|
+
// in the sidebar (e.g. "new session") belongs to its own domain module
|
|
6
|
+
// (Sessions, Skills, …), not here.
|
|
7
|
+
//
|
|
8
|
+
// Contract:
|
|
9
|
+
// Sidebar.init() — attach click handlers. Must be called after DOM ready.
|
|
10
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
const Sidebar = (() => {
|
|
13
|
+
function init() {
|
|
14
|
+
// Settings button toggles between "settings" and "welcome" view.
|
|
15
|
+
document.getElementById("btn-settings").addEventListener("click", () => {
|
|
16
|
+
if (Router.current === "settings") {
|
|
17
|
+
Router.navigate("welcome");
|
|
18
|
+
} else {
|
|
19
|
+
Router.navigate("settings");
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// Primary navigation items — each just swaps the current route.
|
|
24
|
+
document.getElementById("tasks-sidebar-item").addEventListener("click", () => Router.navigate("tasks"));
|
|
25
|
+
document.getElementById("skills-sidebar-item").addEventListener("click", () => Router.navigate("skills"));
|
|
26
|
+
document.getElementById("channels-sidebar-item").addEventListener("click", () => Router.navigate("channels"));
|
|
27
|
+
document.getElementById("trash-sidebar-item").addEventListener("click", () => Router.navigate("trash"));
|
|
28
|
+
document.getElementById("profile-sidebar-item").addEventListener("click", () => Router.navigate("profile"));
|
|
29
|
+
|
|
30
|
+
// memories-sidebar-item is retained as a hidden legacy placeholder — no click handler.
|
|
31
|
+
|
|
32
|
+
// creator-sidebar-item is conditionally rendered (only when user_licensed).
|
|
33
|
+
// This ?. is a legitimate business guard, not defensive padding — the
|
|
34
|
+
// element genuinely may not exist in the DOM for unlicensed users.
|
|
35
|
+
document.getElementById("creator-sidebar-item")?.addEventListener("click", () => Router.navigate("creator"));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return { init };
|
|
39
|
+
})();
|
data/lib/clacky/web/skills.js
CHANGED
|
@@ -764,3 +764,463 @@ const Skills = (() => {
|
|
|
764
764
|
},
|
|
765
765
|
};
|
|
766
766
|
})();
|
|
767
|
+
|
|
768
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
769
|
+
// SkillAC — slash-command skill autocomplete dropdown + composer bindings
|
|
770
|
+
//
|
|
771
|
+
// Handles the "/xxx" slash-command autocomplete UI above the message input,
|
|
772
|
+
// plus all composer keyboard/composition/input DOM bindings that depend on it
|
|
773
|
+
// (Enter to send, / button, IME composition guard).
|
|
774
|
+
//
|
|
775
|
+
// Moved verbatim from app.js; structural changes only:
|
|
776
|
+
// - `_lastCompositionEndTime` moved into the IIFE closure (was module-level)
|
|
777
|
+
// - The bare DOM bindings (btn-slash, user-input keydown/input/compositionend,
|
|
778
|
+
// btn-create-skill, btn-import-skill) are wrapped in a private
|
|
779
|
+
// `_initDOMBindings()` function called at the end of `init()`.
|
|
780
|
+
//
|
|
781
|
+
// Depends on: Sessions (sendMessage), Skills (createInSession, toggleImportBar),
|
|
782
|
+
// I18n, global $ helper.
|
|
783
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
784
|
+
const SkillAC = (() => {
|
|
785
|
+
let _initialized = false;
|
|
786
|
+
let _visible = false;
|
|
787
|
+
let _activeIndex = -1;
|
|
788
|
+
let _items = []; // filtered [{ name, description, encrypted, source }]
|
|
789
|
+
let _currentSession = null; // track active session id for live fetch
|
|
790
|
+
|
|
791
|
+
// Load from localStorage, default to false (hide system skills)
|
|
792
|
+
let _showSystemSkills = localStorage.getItem("skill-ac-show-system") === "true";
|
|
793
|
+
|
|
794
|
+
// Cross-browser IME composition fix:
|
|
795
|
+
// Safari fires compositionend BEFORE keydown (violating W3C spec), and the
|
|
796
|
+
// gap between compositionend and keydown is ~5ms on Safari. We record the
|
|
797
|
+
// timestamp of compositionend and treat any Enter keydown within 20ms as
|
|
798
|
+
// still-composing. Chrome is unaffected because e.isComposing is still true.
|
|
799
|
+
// Reference: https://bugs.webkit.org/show_bug.cgi?id=165004
|
|
800
|
+
let _lastCompositionEndTime = -Infinity;
|
|
801
|
+
|
|
802
|
+
/** Called whenever the active session changes — just store the id, no prefetch. */
|
|
803
|
+
function _loadForSession(sessionId) {
|
|
804
|
+
_currentSession = sessionId || null;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/** Fetch live skill list from server for the current session. */
|
|
808
|
+
async function _fetchSkills() {
|
|
809
|
+
if (!_currentSession) return [];
|
|
810
|
+
try {
|
|
811
|
+
const res = await fetch(`/api/sessions/${_currentSession}/skills`);
|
|
812
|
+
const data = await res.json();
|
|
813
|
+
return data.skills || [];
|
|
814
|
+
} catch (e) {
|
|
815
|
+
console.error("[SkillAC] fetchSkills failed", e);
|
|
816
|
+
return [];
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/** Return the /xxx prefix if the entire input is a slash command, else null. */
|
|
821
|
+
function _getSlashQuery(value) {
|
|
822
|
+
// Full-width slash / dunhao are already replaced in the input event handler,
|
|
823
|
+
// but guard here too in case value is passed programmatically.
|
|
824
|
+
let trimmed = value.replace(/^[/、]/, "/");
|
|
825
|
+
|
|
826
|
+
// Only activate when the whole input starts with / (no leading space)
|
|
827
|
+
if (!trimmed.startsWith("/")) return null;
|
|
828
|
+
// Only single-word slash token — no spaces allowed after /
|
|
829
|
+
if (/^\/\S*$/.test(trimmed)) return trimmed.slice(1).toLowerCase();
|
|
830
|
+
return null;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* Score how well a skill matches the query string.
|
|
835
|
+
* Only matches against name and name_zh — description is intentionally excluded.
|
|
836
|
+
* All matches are contiguous substring matches (no fuzzy/subsequence).
|
|
837
|
+
* Returns 0 if no match (should be filtered out).
|
|
838
|
+
*
|
|
839
|
+
* Scoring tiers:
|
|
840
|
+
* 100 — name or name_zh exact match
|
|
841
|
+
* 80 — name or name_zh starts-with
|
|
842
|
+
* 60 — name or name_zh contains
|
|
843
|
+
* 0 — no match
|
|
844
|
+
*/
|
|
845
|
+
function _scoreMatch(skill, query) {
|
|
846
|
+
if (!query) return 50; // empty query → show all with neutral score
|
|
847
|
+
|
|
848
|
+
const q = query.toLowerCase();
|
|
849
|
+
const name = (skill.name || "").toLowerCase();
|
|
850
|
+
const zh = (skill.name_zh || "").toLowerCase();
|
|
851
|
+
|
|
852
|
+
// Exact match
|
|
853
|
+
if (name === q || zh === q) return 100;
|
|
854
|
+
|
|
855
|
+
// Prefix match
|
|
856
|
+
if (name.startsWith(q) || zh.startsWith(q)) return 80;
|
|
857
|
+
|
|
858
|
+
// Contains match (contiguous substring)
|
|
859
|
+
if (name.includes(q) || zh.includes(q)) return 60;
|
|
860
|
+
|
|
861
|
+
return 0;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* Wrap the matching substring in <mark> for highlighting.
|
|
866
|
+
* Returns an array of DOM nodes (text + mark nodes).
|
|
867
|
+
*/
|
|
868
|
+
function _highlight(text, query) {
|
|
869
|
+
if (!query) return [document.createTextNode(text)];
|
|
870
|
+
const idx = text.toLowerCase().indexOf(query.toLowerCase());
|
|
871
|
+
if (idx === -1) return [document.createTextNode(text)];
|
|
872
|
+
|
|
873
|
+
const nodes = [];
|
|
874
|
+
if (idx > 0) nodes.push(document.createTextNode(text.slice(0, idx)));
|
|
875
|
+
const mark = document.createElement("span");
|
|
876
|
+
mark.className = "skill-ac-highlight";
|
|
877
|
+
mark.textContent = text.slice(idx, idx + query.length);
|
|
878
|
+
nodes.push(mark);
|
|
879
|
+
if (idx + query.length < text.length) {
|
|
880
|
+
nodes.push(document.createTextNode(text.slice(idx + query.length)));
|
|
881
|
+
}
|
|
882
|
+
return nodes;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
async function _render(query) {
|
|
886
|
+
const all = await _fetchSkills();
|
|
887
|
+
|
|
888
|
+
// Score and filter
|
|
889
|
+
let scored = all
|
|
890
|
+
.map(s => ({ skill: s, score: _scoreMatch(s, query) }))
|
|
891
|
+
.filter(({ score }) => score > 0);
|
|
892
|
+
|
|
893
|
+
if (!_showSystemSkills) {
|
|
894
|
+
scored = scored.filter(({ skill }) => skill.source_type !== "default");
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// Sort by score descending, stable secondary sort by name
|
|
898
|
+
scored.sort((a, b) => b.score - a.score || a.skill.name.localeCompare(b.skill.name));
|
|
899
|
+
|
|
900
|
+
_items = scored.map(({ skill }) => skill);
|
|
901
|
+
|
|
902
|
+
const list = $("skill-autocomplete-list");
|
|
903
|
+
list.innerHTML = "";
|
|
904
|
+
|
|
905
|
+
if (_items.length === 0) {
|
|
906
|
+
// Show empty state instead of hiding the dropdown
|
|
907
|
+
const emptyEl = document.createElement("div");
|
|
908
|
+
emptyEl.className = "skill-ac-empty";
|
|
909
|
+
emptyEl.textContent = I18n.t("skills.ac.empty");
|
|
910
|
+
list.appendChild(emptyEl);
|
|
911
|
+
$("skill-autocomplete").style.display = "";
|
|
912
|
+
_visible = true;
|
|
913
|
+
_createOverlay();
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
_items.forEach((skill, idx) => {
|
|
918
|
+
const item = document.createElement("div");
|
|
919
|
+
item.className = "skill-ac-item" + (idx === _activeIndex ? " active" : "");
|
|
920
|
+
item.setAttribute("role", "option");
|
|
921
|
+
item.setAttribute("data-idx", idx);
|
|
922
|
+
|
|
923
|
+
const nameEl = document.createElement("span");
|
|
924
|
+
nameEl.className = "skill-ac-name";
|
|
925
|
+
|
|
926
|
+
const currentLangForName = I18n.lang();
|
|
927
|
+
const showZhFirst = currentLangForName === "zh" && skill.name_zh;
|
|
928
|
+
|
|
929
|
+
if (showZhFirst) {
|
|
930
|
+
// Chinese UI: /中文名 first (with slash), then english id (no slash) after
|
|
931
|
+
const zhEl = document.createElement("span");
|
|
932
|
+
zhEl.className = "skill-ac-name-zh";
|
|
933
|
+
zhEl.appendChild(document.createTextNode("/"));
|
|
934
|
+
_highlight(skill.name_zh, query).forEach(function(n) { zhEl.appendChild(n); });
|
|
935
|
+
nameEl.appendChild(zhEl);
|
|
936
|
+
|
|
937
|
+
const nameTextEl = document.createElement("span");
|
|
938
|
+
nameTextEl.className = "skill-ac-name-id";
|
|
939
|
+
_highlight(skill.name, query).forEach(function(n) { nameTextEl.appendChild(n); });
|
|
940
|
+
nameEl.appendChild(nameTextEl);
|
|
941
|
+
} else {
|
|
942
|
+
// English UI (or no zh name): show /id only, no zh name
|
|
943
|
+
const nameTextEl = document.createElement("span");
|
|
944
|
+
nameTextEl.appendChild(document.createTextNode("/"));
|
|
945
|
+
_highlight(skill.name, query).forEach(function(n) { nameTextEl.appendChild(n); });
|
|
946
|
+
nameEl.appendChild(nameTextEl);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// meta: encrypted badge + source type label (subtle)
|
|
950
|
+
const metaEl = document.createElement("span");
|
|
951
|
+
metaEl.className = "skill-ac-meta";
|
|
952
|
+
if (skill.encrypted) {
|
|
953
|
+
const encBadge = document.createElement("span");
|
|
954
|
+
encBadge.className = "skill-ac-enc";
|
|
955
|
+
encBadge.textContent = "🔒";
|
|
956
|
+
metaEl.appendChild(encBadge);
|
|
957
|
+
}
|
|
958
|
+
const sourceLabel = {
|
|
959
|
+
"default": "built-in",
|
|
960
|
+
"global_clacky": "user",
|
|
961
|
+
"global_claude": "user",
|
|
962
|
+
"project_clacky": "project",
|
|
963
|
+
"project_claude": "project",
|
|
964
|
+
"brand": "brand",
|
|
965
|
+
}[skill.source_type];
|
|
966
|
+
if (sourceLabel) {
|
|
967
|
+
const srcEl = document.createElement("span");
|
|
968
|
+
srcEl.className = "skill-ac-src";
|
|
969
|
+
srcEl.textContent = sourceLabel;
|
|
970
|
+
metaEl.appendChild(srcEl);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
const descEl = document.createElement("span");
|
|
974
|
+
descEl.className = "skill-ac-desc";
|
|
975
|
+
// Choose description based on current language
|
|
976
|
+
const description = (currentLangForName === "zh" && skill.description_zh)
|
|
977
|
+
? skill.description_zh
|
|
978
|
+
: skill.description || "";
|
|
979
|
+
descEl.textContent = description;
|
|
980
|
+
|
|
981
|
+
item.appendChild(nameEl);
|
|
982
|
+
item.appendChild(metaEl);
|
|
983
|
+
item.appendChild(descEl);
|
|
984
|
+
|
|
985
|
+
item.addEventListener("mousedown", e => {
|
|
986
|
+
// mousedown fires before blur — prevent input losing focus
|
|
987
|
+
e.preventDefault();
|
|
988
|
+
_select(idx);
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
list.appendChild(item);
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
$("skill-autocomplete").style.display = "";
|
|
995
|
+
_visible = true;
|
|
996
|
+
_createOverlay();
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
function _hide() {
|
|
1000
|
+
$("skill-autocomplete").style.display = "none";
|
|
1001
|
+
_visible = false;
|
|
1002
|
+
_activeIndex = -1;
|
|
1003
|
+
_items = [];
|
|
1004
|
+
$("btn-slash")?.classList.remove("active");
|
|
1005
|
+
_removeOverlay();
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
function _createOverlay() {
|
|
1009
|
+
// Remove existing overlay if any
|
|
1010
|
+
_removeOverlay();
|
|
1011
|
+
|
|
1012
|
+
const overlay = document.createElement("div");
|
|
1013
|
+
overlay.id = "skill-ac-overlay";
|
|
1014
|
+
overlay.style.cssText = "position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 999; background: transparent;";
|
|
1015
|
+
|
|
1016
|
+
// Click overlay to close dropdown
|
|
1017
|
+
overlay.addEventListener("click", () => {
|
|
1018
|
+
_hide();
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
document.body.appendChild(overlay);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
function _removeOverlay() {
|
|
1025
|
+
const overlay = document.getElementById("skill-ac-overlay");
|
|
1026
|
+
if (overlay) overlay.remove();
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
function _select(idx) {
|
|
1030
|
+
const skill = _items[idx];
|
|
1031
|
+
if (!skill) return;
|
|
1032
|
+
const input = $("user-input");
|
|
1033
|
+
input.value = "/" + skill.name + " ";
|
|
1034
|
+
input.style.height = "auto";
|
|
1035
|
+
input.style.height = Math.min(input.scrollHeight, 200) + "px";
|
|
1036
|
+
_hide();
|
|
1037
|
+
input.focus();
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
function _moveActive(delta) {
|
|
1041
|
+
if (!_visible || _items.length === 0) return;
|
|
1042
|
+
_activeIndex = (_activeIndex + delta + _items.length) % _items.length;
|
|
1043
|
+
// Re-render to apply active class
|
|
1044
|
+
const list = $("skill-autocomplete-list");
|
|
1045
|
+
list.querySelectorAll(".skill-ac-item").forEach((el, i) => {
|
|
1046
|
+
el.classList.toggle("active", i === _activeIndex);
|
|
1047
|
+
if (i === _activeIndex) el.scrollIntoView({ block: "nearest" });
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/** Open the dropdown showing all skills, used by the / button. */
|
|
1052
|
+
async function _openAll() {
|
|
1053
|
+
_activeIndex = 0; // Default to first item
|
|
1054
|
+
await _render("");
|
|
1055
|
+
$("user-input").focus();
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/** Toggle the dropdown (open if hidden, close if visible). */
|
|
1059
|
+
async function _toggle() {
|
|
1060
|
+
if (_visible) {
|
|
1061
|
+
_hide();
|
|
1062
|
+
} else {
|
|
1063
|
+
await _openAll();
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// ── DOM bindings: composer keyboard/composition/input + slash button + ────
|
|
1068
|
+
// ── skill-panel create/import buttons. Called once from init(). ──
|
|
1069
|
+
function _initDOMBindings() {
|
|
1070
|
+
// / button: set input to "/" and open skill autocomplete.
|
|
1071
|
+
// mousedown + preventDefault prevents the textarea from losing focus
|
|
1072
|
+
// (which would trigger the blur→hide timer and immediately close
|
|
1073
|
+
// the dropdown we're about to open).
|
|
1074
|
+
$("btn-slash").addEventListener("mousedown", e => {
|
|
1075
|
+
e.preventDefault(); // keep focus on user-input
|
|
1076
|
+
});
|
|
1077
|
+
$("btn-slash").addEventListener("click", () => {
|
|
1078
|
+
const input = $("user-input");
|
|
1079
|
+
if (input.value === "" || input.value === "/") {
|
|
1080
|
+
input.value = "/";
|
|
1081
|
+
input.style.height = "auto";
|
|
1082
|
+
input.style.height = Math.min(input.scrollHeight, 200) + "px";
|
|
1083
|
+
}
|
|
1084
|
+
_toggle(); // Toggle dropdown instead of always opening
|
|
1085
|
+
if (_visible) {
|
|
1086
|
+
$("btn-slash").classList.add("active");
|
|
1087
|
+
}
|
|
1088
|
+
input.focus();
|
|
1089
|
+
});
|
|
1090
|
+
|
|
1091
|
+
// IME composition guard: record timestamp of compositionend so the
|
|
1092
|
+
// Enter keydown handler can detect Safari's out-of-order firing.
|
|
1093
|
+
$("user-input").addEventListener("compositionend", () => {
|
|
1094
|
+
_lastCompositionEndTime = Date.now();
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
// Main composer keydown: SkillAC consumes nav keys first, then Enter → send.
|
|
1098
|
+
$("user-input").addEventListener("keydown", e => {
|
|
1099
|
+
// Let skill autocomplete consume arrow/enter/escape first
|
|
1100
|
+
if (_handleKey(e)) return;
|
|
1101
|
+
|
|
1102
|
+
if (e.key === "Enter" && !e.shiftKey && !e.isComposing && (Date.now() - _lastCompositionEndTime) > 20) {
|
|
1103
|
+
e.preventDefault();
|
|
1104
|
+
Sessions.sendMessage();
|
|
1105
|
+
}
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
// Composer input: auto-grow textarea, normalize full-width slash, drive AC.
|
|
1109
|
+
$("user-input").addEventListener("input", () => {
|
|
1110
|
+
const el = $("user-input");
|
|
1111
|
+
el.style.height = "auto";
|
|
1112
|
+
el.style.height = Math.min(el.scrollHeight, 200) + "px";
|
|
1113
|
+
|
|
1114
|
+
// Replace full-width slash / or Chinese dunhao 、 with ASCII / in-place
|
|
1115
|
+
if (/^[/、]/.test(el.value)) {
|
|
1116
|
+
const pos = el.selectionStart;
|
|
1117
|
+
el.value = el.value.replace(/^[/、]/, "/");
|
|
1118
|
+
el.setSelectionRange(pos, pos);
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// Trigger skill autocomplete
|
|
1122
|
+
_update(el.value);
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
// Skills panel action buttons (domain belongs to Skills, but the
|
|
1126
|
+
// bindings live here because this is where all composer/skill-related
|
|
1127
|
+
// DOM wiring was historically colocated).
|
|
1128
|
+
$("btn-create-skill").addEventListener("click", () => Skills.createInSession());
|
|
1129
|
+
$("btn-import-skill").addEventListener("click", () => Skills.toggleImportBar());
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// Update handler — driven from the input event above. Exposed on the
|
|
1133
|
+
// public API for programmatic use too.
|
|
1134
|
+
function _update(value) {
|
|
1135
|
+
const query = _getSlashQuery(value);
|
|
1136
|
+
if (query === null) { _hide(); return; }
|
|
1137
|
+
_activeIndex = 0; // Always highlight the first match
|
|
1138
|
+
_render(query); // async, fire-and-forget
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// Keyboard handler for the dropdown. Returns true if the event was consumed.
|
|
1142
|
+
function _handleKey(e) {
|
|
1143
|
+
if (!_visible) return false;
|
|
1144
|
+
if (e.key === "ArrowDown") { e.preventDefault(); _moveActive(1); return true; }
|
|
1145
|
+
if (e.key === "ArrowUp") { e.preventDefault(); _moveActive(-1); return true; }
|
|
1146
|
+
if (e.key === "Escape") { e.preventDefault(); _hide(); return true; }
|
|
1147
|
+
if (e.key === "Tab") {
|
|
1148
|
+
// Tab: select active item if one is highlighted, otherwise select first item
|
|
1149
|
+
e.preventDefault();
|
|
1150
|
+
const targetIdx = _activeIndex >= 0 ? _activeIndex : 0;
|
|
1151
|
+
_select(targetIdx);
|
|
1152
|
+
return true;
|
|
1153
|
+
}
|
|
1154
|
+
if (e.key === "Enter" && !e.isComposing && (Date.now() - _lastCompositionEndTime) > 20) {
|
|
1155
|
+
if (_activeIndex >= 0) {
|
|
1156
|
+
e.preventDefault();
|
|
1157
|
+
_select(_activeIndex);
|
|
1158
|
+
return true;
|
|
1159
|
+
}
|
|
1160
|
+
// No item highlighted — select first item if available
|
|
1161
|
+
if (_items.length > 0) {
|
|
1162
|
+
e.preventDefault();
|
|
1163
|
+
_select(0);
|
|
1164
|
+
return true;
|
|
1165
|
+
}
|
|
1166
|
+
// No items — let Enter fall through to sendMessage
|
|
1167
|
+
_hide();
|
|
1168
|
+
return false;
|
|
1169
|
+
}
|
|
1170
|
+
return false;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
return {
|
|
1174
|
+
get visible() { return _visible; },
|
|
1175
|
+
get activeIndex() { return _activeIndex; },
|
|
1176
|
+
|
|
1177
|
+
/** Initialize event listeners (call once on page load). */
|
|
1178
|
+
init() {
|
|
1179
|
+
if (_initialized) return;
|
|
1180
|
+
_initialized = true;
|
|
1181
|
+
|
|
1182
|
+
const chk = $("chk-ac-show-system-skills");
|
|
1183
|
+
|
|
1184
|
+
if (chk) {
|
|
1185
|
+
// Restore state from localStorage
|
|
1186
|
+
chk.checked = _showSystemSkills;
|
|
1187
|
+
|
|
1188
|
+
chk.addEventListener("change", async () => {
|
|
1189
|
+
_showSystemSkills = chk.checked;
|
|
1190
|
+
// Persist to localStorage
|
|
1191
|
+
localStorage.setItem("skill-ac-show-system", _showSystemSkills ? "true" : "false");
|
|
1192
|
+
|
|
1193
|
+
// If dropdown is visible, re-fetch and re-render
|
|
1194
|
+
if (_visible) {
|
|
1195
|
+
const input = $("user-input");
|
|
1196
|
+
const query = _getSlashQuery(input.value);
|
|
1197
|
+
if (query !== null) {
|
|
1198
|
+
await _render(query);
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
});
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
// Wire up all composer/slash DOM bindings.
|
|
1205
|
+
_initDOMBindings();
|
|
1206
|
+
},
|
|
1207
|
+
|
|
1208
|
+
/** Called on every `input` event — decide whether to show/hide/update. */
|
|
1209
|
+
update: _update,
|
|
1210
|
+
|
|
1211
|
+
/** Open dropdown with all skills (triggered by / button). */
|
|
1212
|
+
openAll: _openAll,
|
|
1213
|
+
|
|
1214
|
+
/** Toggle dropdown visibility (used by / button). */
|
|
1215
|
+
toggle: _toggle,
|
|
1216
|
+
|
|
1217
|
+
/** Hide the dropdown. */
|
|
1218
|
+
hide: _hide,
|
|
1219
|
+
|
|
1220
|
+
/** Reload session-scoped skill list when the active session changes. */
|
|
1221
|
+
loadForSession: _loadForSession,
|
|
1222
|
+
|
|
1223
|
+
/** Handle keyboard nav inside the dropdown. Returns true if event was consumed. */
|
|
1224
|
+
handleKey: _handleKey,
|
|
1225
|
+
};
|
|
1226
|
+
})();
|