| 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 {}; |