llms-py 3.0.21__py3-none-any.whl → 3.0.23__py3-none-any.whl
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.
- llms/extensions/computer/__init__.py +16 -8
- llms/extensions/gallery/ui/index.mjs +2 -1
- llms/extensions/skills/README.md +275 -0
- llms/extensions/skills/__init__.py +362 -3
- llms/extensions/skills/installer.py +415 -0
- llms/extensions/skills/ui/data/skills-top-5000.json +1 -0
- llms/extensions/skills/ui/index.mjs +572 -4
- llms/extensions/skills/ui/skills/create-plan/SKILL.md +6 -6
- llms/extensions/skills/ui/skills/skill-creator/LICENSE.txt +202 -0
- llms/extensions/skills/ui/skills/skill-creator/SKILL.md +356 -0
- llms/extensions/skills/ui/skills/skill-creator/references/output-patterns.md +82 -0
- llms/extensions/skills/ui/skills/skill-creator/references/workflows.md +28 -0
- llms/extensions/skills/ui/skills/skill-creator/scripts/init_skill.py +299 -0
- llms/extensions/skills/ui/skills/skill-creator/scripts/package_skill.py +111 -0
- llms/extensions/skills/ui/skills/skill-creator/scripts/quick_validate.py +98 -0
- llms/llms.json +31 -19
- llms/main.py +20 -7
- llms/providers.json +1 -1
- llms/ui/ai.mjs +1 -1
- llms/ui/app.css +67 -0
- llms/ui/ctx.mjs +6 -7
- {llms_py-3.0.21.dist-info → llms_py-3.0.23.dist-info}/METADATA +1 -1
- {llms_py-3.0.21.dist-info → llms_py-3.0.23.dist-info}/RECORD +27 -17
- {llms_py-3.0.21.dist-info → llms_py-3.0.23.dist-info}/WHEEL +0 -0
- {llms_py-3.0.21.dist-info → llms_py-3.0.23.dist-info}/entry_points.txt +0 -0
- {llms_py-3.0.21.dist-info → llms_py-3.0.23.dist-info}/licenses/LICENSE +0 -0
- {llms_py-3.0.21.dist-info → llms_py-3.0.23.dist-info}/top_level.txt +0 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ref, inject, computed } from "vue"
|
|
1
|
+
import { ref, inject, computed, nextTick } from "vue"
|
|
2
2
|
import { leftPart } from "@servicestack/client"
|
|
3
3
|
|
|
4
4
|
let ext
|
|
@@ -119,8 +119,13 @@ const SkillSelector = {
|
|
|
119
119
|
skills
|
|
120
120
|
}))
|
|
121
121
|
|
|
122
|
-
// Sort groups
|
|
123
|
-
definedGroups.sort((a, b) =>
|
|
122
|
+
// Sort groups: writable (~/.llms/.agents) first, then alphabetically
|
|
123
|
+
definedGroups.sort((a, b) => {
|
|
124
|
+
const aEditable = a.name === '~/.llms/.agents'
|
|
125
|
+
const bEditable = b.name === '~/.llms/.agents'
|
|
126
|
+
if (aEditable !== bEditable) return aEditable ? -1 : 1
|
|
127
|
+
return a.name.localeCompare(b.name)
|
|
128
|
+
})
|
|
124
129
|
|
|
125
130
|
if (otherSkills.length > 0) {
|
|
126
131
|
definedGroups.push({ name: '', skills: otherSkills })
|
|
@@ -213,6 +218,559 @@ const SkillSelector = {
|
|
|
213
218
|
}
|
|
214
219
|
}
|
|
215
220
|
|
|
221
|
+
// Skills Page Component - Full management interface
|
|
222
|
+
const SkillPage = {
|
|
223
|
+
template: `
|
|
224
|
+
<div class="h-full flex flex-col">
|
|
225
|
+
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between flex-shrink-0">
|
|
226
|
+
<div>
|
|
227
|
+
<h1 class="text-xl font-bold text-gray-900 dark:text-gray-100">Manage Skills</h1>
|
|
228
|
+
<p class="text-sm text-gray-500 dark:text-gray-400">{{ Object.keys(skills).length }} skills available</p>
|
|
229
|
+
</div>
|
|
230
|
+
<div class="flex items-center gap-2">
|
|
231
|
+
<button @click="showCreateDialog = true" type="button" class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 transition-colors">
|
|
232
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd" /></svg>
|
|
233
|
+
Create Skill
|
|
234
|
+
</button>
|
|
235
|
+
<button @click="$ctx.togglePath('/skills/store', { left:false })" type="button" class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
|
236
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="size-4" viewBox="0 0 24 24"><path fill="currentColor" fill-rule="evenodd" d="M18.319 14.433A8.001 8.001 0 0 0 6.343 3.868a8 8 0 0 0 10.564 11.976l.043.045l4.242 4.243a1 1 0 1 0 1.415-1.415l-4.243-4.242zm-2.076-9.15a6 6 0 1 1-8.485 8.485a6 6 0 0 1 8.485-8.485" clip-rule="evenodd"/></svg>
|
|
237
|
+
Discover Skills
|
|
238
|
+
</button>
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
<div class="flex-1 flex min-h-0">
|
|
242
|
+
<div class="w-72 border-r border-gray-200 dark:border-gray-700 flex flex-col bg-gray-50 dark:bg-gray-800/50 flex-shrink-0">
|
|
243
|
+
<div class="p-2 border-b border-gray-200 dark:border-gray-700">
|
|
244
|
+
<input v-model="searchQuery" type="text" placeholder="Search installed skills..." class="w-full px-3 py-1.5 text-sm rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500" />
|
|
245
|
+
</div>
|
|
246
|
+
<div class="flex-1 overflow-y-auto">
|
|
247
|
+
<div v-for="group in skillGroups" :key="group.name" class="border-b border-gray-200 dark:border-gray-700 last:border-b-0">
|
|
248
|
+
<div class="px-3 py-2 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider bg-gray-100 dark:bg-gray-800 flex items-center justify-between"><span>{{ group.name || 'Other' }}</span><svg v-if="!isGroupEditable(group.name)" xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5 text-gray-400" viewBox="0 0 20 20" fill="currentColor" title="Read-only"><path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd"/></svg></div>
|
|
249
|
+
<div class="py-1">
|
|
250
|
+
<div v-for="skill in group.skills" :key="skill.name">
|
|
251
|
+
<div @click="toggleSkillExpand(skill)" class="select-none w-full px-3 py-2 text-left text-sm transition-colors flex items-center gap-2 cursor-pointer" :class="selectedSkill?.name === skill.name ? 'bg-blue-50 dark:bg-blue-900/20' : 'hover:bg-gray-100 dark:hover:bg-gray-700'">
|
|
252
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5 text-gray-400 transition-transform flex-shrink-0" :class="{ '-rotate-90': !isSkillExpanded(skill.name) }" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd" /></svg>
|
|
253
|
+
<span class="truncate font-medium flex-1" :class="selectedSkill?.name === skill.name ? 'text-blue-800 dark:text-blue-200' : 'text-gray-700 dark:text-gray-300'">{{ skill.name }}</span>
|
|
254
|
+
<span v-if="skill.files?.length" class="text-[10px] px-1.5 py-0.5 rounded-full bg-gray-200 dark:bg-gray-600 text-gray-600 dark:text-gray-300 font-medium">{{ skill.files.length }}</span>
|
|
255
|
+
</div>
|
|
256
|
+
<div v-show="isSkillExpanded(skill.name)" class="pl-4 bg-white dark:bg-gray-900/50">
|
|
257
|
+
<div v-if="isEditable(skill)" class="px-3 py-1 flex items-center gap-1 border-b border-gray-100 dark:border-gray-800">
|
|
258
|
+
<button @click.stop="selectSkill(skill); showAddFileDialog = true" type="button" title="Add File" class="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-400 hover:text-gray-600 text-xs">+ file</button>
|
|
259
|
+
<button @click.stop="selectSkill(skill); confirmDeleteSkill()" type="button" title="Delete Skill" class="p-1 rounded hover:bg-red-50 dark:hover:bg-red-900/20 text-gray-400 hover:text-red-500 text-xs ml-auto">delete</button>
|
|
260
|
+
</div>
|
|
261
|
+
<div v-for="node in getFileTree(skill)" :key="node.path">
|
|
262
|
+
<SkillFileNode :node="node" :skill="skill" :selected-file="selectedSkill?.name === skill.name ? selectedFile : null" :is-editable="isEditable(skill)" @select="onFileSelect(skill, $event)" @delete="onFileDelete(skill, $event)" />
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
</div>
|
|
267
|
+
</div>
|
|
268
|
+
</div>
|
|
269
|
+
</div>
|
|
270
|
+
<div class="flex-1 flex flex-col min-w-0">
|
|
271
|
+
<template v-if="selectedFile">
|
|
272
|
+
<div class="px-4 py-2 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between bg-gray-50 dark:bg-gray-800">
|
|
273
|
+
<div class="flex items-center gap-2 min-w-0">
|
|
274
|
+
<span class="text-xs text-gray-500 dark:text-gray-400">{{ selectedSkill?.name }} /</span>
|
|
275
|
+
<span class="text-sm font-mono text-gray-700 dark:text-gray-300 truncate">{{ selectedFile }}</span>
|
|
276
|
+
<span v-if="isEditing" class="text-xs px-1.5 py-0.5 rounded bg-yellow-100 dark:bg-yellow-900/40 text-yellow-700 dark:text-yellow-300">editing</span>
|
|
277
|
+
<span v-if="hasUnsavedChanges" class="text-xs text-orange-500">•</span>
|
|
278
|
+
</div>
|
|
279
|
+
<div class="flex items-center gap-2">
|
|
280
|
+
<template v-if="isEditing">
|
|
281
|
+
<button @click="saveFile" :disabled="saving" type="button" class="px-3 py-1 text-xs font-medium rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50">{{ saving ? 'Saving...' : 'Save' }}</button>
|
|
282
|
+
<button @click="cancelEdit" type="button" class="px-3 py-1 text-xs font-medium rounded border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">Cancel</button>
|
|
283
|
+
</template>
|
|
284
|
+
<template v-else-if="isEditable(selectedSkill)">
|
|
285
|
+
<button @click="startEdit" type="button" class="px-3 py-1 text-xs font-medium rounded border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">Edit</button>
|
|
286
|
+
</template>
|
|
287
|
+
</div>
|
|
288
|
+
</div>
|
|
289
|
+
<div class="flex-1 overflow-auto">
|
|
290
|
+
<div v-if="loadingFile" class="flex items-center justify-center h-full text-gray-500">Loading...</div>
|
|
291
|
+
<textarea v-else-if="isEditing" ref="editorRef" v-model="editContent" class="w-full h-full p-4 font-mono text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 resize-none focus:outline-none" spellcheck="false"></textarea>
|
|
292
|
+
<div v-else class="p-4 font-mono text-sm text-gray-900 dark:text-gray-100 whitespace-pre-wrap break-words">{{ fileContent }}</div>
|
|
293
|
+
</div>
|
|
294
|
+
</template>
|
|
295
|
+
<template v-else-if="selectedSkill">
|
|
296
|
+
<div class="p-6">
|
|
297
|
+
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">{{ selectedSkill.name }}</h2>
|
|
298
|
+
<p class="text-gray-600 dark:text-gray-400 mb-4">{{ selectedSkill.description }}</p>
|
|
299
|
+
<div class="grid grid-cols-2 gap-4 text-sm">
|
|
300
|
+
<div><span class="text-gray-500 dark:text-gray-400">Group:</span><span class="ml-2 text-gray-900 dark:text-gray-100">{{ selectedSkill.group }}</span></div>
|
|
301
|
+
<div><span class="text-gray-500 dark:text-gray-400">Files:</span><span class="ml-2 text-gray-900 dark:text-gray-100">{{ selectedSkill.files?.length || 0 }}</span></div>
|
|
302
|
+
<div class="col-span-2"><span class="text-gray-500 dark:text-gray-400">Location:</span><span class="ml-2 font-mono text-xs text-gray-900 dark:text-gray-100 break-all">{{ selectedSkill.location }}</span></div>
|
|
303
|
+
</div>
|
|
304
|
+
<div class="mt-6"><p class="text-sm text-gray-500 dark:text-gray-400">Select a file from the tree to view or edit its contents.</p></div>
|
|
305
|
+
</div>
|
|
306
|
+
</template>
|
|
307
|
+
<template v-else>
|
|
308
|
+
<div class="flex items-center justify-center h-full text-gray-500 dark:text-gray-400">
|
|
309
|
+
<div class="text-center">
|
|
310
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mx-auto mb-4 text-gray-300 dark:text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" /></svg>
|
|
311
|
+
<p>Select a skill to view its files</p>
|
|
312
|
+
</div>
|
|
313
|
+
</div>
|
|
314
|
+
</template>
|
|
315
|
+
</div>
|
|
316
|
+
</div>
|
|
317
|
+
<div v-if="showCreateDialog" class="fixed inset-0 z-100 flex items-center justify-center bg-black/50" @click.self="showCreateDialog = false">
|
|
318
|
+
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md mx-4">
|
|
319
|
+
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700"><h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Create New Skill</h3></div>
|
|
320
|
+
<div class="p-4 space-y-4">
|
|
321
|
+
<div>
|
|
322
|
+
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Skill Name</label>
|
|
323
|
+
<input :value="newSkillName" @input="onSkillNameInput" type="text" placeholder="my-new-skill" class="w-full px-3 py-2 text-sm rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500" @keyup.enter="createSkill" maxlength="40" />
|
|
324
|
+
<p class="mt-1 text-xs text-gray-500">Lowercase letters, numbers, and hyphens only. Max 40 characters.</p>
|
|
325
|
+
</div>
|
|
326
|
+
<div v-if="createError" class="p-3 rounded-md bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-sm">{{ createError }}</div>
|
|
327
|
+
</div>
|
|
328
|
+
<div class="px-4 py-3 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-2">
|
|
329
|
+
<button @click="showCreateDialog = false" type="button" class="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">Cancel</button>
|
|
330
|
+
<button @click="createSkill" :disabled="creating || !newSkillName.trim()" type="button" class="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50">{{ creating ? 'Creating...' : 'Create' }}</button>
|
|
331
|
+
</div>
|
|
332
|
+
</div>
|
|
333
|
+
</div>
|
|
334
|
+
<div v-if="showAddFileDialog" class="fixed inset-0 z-100 flex items-center justify-center bg-black/50" @click.self="showAddFileDialog = false">
|
|
335
|
+
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md mx-4">
|
|
336
|
+
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700"><h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Add New File</h3></div>
|
|
337
|
+
<div class="p-4 space-y-4">
|
|
338
|
+
<div>
|
|
339
|
+
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">File Path</label>
|
|
340
|
+
<input v-model="newFilePath" type="text" placeholder="scripts/my-script.py" class="w-full px-3 py-2 text-sm rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500" @keyup.enter="addFile" />
|
|
341
|
+
<p class="mt-1 text-xs text-gray-500">Relative path from skill root (e.g., scripts/helper.py)</p>
|
|
342
|
+
</div>
|
|
343
|
+
<div v-if="addFileError" class="p-3 rounded-md bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-sm">{{ addFileError }}</div>
|
|
344
|
+
</div>
|
|
345
|
+
<div class="px-4 py-3 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-2">
|
|
346
|
+
<button @click="showAddFileDialog = false" type="button" class="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">Cancel</button>
|
|
347
|
+
<button @click="addFile" :disabled="addingFile || !newFilePath.trim()" type="button" class="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50">{{ addingFile ? 'Adding...' : 'Add' }}</button>
|
|
348
|
+
</div>
|
|
349
|
+
</div>
|
|
350
|
+
</div>
|
|
351
|
+
<div v-if="deleteConfirm" class="fixed inset-0 z-100 flex items-center justify-center bg-black/50" @click.self="deleteConfirm = null">
|
|
352
|
+
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-sm mx-4">
|
|
353
|
+
<div class="p-4">
|
|
354
|
+
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">Confirm Delete</h3>
|
|
355
|
+
<p class="text-gray-600 dark:text-gray-400 text-sm">{{ deleteConfirm.type === 'skill' ? 'Delete skill "' + deleteConfirm.name + '"? This cannot be undone.' : 'Delete "' + deleteConfirm.path + '"?' }}</p>
|
|
356
|
+
</div>
|
|
357
|
+
<div class="px-4 py-3 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-2">
|
|
358
|
+
<button @click="deleteConfirm = null" type="button" class="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">Cancel</button>
|
|
359
|
+
<button @click="executeDelete" :disabled="deleting" type="button" class="px-4 py-2 text-sm font-medium rounded-md bg-red-600 text-white hover:bg-red-700 disabled:opacity-50">{{ deleting ? 'Deleting...' : 'Delete' }}</button>
|
|
360
|
+
</div>
|
|
361
|
+
</div>
|
|
362
|
+
</div>
|
|
363
|
+
</div>
|
|
364
|
+
`,
|
|
365
|
+
setup() {
|
|
366
|
+
const ctx = inject('ctx')
|
|
367
|
+
const searchQuery = ref('')
|
|
368
|
+
const selectedSkill = ref(null)
|
|
369
|
+
const selectedFile = ref(null)
|
|
370
|
+
const fileContent = ref('')
|
|
371
|
+
const editContent = ref('')
|
|
372
|
+
const isEditing = ref(false)
|
|
373
|
+
const loadingFile = ref(false)
|
|
374
|
+
const saving = ref(false)
|
|
375
|
+
const showCreateDialog = ref(false)
|
|
376
|
+
const showAddFileDialog = ref(false)
|
|
377
|
+
const deleteConfirm = ref(null)
|
|
378
|
+
const newSkillName = ref('')
|
|
379
|
+
const creating = ref(false)
|
|
380
|
+
const createError = ref('')
|
|
381
|
+
const newFilePath = ref('')
|
|
382
|
+
const addingFile = ref(false)
|
|
383
|
+
const addFileError = ref('')
|
|
384
|
+
const deleting = ref(false)
|
|
385
|
+
const editorRef = ref(null)
|
|
386
|
+
const expandedSkills = ref({})
|
|
387
|
+
const skills = computed(() => ctx.state.skills || {})
|
|
388
|
+
const skillGroups = computed(() => {
|
|
389
|
+
const grouped = {}
|
|
390
|
+
const query = searchQuery.value.toLowerCase()
|
|
391
|
+
Object.values(skills.value).forEach(skill => {
|
|
392
|
+
if (query && !skill.name.toLowerCase().includes(query) && !skill.description?.toLowerCase().includes(query)) return
|
|
393
|
+
const group = skill.group || 'Other'
|
|
394
|
+
if (!grouped[group]) grouped[group] = []
|
|
395
|
+
grouped[group].push(skill)
|
|
396
|
+
})
|
|
397
|
+
return Object.entries(grouped).sort((a, b) => {
|
|
398
|
+
const aEditable = a[0] === '~/.llms/.agents'
|
|
399
|
+
const bEditable = b[0] === '~/.llms/.agents'
|
|
400
|
+
if (aEditable !== bEditable) return aEditable ? -1 : 1
|
|
401
|
+
return a[0].localeCompare(b[0])
|
|
402
|
+
}).map(([name, skills]) => ({ name, skills: skills.sort((a, b) => a.name.localeCompare(b.name)) }))
|
|
403
|
+
})
|
|
404
|
+
function getFileTree(skill) {
|
|
405
|
+
if (!skill?.files) return []
|
|
406
|
+
const files = [...skill.files].sort()
|
|
407
|
+
const tree = []
|
|
408
|
+
const dirs = {}
|
|
409
|
+
files.forEach(filePath => {
|
|
410
|
+
const parts = filePath.split('/')
|
|
411
|
+
if (parts.length === 1) {
|
|
412
|
+
tree.push({ name: filePath, path: filePath, isFile: true })
|
|
413
|
+
} else {
|
|
414
|
+
const dirName = parts[0]
|
|
415
|
+
if (!dirs[dirName]) { dirs[dirName] = { name: dirName, path: dirName, isFile: false, children: [] }; tree.push(dirs[dirName]) }
|
|
416
|
+
dirs[dirName].children.push({ name: parts.slice(1).join('/'), path: filePath, isFile: true })
|
|
417
|
+
}
|
|
418
|
+
})
|
|
419
|
+
return tree.sort((a, b) => { if (a.isFile !== b.isFile) return a.isFile ? 1 : -1; return a.name.localeCompare(b.name) })
|
|
420
|
+
}
|
|
421
|
+
const hasUnsavedChanges = computed(() => isEditing.value && editContent.value !== fileContent.value)
|
|
422
|
+
function isGroupEditable(groupName) { return groupName === '~/.llms/.agents' }
|
|
423
|
+
function isEditable(skill) { return skill?.group === '~/.llms/.agents' }
|
|
424
|
+
function isSkillExpanded(name) { return !!expandedSkills.value[name] }
|
|
425
|
+
function toggleSkillExpand(skill) {
|
|
426
|
+
expandedSkills.value[skill.name] = !expandedSkills.value[skill.name]
|
|
427
|
+
if (expandedSkills.value[skill.name]) {
|
|
428
|
+
selectedSkill.value = skill
|
|
429
|
+
selectedFile.value = null
|
|
430
|
+
fileContent.value = ''
|
|
431
|
+
isEditing.value = false
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
function selectSkill(skill) {
|
|
435
|
+
if (hasUnsavedChanges.value && !confirm('Discard unsaved changes?')) return
|
|
436
|
+
selectedSkill.value = skill; selectedFile.value = null; fileContent.value = ''; isEditing.value = false
|
|
437
|
+
expandedSkills.value[skill.name] = true
|
|
438
|
+
}
|
|
439
|
+
async function selectFile(filePath) {
|
|
440
|
+
if (hasUnsavedChanges.value && !confirm('Discard unsaved changes?')) return
|
|
441
|
+
selectedFile.value = filePath; isEditing.value = false; loadingFile.value = true
|
|
442
|
+
try {
|
|
443
|
+
const res = await ext.getJson(`/file/${selectedSkill.value.name}/${filePath}`)
|
|
444
|
+
fileContent.value = res.response ? res.response.content : `Error: ${res.error?.message || 'Failed to load'}`
|
|
445
|
+
} catch (e) { fileContent.value = `Error: ${e.message}` }
|
|
446
|
+
finally { loadingFile.value = false }
|
|
447
|
+
}
|
|
448
|
+
function onFileSelect(skill, filePath) {
|
|
449
|
+
if (hasUnsavedChanges.value && !confirm('Discard unsaved changes?')) return
|
|
450
|
+
selectedSkill.value = skill
|
|
451
|
+
selectFile(filePath)
|
|
452
|
+
}
|
|
453
|
+
function onFileDelete(skill, filePath) {
|
|
454
|
+
selectedSkill.value = skill
|
|
455
|
+
confirmDeleteFile(filePath)
|
|
456
|
+
}
|
|
457
|
+
function startEdit() { editContent.value = fileContent.value; isEditing.value = true; nextTick(() => editorRef.value?.focus()) }
|
|
458
|
+
function cancelEdit() { if (hasUnsavedChanges.value && !confirm('Discard changes?')) return; isEditing.value = false; editContent.value = '' }
|
|
459
|
+
async function saveFile() {
|
|
460
|
+
saving.value = true
|
|
461
|
+
try {
|
|
462
|
+
const res = await ext.postJson(`/file/${selectedSkill.value.name}`, { path: selectedFile.value, content: editContent.value })
|
|
463
|
+
if (res.response) { fileContent.value = editContent.value; isEditing.value = false; if (res.response.skill) { ctx.setState({ skills: { ...skills.value, [res.response.skill.name]: res.response.skill } }); selectedSkill.value = res.response.skill } }
|
|
464
|
+
else { alert(`Error: ${res.error?.message || 'Unknown'}`) }
|
|
465
|
+
} catch (e) { alert(`Error: ${e.message}`) }
|
|
466
|
+
finally { saving.value = false }
|
|
467
|
+
}
|
|
468
|
+
function onSkillNameInput(e) {
|
|
469
|
+
// Sanitize to lowercase letters, numbers, and hyphens only
|
|
470
|
+
const sanitized = e.target.value.toLowerCase().replace(/[^a-z0-9-\s]/g, '').replace(/\s+/g, '-')
|
|
471
|
+
newSkillName.value = sanitized
|
|
472
|
+
// Update input value if sanitization changed it
|
|
473
|
+
if (e.target.value !== sanitized) {
|
|
474
|
+
e.target.value = sanitized
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
async function createSkill() {
|
|
478
|
+
createError.value = ''; creating.value = true
|
|
479
|
+
try {
|
|
480
|
+
const res = await ext.postJson('/create', { name: newSkillName.value.trim() })
|
|
481
|
+
if (res.response) {
|
|
482
|
+
ctx.setState({ skills: { ...skills.value, [res.response.skill.name]: res.response.skill } })
|
|
483
|
+
selectedSkill.value = res.response.skill
|
|
484
|
+
expandedSkills.value[res.response.skill.name] = true
|
|
485
|
+
showCreateDialog.value = false
|
|
486
|
+
newSkillName.value = ''
|
|
487
|
+
}
|
|
488
|
+
else { createError.value = res.error?.message || 'Failed' }
|
|
489
|
+
} catch (e) { createError.value = e.message }
|
|
490
|
+
finally { creating.value = false }
|
|
491
|
+
}
|
|
492
|
+
async function addFile() {
|
|
493
|
+
addFileError.value = ''; addingFile.value = true
|
|
494
|
+
try {
|
|
495
|
+
const res = await ext.postJson(`/file/${selectedSkill.value.name}`, { path: newFilePath.value.trim(), content: '' })
|
|
496
|
+
if (res.response) { if (res.response.skill) { ctx.setState({ skills: { ...skills.value, [res.response.skill.name]: res.response.skill } }); selectedSkill.value = res.response.skill }; selectedFile.value = newFilePath.value.trim(); fileContent.value = ''; showAddFileDialog.value = false; newFilePath.value = ''; startEdit() }
|
|
497
|
+
else { addFileError.value = res.error?.message || 'Failed' }
|
|
498
|
+
} catch (e) { addFileError.value = e.message }
|
|
499
|
+
finally { addingFile.value = false }
|
|
500
|
+
}
|
|
501
|
+
function confirmDeleteSkill() { deleteConfirm.value = { type: 'skill', name: selectedSkill.value.name } }
|
|
502
|
+
function confirmDeleteFile(filePath) { deleteConfirm.value = { type: 'file', path: filePath, skillName: selectedSkill.value.name } }
|
|
503
|
+
async function executeDelete() {
|
|
504
|
+
deleting.value = true
|
|
505
|
+
try {
|
|
506
|
+
if (deleteConfirm.value.type === 'skill') {
|
|
507
|
+
const res = await ext.deleteJson(`/skill/${deleteConfirm.value.name}`)
|
|
508
|
+
if (res.response?.deleted) { const s = { ...skills.value }; delete s[deleteConfirm.value.name]; ctx.setState({ skills: s }); selectedSkill.value = null; selectedFile.value = null; delete expandedSkills.value[deleteConfirm.value.name] }
|
|
509
|
+
else { alert(`Error: ${res.error?.message || 'Failed'}`) }
|
|
510
|
+
} else {
|
|
511
|
+
const res = await ext.deleteJson(`/file/${deleteConfirm.value.skillName}?path=${encodeURIComponent(deleteConfirm.value.path)}`)
|
|
512
|
+
if (res.response) { if (res.response.skill) { ctx.setState({ skills: { ...skills.value, [res.response.skill.name]: res.response.skill } }); selectedSkill.value = res.response.skill }; if (selectedFile.value === deleteConfirm.value.path) { selectedFile.value = null; fileContent.value = '' } }
|
|
513
|
+
else { alert(`Error: ${res.error?.message || 'Failed'}`) }
|
|
514
|
+
}
|
|
515
|
+
} catch (e) { alert(`Error: ${e.message}`) }
|
|
516
|
+
finally { deleting.value = false; deleteConfirm.value = null }
|
|
517
|
+
}
|
|
518
|
+
return { skills, searchQuery, skillGroups, selectedSkill, selectedFile, fileContent, editContent, isEditing, loadingFile, saving, hasUnsavedChanges, editorRef, showCreateDialog, showAddFileDialog, deleteConfirm, newSkillName, creating, createError, newFilePath, addingFile, addFileError, deleting, isEditable, isGroupEditable, selectSkill, selectFile, startEdit, cancelEdit, saveFile, createSkill, addFile, confirmDeleteSkill, confirmDeleteFile, executeDelete, expandedSkills, isSkillExpanded, toggleSkillExpand, getFileTree, onFileSelect, onFileDelete, onSkillNameInput }
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const FileTreeNode = {
|
|
523
|
+
name: 'FileTreeNode',
|
|
524
|
+
template: `
|
|
525
|
+
<div>
|
|
526
|
+
<div v-if="node.isFile" @click="$emit('select', node.path)" class="group flex items-center gap-2 px-3 py-1 text-sm cursor-pointer transition-colors" :class="selectedFile === node.path ? 'bg-blue-100 dark:bg-blue-900/40 text-blue-800 dark:text-blue-200' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'">
|
|
527
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-gray-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
|
|
528
|
+
<span class="truncate flex-1">{{ node.name }}</span>
|
|
529
|
+
<button v-if="isEditable && node.path.toLowerCase() !== 'skill.md'" @click.stop="$emit('delete', node.path)" type="button" class="opacity-0 group-hover:opacity-100 p-0.5 rounded hover:bg-red-100 dark:hover:bg-red-900/30 text-gray-400 hover:text-red-600">
|
|
530
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" /></svg>
|
|
531
|
+
</button>
|
|
532
|
+
</div>
|
|
533
|
+
<div v-else>
|
|
534
|
+
<div @click="expanded = !expanded" class="flex items-center gap-2 px-3 py-1 text-sm cursor-pointer text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700">
|
|
535
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-gray-400 transition-transform" :class="{ '-rotate-90': !expanded }" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /></svg>
|
|
536
|
+
<span class="font-medium">{{ node.name }}/</span>
|
|
537
|
+
</div>
|
|
538
|
+
<div v-show="expanded" class="pl-4">
|
|
539
|
+
<FileTreeNode v-for="child in node.children" :key="child.path" :node="child" :selected-file="selectedFile" :is-editable="isEditable" @select="$emit('select', $event)" @delete="$emit('delete', $event)" />
|
|
540
|
+
</div>
|
|
541
|
+
</div>
|
|
542
|
+
</div>
|
|
543
|
+
`,
|
|
544
|
+
props: { node: { type: Object, required: true }, selectedFile: { type: String, default: null }, isEditable: { type: Boolean, default: false } },
|
|
545
|
+
emits: ['select', 'delete'],
|
|
546
|
+
setup() { return { expanded: ref(true) } }
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const SkillFileNode = {
|
|
550
|
+
name: 'SkillFileNode',
|
|
551
|
+
template: `
|
|
552
|
+
<div>
|
|
553
|
+
<div v-if="node.isFile" @click="$emit('select', node.path)" class="group flex items-center gap-1.5 px-2 py-0.5 text-xs cursor-pointer transition-colors" :class="selectedFile === node.path ? 'bg-blue-100 dark:bg-blue-900/40 text-blue-800 dark:text-blue-200' : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'">
|
|
554
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 text-gray-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
|
|
555
|
+
<span class="select-none truncate flex-1">{{ node.name }}</span>
|
|
556
|
+
<button v-if="isEditable && node.path.toLowerCase() !== 'skill.md'" @click.stop="$emit('delete', node.path)" type="button" class="opacity-0 group-hover:opacity-100 p-0.5 rounded hover:bg-red-100 dark:hover:bg-red-900/30 text-gray-400 hover:text-red-500">
|
|
557
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-2.5 w-2.5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" /></svg>
|
|
558
|
+
</button>
|
|
559
|
+
</div>
|
|
560
|
+
<div v-else>
|
|
561
|
+
<div @click="expanded = !expanded" class="flex items-center gap-1.5 px-2 py-0.5 text-xs cursor-pointer text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700">
|
|
562
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 text-gray-400 transition-transform" :class="{ '-rotate-90': !expanded }" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /></svg>
|
|
563
|
+
<span class="select-none font-medium">{{ node.name }}/</span>
|
|
564
|
+
</div>
|
|
565
|
+
<div v-show="expanded" class="pl-3">
|
|
566
|
+
<SkillFileNode v-for="child in node.children" :key="child.path" :node="child" :skill="skill" :selected-file="selectedFile" :is-editable="isEditable" @select="$emit('select', $event)" @delete="$emit('delete', $event)" />
|
|
567
|
+
</div>
|
|
568
|
+
</div>
|
|
569
|
+
</div>
|
|
570
|
+
`,
|
|
571
|
+
props: { node: { type: Object, required: true }, skill: { type: Object, required: true }, selectedFile: { type: String, default: null }, isEditable: { type: Boolean, default: false } },
|
|
572
|
+
emits: ['select', 'delete'],
|
|
573
|
+
setup() { return { expanded: ref(true) } }
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Skill Store Component - Search and install available skills
|
|
577
|
+
const SkillStore = {
|
|
578
|
+
template: `
|
|
579
|
+
<div class="h-full flex flex-col">
|
|
580
|
+
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between flex-shrink-0">
|
|
581
|
+
<div>
|
|
582
|
+
<h1 class="text-xl font-bold text-gray-900 dark:text-gray-100">Skill Store</h1>
|
|
583
|
+
<p class="text-sm text-gray-500 dark:text-gray-400">{{ total.toLocaleString() }} skills available</p>
|
|
584
|
+
</div>
|
|
585
|
+
<div class="flex items-center gap-2">
|
|
586
|
+
<button @click="$ctx.togglePath('/skills', { left:false })" type="button" class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
|
587
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clip-rule="evenodd" /></svg>
|
|
588
|
+
Installed Skills
|
|
589
|
+
</button>
|
|
590
|
+
</div>
|
|
591
|
+
</div>
|
|
592
|
+
<div class="p-4 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
|
|
593
|
+
<div class="relative">
|
|
594
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
595
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
596
|
+
</svg>
|
|
597
|
+
<input v-model="searchQuery" @input="onSearchInput" type="text" placeholder="Search available skills..."
|
|
598
|
+
class="w-full pl-10 pr-4 py-2.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
|
|
599
|
+
<div v-if="searching" class="absolute right-3 top-1/2 -translate-y-1/2">
|
|
600
|
+
<svg class="animate-spin h-5 w-5 text-blue-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
601
|
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
602
|
+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
603
|
+
</svg>
|
|
604
|
+
</div>
|
|
605
|
+
</div>
|
|
606
|
+
</div>
|
|
607
|
+
<div class="flex-1 overflow-y-auto">
|
|
608
|
+
<div v-if="results.length === 0 && !searching" class="flex items-center justify-center h-full text-gray-500 dark:text-gray-400">
|
|
609
|
+
<div class="text-center">
|
|
610
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mx-auto mb-4 text-gray-300 dark:text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
611
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
612
|
+
</svg>
|
|
613
|
+
<p>{{ searchQuery ? 'No skills found' : 'Search for skills to install' }}</p>
|
|
614
|
+
</div>
|
|
615
|
+
</div>
|
|
616
|
+
<div v-else class="divide-y divide-gray-200 dark:divide-gray-700">
|
|
617
|
+
<div v-for="skill in results" :key="skill.id" class="p-4 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
|
|
618
|
+
<div class="flex items-start justify-between gap-4">
|
|
619
|
+
<div class="min-w-0 flex-1">
|
|
620
|
+
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">{{ skill.name }}</h3>
|
|
621
|
+
<div class="mt-1 flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400">
|
|
622
|
+
<span class="inline-flex items-center gap-1">
|
|
623
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
|
|
624
|
+
<path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
|
|
625
|
+
</svg>
|
|
626
|
+
{{ formatInstalls(skill.installs) }}
|
|
627
|
+
</span>
|
|
628
|
+
<span class="inline-flex items-center gap-1 truncate" :title="skill.topSource">
|
|
629
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor">
|
|
630
|
+
<path fill-rule="evenodd" d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z" clip-rule="evenodd" />
|
|
631
|
+
</svg>
|
|
632
|
+
{{ skill.topSource }}
|
|
633
|
+
</span>
|
|
634
|
+
</div>
|
|
635
|
+
</div>
|
|
636
|
+
<div class="flex-shrink-0">
|
|
637
|
+
<button v-if="isInstalled(skill.id)" disabled type="button"
|
|
638
|
+
class="px-3 py-1.5 text-xs font-medium rounded-md bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed">
|
|
639
|
+
Installed
|
|
640
|
+
</button>
|
|
641
|
+
<button v-else-if="installing.has(skill.id)" disabled type="button"
|
|
642
|
+
class="px-3 py-1.5 text-xs font-medium rounded-md bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 cursor-wait inline-flex items-center gap-1.5">
|
|
643
|
+
<svg class="animate-spin h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
644
|
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
645
|
+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
646
|
+
</svg>
|
|
647
|
+
Installing...
|
|
648
|
+
</button>
|
|
649
|
+
<button v-else @click="installSkill(skill)" type="button"
|
|
650
|
+
class="px-3 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 transition-colors">
|
|
651
|
+
Install
|
|
652
|
+
</button>
|
|
653
|
+
</div>
|
|
654
|
+
</div>
|
|
655
|
+
<div v-if="installError[skill.id]" class="mt-2 text-xs text-red-600 dark:text-red-400">
|
|
656
|
+
{{ installError[skill.id] }}
|
|
657
|
+
</div>
|
|
658
|
+
</div>
|
|
659
|
+
</div>
|
|
660
|
+
<div v-if="results.length > 0 && results.length < total" class="p-4 flex justify-center">
|
|
661
|
+
<button @click="loadMore" :disabled="searching" type="button"
|
|
662
|
+
class="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors disabled:opacity-50">
|
|
663
|
+
{{ searching ? 'Loading...' : 'Load More' }}
|
|
664
|
+
</button>
|
|
665
|
+
</div>
|
|
666
|
+
</div>
|
|
667
|
+
</div>
|
|
668
|
+
`,
|
|
669
|
+
setup() {
|
|
670
|
+
const ctx = inject('ctx')
|
|
671
|
+
const searchQuery = ref('')
|
|
672
|
+
const results = ref([])
|
|
673
|
+
const total = ref(0)
|
|
674
|
+
const searching = ref(false)
|
|
675
|
+
const installing = ref(new Set())
|
|
676
|
+
const installError = ref({})
|
|
677
|
+
const offset = ref(0)
|
|
678
|
+
const limit = 50
|
|
679
|
+
let searchTimeout = null
|
|
680
|
+
|
|
681
|
+
const installedSkills = computed(() => ctx.state.skills || {})
|
|
682
|
+
|
|
683
|
+
function isInstalled(skillId) {
|
|
684
|
+
// Check if skill is already installed by comparing id/name
|
|
685
|
+
return Object.values(installedSkills.value).some(s =>
|
|
686
|
+
s.name === skillId || s.name === skillId.replace(/-/g, ' ')
|
|
687
|
+
)
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function formatInstalls(count) {
|
|
691
|
+
if (count >= 1000000) return (count / 1000000).toFixed(1) + 'M'
|
|
692
|
+
if (count >= 1000) return (count / 1000).toFixed(1) + 'k'
|
|
693
|
+
return count.toString()
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
async function search(append = false) {
|
|
697
|
+
searching.value = true
|
|
698
|
+
try {
|
|
699
|
+
const params = new URLSearchParams({
|
|
700
|
+
q: searchQuery.value,
|
|
701
|
+
limit: limit.toString(),
|
|
702
|
+
offset: (append ? offset.value : 0).toString()
|
|
703
|
+
})
|
|
704
|
+
const res = await ext.getJson(`/search?${params}`)
|
|
705
|
+
if (res.response) {
|
|
706
|
+
if (append) {
|
|
707
|
+
results.value = [...results.value, ...res.response.results]
|
|
708
|
+
} else {
|
|
709
|
+
results.value = res.response.results
|
|
710
|
+
offset.value = 0
|
|
711
|
+
}
|
|
712
|
+
total.value = res.response.total
|
|
713
|
+
offset.value = results.value.length
|
|
714
|
+
}
|
|
715
|
+
} catch (e) {
|
|
716
|
+
console.error('Search failed:', e)
|
|
717
|
+
} finally {
|
|
718
|
+
searching.value = false
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function onSearchInput() {
|
|
723
|
+
if (searchTimeout) clearTimeout(searchTimeout)
|
|
724
|
+
searchTimeout = setTimeout(() => search(false), 300)
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function loadMore() {
|
|
728
|
+
search(true)
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
async function installSkill(skill) {
|
|
732
|
+
installing.value = new Set([...installing.value, skill.id])
|
|
733
|
+
delete installError.value[skill.id]
|
|
734
|
+
|
|
735
|
+
try {
|
|
736
|
+
const res = await ext.postJson(`/install/${skill.id}`)
|
|
737
|
+
if (res.error) {
|
|
738
|
+
installError.value[skill.id] = res.error.message || 'Installation failed'
|
|
739
|
+
} else {
|
|
740
|
+
// Refresh installed skills
|
|
741
|
+
const api = await ext.getJson('/')
|
|
742
|
+
if (api.response) {
|
|
743
|
+
ctx.setState({ skills: api.response })
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
} catch (e) {
|
|
747
|
+
installError.value[skill.id] = e.message || 'Installation failed'
|
|
748
|
+
} finally {
|
|
749
|
+
const newSet = new Set(installing.value)
|
|
750
|
+
newSet.delete(skill.id)
|
|
751
|
+
installing.value = newSet
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// Initial load - show popular skills
|
|
756
|
+
search(false)
|
|
757
|
+
|
|
758
|
+
return {
|
|
759
|
+
searchQuery,
|
|
760
|
+
results,
|
|
761
|
+
total,
|
|
762
|
+
searching,
|
|
763
|
+
installing,
|
|
764
|
+
installError,
|
|
765
|
+
isInstalled,
|
|
766
|
+
formatInstalls,
|
|
767
|
+
onSearchInput,
|
|
768
|
+
loadMore,
|
|
769
|
+
installSkill
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
216
774
|
function codeFragment(s) {
|
|
217
775
|
return "`" + s + "`"
|
|
218
776
|
}
|
|
@@ -262,10 +820,20 @@ export default {
|
|
|
262
820
|
install(ctx) {
|
|
263
821
|
ext = ctx.scope("skills")
|
|
264
822
|
|
|
265
|
-
ctx.components({ SkillSelector })
|
|
823
|
+
ctx.components({ SkillSelector, SkillPage, SkillStore, FileTreeNode, SkillFileNode })
|
|
266
824
|
|
|
267
825
|
const svg = (attrs, title) => `<svg ${attrs} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">${title ? "<title>" + title + "</title>" : ''}<path fill="currentColor" d="M20 17a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H9.46c.35.61.54 1.3.54 2h10v11h-9v2m4-10v2H9v13H7v-6H5v6H3v-8H1.5V9a2 2 0 0 1 2-2zM8 4a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2a2 2 0 0 1 2 2"/></svg>`
|
|
268
826
|
|
|
827
|
+
ctx.setLeftIcons({
|
|
828
|
+
skills: {
|
|
829
|
+
component: { template: svg([`@click="$ctx.togglePath('/skills', { left:false })"`].join(' ')) },
|
|
830
|
+
isActive({ path }) { return path === '/skills' }
|
|
831
|
+
}
|
|
832
|
+
})
|
|
833
|
+
|
|
834
|
+
ctx.routes.push({ path: '/skills', component: SkillPage, meta: { title: 'Manage Skills' } })
|
|
835
|
+
ctx.routes.push({ path: '/skills/store', component: SkillStore, meta: { title: 'Skill Store' } })
|
|
836
|
+
|
|
269
837
|
ctx.setTopIcons({
|
|
270
838
|
skills: {
|
|
271
839
|
component: {
|
|
@@ -49,12 +49,12 @@ Throughout the entire workflow, operate in read-only mode. Do not write or updat
|
|
|
49
49
|
- Out:
|
|
50
50
|
|
|
51
51
|
## Action items
|
|
52
|
-
[ ] <Step 1>
|
|
53
|
-
[ ] <Step 2>
|
|
54
|
-
[ ] <Step 3>
|
|
55
|
-
[ ] <Step 4>
|
|
56
|
-
[ ] <Step 5>
|
|
57
|
-
[ ] <Step 6>
|
|
52
|
+
- [ ] <Step 1>
|
|
53
|
+
- [ ] <Step 2>
|
|
54
|
+
- [ ] <Step 3>
|
|
55
|
+
- [ ] <Step 4>
|
|
56
|
+
- [ ] <Step 5>
|
|
57
|
+
- [ ] <Step 6>
|
|
58
58
|
|
|
59
59
|
## Open questions
|
|
60
60
|
- <Question 1>
|