Files
curriculum-project-hub/hub/admin-web/src/routes/admin/skills/+page.svelte
T
ChickenPige0n 854c6189bb feat(hub): org-scoped agent role/skill folder tree (ADR-0028)
Add a shared transparent OrganizationAgentConfigFolder tree for grouping
agent roles and skills in the admin UI without affecting identity, bindings,
run loading, or slash commands.
2026-07-19 14:18:19 +08:00

336 lines
10 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 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 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`;
}
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">
<div class="flex items-center justify-between">
<h2 class="saas-section-title">新建技能</h2>
<button class="text-sm text-primary-700 hover:text-primary-900" onclick={() => (showNewSkill = !showNewSkill)}>
{showNewSkill ? '取消' : '+ 新建'}
</button>
</div>
{#if showNewSkill}
<div class="mt-4 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="mt-2 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}