forked from EduCraft/curriculum-project-hub
ff990b4caf
- parse skill package zips client-side, mirroring backend ingestion limits (ADR-0018) - add zip upload flow on skills page and zip replace in SkillEditor - group role skill bindings by the shared management folder tree (ADR-0028) - add fflate dependency
409 lines
13 KiB
Svelte
409 lines
13 KiB
Svelte
<script lang="ts">
|
||
import { page } from '$app/state';
|
||
import { api, type AgentConfigFolderRow, type AgentSkillRow, type SkillFileEntry } from '$lib/api';
|
||
import { session } from '$lib/session';
|
||
import { resolveOrg } from '$lib/org';
|
||
import { parseSkillZip } from '$lib/skillZip';
|
||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||
import LoadingState from '$lib/components/LoadingState.svelte';
|
||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||
import SkillEditor from '$lib/components/SkillEditor.svelte';
|
||
import AgentConfigFolderNav from '$lib/components/AgentConfigFolderNav.svelte';
|
||
import Modal from '$lib/components/Modal.svelte';
|
||
import SelectField from '$lib/components/SelectField.svelte';
|
||
import { toastError, toastSuccess } from '$lib/toast';
|
||
|
||
const org = $derived(resolveOrg($session.me, page.url.search));
|
||
const slug = $derived(org?.slug ?? '');
|
||
|
||
let skills = $state<AgentSkillRow[]>([]);
|
||
let folders = $state<AgentConfigFolderRow[]>([]);
|
||
let loading = $state(true);
|
||
let error = $state<string | null>(null);
|
||
|
||
/** 'all' | 'unfiled' | folder id (ADR-0028: transparent grouping filter) */
|
||
let selectedFolder = $state<string>('all');
|
||
|
||
let showNewSkill = $state(false);
|
||
let newSkillName = $state('');
|
||
let newSkillVersion = $state('0.1.0');
|
||
let newSkillDescription = $state('');
|
||
let creating = $state(false);
|
||
|
||
let showZipUpload = $state(false);
|
||
let zipVersion = $state('0.1.0');
|
||
let zipUploading = $state(false);
|
||
let zipInputEl = $state<HTMLInputElement | null>(null);
|
||
|
||
let showFolderModal = $state(false);
|
||
let folderModalMode = $state<'create' | 'rename'>('create');
|
||
let folderModalId = $state<string | null>(null);
|
||
let folderName = $state('');
|
||
let folderParent = $state('');
|
||
let savingFolder = $state(false);
|
||
|
||
async function load() {
|
||
loading = true;
|
||
error = null;
|
||
try {
|
||
const [s, f] = await Promise.all([api.agentSkills(slug), api.agentConfigFolders(slug)]);
|
||
skills = s.skills;
|
||
folders = f.folders;
|
||
} catch (err) {
|
||
error = err instanceof Error ? err.message : String(err);
|
||
} finally {
|
||
loading = false;
|
||
}
|
||
}
|
||
|
||
const counts = $derived.by(() => {
|
||
const map: Record<string, number> = {};
|
||
for (const s of skills) {
|
||
if (s.folderId) map[s.folderId] = (map[s.folderId] ?? 0) + 1;
|
||
}
|
||
return map;
|
||
});
|
||
const unfiledCount = $derived(skills.filter((s) => !s.folderId).length);
|
||
const visibleSkills = $derived(
|
||
selectedFolder === 'all'
|
||
? skills
|
||
: selectedFolder === 'unfiled'
|
||
? skills.filter((s) => !s.folderId)
|
||
: skills.filter((s) => s.folderId === selectedFolder),
|
||
);
|
||
|
||
function folderPath(f: AgentConfigFolderRow): string {
|
||
const parts: string[] = [f.name];
|
||
let cur: AgentConfigFolderRow | undefined = f;
|
||
while (cur?.parentId) {
|
||
const parent = folders.find((x) => x.id === cur!.parentId);
|
||
if (!parent) break;
|
||
parts.unshift(parent.name);
|
||
cur = parent;
|
||
}
|
||
return parts.join(' / ');
|
||
}
|
||
|
||
/** Self + descendant ids of a folder — excluded as move targets in the rename modal. */
|
||
function subtreeIds(folderId: string): Set<string> {
|
||
const ids = new Set<string>([folderId]);
|
||
let grew = true;
|
||
while (grew) {
|
||
grew = false;
|
||
for (const f of folders) {
|
||
if (f.parentId && ids.has(f.parentId) && !ids.has(f.id)) {
|
||
ids.add(f.id);
|
||
grew = true;
|
||
}
|
||
}
|
||
}
|
||
return ids;
|
||
}
|
||
|
||
const folderItems = $derived([
|
||
{ value: '', label: '(未分类)' },
|
||
...folders.map((f) => ({ value: f.id, label: folderPath(f) })),
|
||
]);
|
||
|
||
const moveTargetItems = $derived.by(() => {
|
||
if (folderModalMode !== 'rename' || folderModalId === null) {
|
||
return [{ value: '', label: '(根)' }, ...folders.map((f) => ({ value: f.id, label: folderPath(f) }))];
|
||
}
|
||
const excluded = subtreeIds(folderModalId);
|
||
return [
|
||
{ value: '', label: '(根)' },
|
||
...folders.filter((f) => !excluded.has(f.id)).map((f) => ({ value: f.id, label: folderPath(f) })),
|
||
];
|
||
});
|
||
|
||
async function createSkill() {
|
||
const name = newSkillName.trim();
|
||
if (name === '') {
|
||
toastError('技能名称不能为空');
|
||
return;
|
||
}
|
||
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(name)) {
|
||
toastError('技能名称仅允许小写字母、数字和连字符,且以字母或数字开头');
|
||
return;
|
||
}
|
||
const version = newSkillVersion.trim();
|
||
if (version === '') {
|
||
toastError('版本号不能为空');
|
||
return;
|
||
}
|
||
creating = true;
|
||
try {
|
||
const manifest = buildManifest(name, newSkillDescription.trim());
|
||
const files: SkillFileEntry[] = [{ path: 'SKILL.md', content: manifest }];
|
||
const result = await api.installAgentSkill(slug, name, { version, files });
|
||
if (selectedFolder !== 'all' && selectedFolder !== 'unfiled') {
|
||
await api.setAgentSkillFolder(slug, result.name, selectedFolder);
|
||
}
|
||
toastSuccess(`技能 ${result.name} 已创建`);
|
||
newSkillName = '';
|
||
newSkillDescription = '';
|
||
showNewSkill = false;
|
||
await load();
|
||
} catch (err) {
|
||
toastError(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
creating = false;
|
||
}
|
||
}
|
||
|
||
function buildManifest(name: string, description: string): string {
|
||
const desc = description === '' ? name : description;
|
||
return `---\nname: ${name}\ndescription: ${desc}\n---\n# ${name}\n\n`;
|
||
}
|
||
|
||
async function uploadZip(fileList: FileList | null) {
|
||
const file = fileList?.[0];
|
||
if (!file) return;
|
||
const version = zipVersion.trim();
|
||
if (version === '') {
|
||
toastError('版本号不能为空');
|
||
if (zipInputEl) zipInputEl.value = '';
|
||
return;
|
||
}
|
||
zipUploading = true;
|
||
try {
|
||
const buf = new Uint8Array(await file.arrayBuffer());
|
||
const parsed = parseSkillZip(buf);
|
||
const result = await api.installAgentSkill(slug, parsed.name, {
|
||
version,
|
||
files: parsed.files,
|
||
});
|
||
if (selectedFolder !== 'all' && selectedFolder !== 'unfiled') {
|
||
await api.setAgentSkillFolder(slug, result.name, selectedFolder);
|
||
}
|
||
toastSuccess(`技能 ${result.name} 已从 zip 安装(${parsed.files.length} 个文件)`);
|
||
showZipUpload = false;
|
||
await load();
|
||
} catch (err) {
|
||
toastError(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
zipUploading = false;
|
||
if (zipInputEl) zipInputEl.value = '';
|
||
}
|
||
}
|
||
|
||
function onInstalled(_result: { id: string; name: string; contentDigest: string }) {
|
||
load();
|
||
}
|
||
|
||
function onDisabled(_name: string) {
|
||
load();
|
||
}
|
||
|
||
function onSkillFolderChanged(name: string, folderId: string | null) {
|
||
skills = skills.map((s) => (s.name === name ? { ...s, folderId } : s));
|
||
}
|
||
|
||
function openCreateFolder(parentId: string | null) {
|
||
folderModalMode = 'create';
|
||
folderModalId = null;
|
||
folderName = '';
|
||
folderParent = parentId ?? '';
|
||
showFolderModal = true;
|
||
}
|
||
|
||
function openRenameFolder(folder: AgentConfigFolderRow) {
|
||
folderModalMode = 'rename';
|
||
folderModalId = folder.id;
|
||
folderName = folder.name;
|
||
folderParent = folder.parentId ?? '';
|
||
showFolderModal = true;
|
||
}
|
||
|
||
async function submitFolderModal() {
|
||
const name = folderName.trim();
|
||
if (name === '') {
|
||
toastError('文件夹名称不能为空');
|
||
return;
|
||
}
|
||
savingFolder = true;
|
||
try {
|
||
if (folderModalMode === 'create') {
|
||
await api.createAgentConfigFolder(slug, {
|
||
name,
|
||
...(folderParent !== '' ? { parentId: folderParent } : {}),
|
||
});
|
||
toastSuccess('文件夹已创建');
|
||
} else if (folderModalId !== null) {
|
||
await api.patchAgentConfigFolder(slug, folderModalId, {
|
||
name,
|
||
parentId: folderParent === '' ? null : folderParent,
|
||
});
|
||
toastSuccess('文件夹已更新');
|
||
}
|
||
showFolderModal = false;
|
||
await load();
|
||
} catch (err) {
|
||
toastError(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
savingFolder = false;
|
||
}
|
||
}
|
||
|
||
async function deleteFolder(folder: AgentConfigFolderRow) {
|
||
if (!confirm(`删除文件夹「${folder.name}」? 仅空文件夹可删除。`)) return;
|
||
try {
|
||
await api.deleteAgentConfigFolder(slug, folder.id);
|
||
if (selectedFolder === folder.id) selectedFolder = 'all';
|
||
toastSuccess('文件夹已删除');
|
||
await load();
|
||
} catch (err) {
|
||
toastError(err instanceof Error ? err.message : String(err));
|
||
}
|
||
}
|
||
|
||
$effect(() => {
|
||
if (slug) load();
|
||
});
|
||
</script>
|
||
|
||
<PageHeader
|
||
title="技能"
|
||
description="技能是组织级 Agent 能力包:一个包含 SKILL.md manifest 的目录。文件夹仅作管理分组,不影响技能解析与绑定。"
|
||
/>
|
||
|
||
{#if loading}
|
||
<LoadingState />
|
||
{:else if error}
|
||
<ErrorBanner message={error} onretry={load} />
|
||
{:else}
|
||
<div class="grid gap-4 lg:grid-cols-[15rem_1fr]">
|
||
<div class="h-fit lg:sticky lg:top-4">
|
||
<AgentConfigFolderNav
|
||
{folders}
|
||
selected={selectedFolder}
|
||
{counts}
|
||
totalCount={skills.length}
|
||
{unfiledCount}
|
||
onselect={(id) => (selectedFolder = id)}
|
||
oncreate={openCreateFolder}
|
||
onrename={openRenameFolder}
|
||
ondelete={deleteFolder}
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<div class="saas-card-pad mb-6 space-y-4">
|
||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||
<h2 class="saas-section-title">新建技能</h2>
|
||
<div class="flex flex-wrap gap-3">
|
||
<button
|
||
type="button"
|
||
class="text-sm text-primary-700 hover:text-primary-900"
|
||
onclick={() => {
|
||
showZipUpload = !showZipUpload;
|
||
if (showZipUpload) showNewSkill = false;
|
||
}}
|
||
>
|
||
{showZipUpload ? '取消上传' : '上传 zip'}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="text-sm text-primary-700 hover:text-primary-900"
|
||
onclick={() => {
|
||
showNewSkill = !showNewSkill;
|
||
if (showNewSkill) showZipUpload = false;
|
||
}}
|
||
>
|
||
{showNewSkill ? '取消' : '+ 空白模板'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{#if showZipUpload}
|
||
<div class="grid gap-3 sm:grid-cols-[8rem_1fr_auto]">
|
||
<input class="saas-input text-sm" placeholder="版本号" bind:value={zipVersion} disabled={zipUploading} />
|
||
<input
|
||
bind:this={zipInputEl}
|
||
class="saas-input text-sm file:mr-3 file:border-0 file:bg-transparent file:text-sm file:font-medium"
|
||
type="file"
|
||
accept=".zip,application/zip,application/x-zip-compressed"
|
||
disabled={zipUploading}
|
||
onchange={(e) => uploadZip(e.currentTarget.files)}
|
||
/>
|
||
<span class="self-center text-xs text-surface-600">{zipUploading ? '安装中…' : '选择 .zip'}</span>
|
||
</div>
|
||
<p class="text-xs text-surface-600">
|
||
zip 根目录即为 skill(须直接含 SKILL.md)。名称取自 manifest;仅 UTF-8 文本;当前选中管理文件夹时会自动归入。覆盖同 name 会更新内容(可能使绑定角色会话不可安全恢复)。
|
||
</p>
|
||
{/if}
|
||
{#if showNewSkill}
|
||
<div class="grid gap-3 sm:grid-cols-[12rem_8rem_1fr_auto]">
|
||
<input
|
||
class="saas-input font-mono text-sm"
|
||
placeholder="技能名(如 typst-help)"
|
||
bind:value={newSkillName}
|
||
/>
|
||
<input
|
||
class="saas-input text-sm"
|
||
placeholder="版本号"
|
||
bind:value={newSkillVersion}
|
||
/>
|
||
<input
|
||
class="saas-input text-sm"
|
||
placeholder="描述"
|
||
bind:value={newSkillDescription}
|
||
/>
|
||
<button class="saas-btn-primary" onclick={createSkill} disabled={creating}>
|
||
{creating ? '创建中…' : '创建'}
|
||
</button>
|
||
</div>
|
||
<p class="text-xs text-surface-600">
|
||
技能名称仅允许小写字母、数字和连字符,且以字母或数字开头。创建后会生成 SKILL.md 模板;当前选中文件夹时新技能会自动归入其中。
|
||
</p>
|
||
{/if}
|
||
</div>
|
||
|
||
{#if skills.length === 0}
|
||
<div class="saas-card">
|
||
<EmptyState title="暂无技能" description="新建一个技能,然后在角色管理中绑定到角色。" />
|
||
</div>
|
||
{:else if visibleSkills.length === 0}
|
||
<div class="saas-card">
|
||
<EmptyState title="此分类下暂无技能" description="在技能卡片上可将其移入当前文件夹。" />
|
||
</div>
|
||
{:else}
|
||
<div class="space-y-4">
|
||
{#each visibleSkills as skill (skill.id)}
|
||
<SkillEditor
|
||
{slug}
|
||
{skill}
|
||
{folderItems}
|
||
oninstalled={onInstalled}
|
||
ondisabled={onDisabled}
|
||
onfolderchanged={onSkillFolderChanged}
|
||
/>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
|
||
<Modal bind:open={showFolderModal} title={folderModalMode === 'create' ? '新建文件夹' : '重命名 / 移动文件夹'}>
|
||
<label class="saas-label" for="agent-folder-name">名称</label>
|
||
<input
|
||
id="agent-folder-name"
|
||
class="saas-input mb-4"
|
||
bind:value={folderName}
|
||
onkeydown={(e) => {
|
||
if (e.key === 'Enter') submitFolderModal();
|
||
}}
|
||
/>
|
||
<p class="saas-label">父文件夹</p>
|
||
<div class="mb-4">
|
||
<SelectField items={moveTargetItems} bind:value={folderParent} />
|
||
</div>
|
||
<div class="flex justify-end gap-2">
|
||
<button class="saas-btn-ghost" onclick={() => (showFolderModal = false)}>取消</button>
|
||
<button class="saas-btn-primary" onclick={submitFolderModal} disabled={savingFolder}>
|
||
{savingFolder ? '保存中…' : '保存'}
|
||
</button>
|
||
</div>
|
||
</Modal>
|
||
{/if}
|