main
js 366 lines 10.3 KB
Raw
1 import * as API from "/js/api.js";
2 import { store as markdownModalStore } from "/components/modals/markdown/markdown-store.js";
3 import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
4 import { toastFrontendError } from "/components/notifications/notification-store.js";
5
6 const CATALOG_API = "/plugins/_skills/skills_catalog";
7
8 function normalizeEntry(entry) {
9 if (!entry) return null;
10 if (typeof entry === "string") {
11 const trimmed = entry.trim();
12 if (!trimmed) return null;
13 return trimmed.includes("/")
14 ? { path: trimmed.replace(/\/+$/, "") }
15 : { name: trimmed };
16 }
17
18 if (typeof entry !== "object") return null;
19 const name = String(entry.name || "").trim();
20 const path = String(entry.path || "").trim().replace(/\/+$/, "");
21 if (!name && !path) return null;
22 return {
23 ...(name ? { name } : {}),
24 ...(path ? { path } : {}),
25 };
26 }
27
28 function entryKey(entry) {
29 if (!entry) return "";
30 return String(entry.path || entry.name || "").trim().toLowerCase();
31 }
32
33 function entryKeys(entry) {
34 return [entry?.path, entry?.name]
35 .map((value) => String(value || "").trim().toLowerCase())
36 .filter(Boolean);
37 }
38
39 function entriesMatch(left, right) {
40 const rightKeys = new Set(entryKeys(right));
41 if (rightKeys.size === 0) return false;
42 return entryKeys(left).some((key) => rightKeys.has(key));
43 }
44
45 function ensureConfig(config) {
46 if (!config || typeof config !== "object") return;
47 const hiddenSkills = Array.isArray(config.hidden_skills) ? config.hidden_skills : [];
48
49 config.hidden_skills = compactEntries(hiddenSkills);
50 }
51
52 function compactEntries(entries, limit = null) {
53 const normalized = [];
54 const seen = new Set();
55
56 for (const item of entries || []) {
57 const entry = normalizeEntry(item);
58 const key = entryKey(entry);
59 if (!entry || !key || seen.has(key)) continue;
60 seen.add(key);
61 normalized.push({
62 ...(entry.name ? { name: entry.name } : {}),
63 ...(entry.path ? { path: entry.path } : {}),
64 });
65 if (limit !== null && normalized.length >= limit) break;
66 }
67
68 return normalized;
69 }
70
71 window.createSkillsConfigModel = (context, config) => ({
72 loadingCatalog: false,
73 mutatingChat: false,
74 catalog: [],
75 search: "",
76 selectedSkills: [],
77 hiddenSkills: [],
78 chatContextAvailable: false,
79
80 initDefaults() {
81 ensureConfig(config);
82 this.selectedSkills = [];
83 this.hiddenSkills = [...this.hiddenEntries];
84 },
85
86 get isChatMode() {
87 return context?.openOptions?.focus === "chat";
88 },
89
90 get hiddenEntries() {
91 ensureConfig(config);
92 return config.hidden_skills;
93 },
94
95 get catalogMap() {
96 const byKey = new Map();
97 for (const skill of this.catalog) {
98 byKey.set(entryKey(skill), skill);
99 if (skill.name) {
100 byKey.set(String(skill.name).trim().toLowerCase(), skill);
101 }
102 }
103 return byKey;
104 },
105
106 get filteredCatalog() {
107 const query = this.search.trim().toLowerCase();
108 if (!query) return this.catalog;
109
110 return this.catalog.filter((skill) => {
111 const haystack = [
112 skill.name,
113 skill.description,
114 skill.path,
115 skill.origin,
116 ]
117 .filter(Boolean)
118 .join(" ")
119 .toLowerCase();
120 return haystack.includes(query);
121 });
122 },
123
124 entryKey(entry) {
125 return entryKey(entry);
126 },
127
128 pinnedSubtitle() {
129 return "These skills are loaded into the current chat history.";
130 },
131
132 allSkillsSubtitle() {
133 return this.isChatMode
134 ? "Check a skill to load it into this chat. Use the eye control to hide or show it in this chat."
135 : "Check a skill to load it into the current chat. Use the eye control to hide or show it in this scope.";
136 },
137
138 hiddenStateLabel() {
139 return this.isChatMode ? "Hidden in this chat" : "Hidden by default";
140 },
141
142 visibilityButtonTitle(skill) {
143 if (this.isHidden(skill)) {
144 return this.isChatMode ? "Show in this chat" : "Show by default";
145 }
146 return this.isChatMode ? "Hide in this chat" : "Hide by default";
147 },
148
149 visibilityButtonIcon(skill) {
150 return this.isHidden(skill) ? "visibility_off" : "visibility";
151 },
152
153 isHidden(skill) {
154 return this.hiddenSkills.some((entry) => entriesMatch(entry, skill));
155 },
156
157 isSelected(skill) {
158 return this.selectedSkills.some((entry) => entriesMatch(entry, skill));
159 },
160
161 isPinDisabled(skill) {
162 return this.mutatingChat || !this.chatContextAvailable || this.isSelected(skill);
163 },
164
165 isVisibilityDisabled() {
166 return this.mutatingChat || (this.isChatMode && !this.chatContextAvailable);
167 },
168
169 isEntryMissing(entry) {
170 const key = entryKey(entry);
171 if (!key) return false;
172 if (this.catalogMap.has(key)) return false;
173 if (entry.name && this.catalogMap.has(String(entry.name).trim().toLowerCase())) return false;
174 return true;
175 },
176
177 labelForEntry(entry) {
178 const skill = this._resolveEntry(entry);
179 if (skill?.name) return skill.name;
180 return entry?.name || "(unnamed skill)";
181 },
182
183 secondaryLabelForEntry(entry) {
184 const skill = this._resolveEntry(entry);
185 if (skill) return `${skill.origin} | ${skill.path}`;
186 if (entry?.path) return `Not visible in the current list | ${entry.path}`;
187 return "Not visible in the current list";
188 },
189
190 _resolveEntry(entry) {
191 const key = entryKey(entry);
192 if (key && this.catalogMap.has(key)) {
193 return this.catalogMap.get(key);
194 }
195 const name = String(entry?.name || "").trim().toLowerCase();
196 return name ? this.catalogMap.get(name) || null : null;
197 },
198
199 _setSelectedSkills(entries) {
200 const normalized = [];
201 const seen = new Set();
202
203 for (const entry of entries) {
204 const item = normalizeEntry(entry);
205 const key = entryKey(item);
206 if (!item || !key || seen.has(key)) continue;
207 seen.add(key);
208 normalized.push(item);
209 }
210
211 this.selectedSkills = normalized;
212 },
213
214 _setHiddenSkills(entries, { writeConfig = true } = {}) {
215 const normalized = compactEntries(entries);
216 this.hiddenSkills = normalized;
217 if (writeConfig) {
218 config.hidden_skills = normalized;
219 }
220 },
221
222 async toggleSkillVisibility(skill, selected) {
223 const previous = [...this.hiddenSkills];
224 const nextEntries = this.hiddenSkills.filter((entry) => !entriesMatch(entry, skill));
225
226 if (!selected) {
227 nextEntries.push({
228 name: String(skill.name || "").trim(),
229 path: String(skill.path || "").trim(),
230 });
231 }
232
233 this._setHiddenSkills(nextEntries, { writeConfig: !this.isChatMode });
234
235 if (this.isChatMode && this.chatContextAvailable) {
236 const ok = await this.submitChatAction(selected ? "show" : "hide", skill);
237 if (!ok) {
238 this._setHiddenSkills(previous, { writeConfig: false });
239 }
240 }
241 },
242
243 async toggleVisibility(skill) {
244 await this.toggleSkillVisibility(skill, this.isHidden(skill));
245 },
246
247 async togglePinnedSkill(skill, selected) {
248 if (!selected || this.isSelected(skill)) {
249 await this.loadCatalog();
250 return;
251 }
252
253 if (!this.chatContextAvailable) {
254 await toastFrontendError("Open a chat before loading a skill.", "Skills");
255 await this.loadCatalog();
256 return;
257 }
258
259 await this.submitChatAction("activate", skill);
260 },
261
262 applyCatalogState(response) {
263 this.chatContextAvailable = !!response?.context_available;
264 const activeFromChat = Array.isArray(response?.active_skills) ? response.active_skills : null;
265 const hiddenFromChat = Array.isArray(response?.hidden_skills) ? response.hidden_skills : null;
266 const hiddenFromConfig = this.hiddenEntries;
267
268 this._setSelectedSkills(
269 activeFromChat || [],
270 );
271 this._setHiddenSkills(
272 this.isChatMode ? hiddenFromChat || [] : hiddenFromConfig,
273 { writeConfig: !this.isChatMode },
274 );
275 },
276
277 async loadCatalog() {
278 this.loadingCatalog = true;
279 try {
280 const response = await API.callJsonApi(CATALOG_API, {
281 action: "list",
282 project_name: context.projectName || "",
283 context_id: chatsStore.selectedContext?.id || "",
284 });
285
286 if (!response?.ok) {
287 throw new Error(response?.error || "Failed to load skills");
288 }
289
290 this.catalog = Array.isArray(response.skills) ? response.skills : [];
291 this.applyCatalogState(response);
292 } catch (error) {
293 this.catalog = [];
294 ensureConfig(config);
295 this.chatContextAvailable = false;
296 this._setSelectedSkills([]);
297 this._setHiddenSkills(this.hiddenEntries);
298 await toastFrontendError(error?.message || "Failed to load skills", "Skills");
299 } finally {
300 this.loadingCatalog = false;
301 }
302 },
303
304 async submitChatAction(action, skill = null) {
305 this.mutatingChat = true;
306 try {
307 const response = await API.callJsonApi(CATALOG_API, {
308 action,
309 context_id: chatsStore.selectedContext?.id || "",
310 project_name: context.projectName || "",
311 ...(skill
312 ? {
313 skill: {
314 name: String(skill.name || "").trim(),
315 path: String(skill.path || "").trim(),
316 },
317 }
318 : {}),
319 });
320
321 if (!response?.ok) {
322 throw new Error(response?.error || "Failed to update skills");
323 }
324
325 this.catalog = Array.isArray(response.skills) ? response.skills : this.catalog;
326 this.chatContextAvailable = !!response.context_available;
327 this.applyCatalogState(response);
328 return true;
329 } catch (error) {
330 await toastFrontendError(error?.message || "Failed to update skills", "Skills");
331 return false;
332 } finally {
333 this.mutatingChat = false;
334 }
335 },
336
337 async openSkill(skill) {
338 try {
339 const response = await API.callJsonApi(CATALOG_API, {
340 action: "get_doc",
341 context_id: chatsStore.selectedContext?.id || "",
342 project_name: context.projectName || "",
343 skill: {
344 name: String(skill?.name || "").trim(),
345 path: String(skill?.path || "").trim(),
346 },
347 });
348
349 if (!response?.ok) {
350 throw new Error(response?.error || "Failed to open skill");
351 }
352 if (!markdownModalStore?.open) {
353 throw new Error("Markdown viewer is unavailable");
354 }
355
356 markdownModalStore.open(response.filename || "SKILL.md", response.content || "", {
357 viewer: "ace",
358 });
359 window.openModal?.("components/modals/markdown/markdown-modal.html");
360 } catch (error) {
361 await toastFrontendError(error?.message || "Failed to open skill", "Skills");
362 }
363 },
364 });
365
366 export {};