forked from EduCraft/curriculum-project-hub
79f72ecca8
Add full skill lifecycle to the org-admin web surface: create, read, edit, disable. Skills are directories (SKILL.md manifest + supporting files), content-addressed by SHA-256 in an immutable store. Backend: - skillStore: extract commitSkillContent (shared populate→inspect→ dedup→atomic rename); add importSkillFromFiles (in-memory file list ingestion) and readSkillFiles (read stored version back as UTF-8) - configuration: add installSkillFromFiles, readSkillFiles, disableSkill (soft-delete + archive bound role sessions), updateSkillDescription (label-only, no archival); refactor installSkill to share commitInstalledSkill - agentConfigRoutes: wire skillStoreRoot; add GET /agent-skills/:name/files, PUT /agent-skills/:name (create/replace), PATCH /agent-skills/:name (description/disable) - orgRoutes: pass readSkillStoreRoot() to agent config routes Frontend: - api.ts: agentSkillFiles, installAgentSkill, patchAgentSkill methods - SkillEditor.svelte: file tree + text editor + version/description form - skills/+page.svelte: skill list, create form (generates SKILL.md template), per-skill editor - layout: add 技能 nav item ADR-0018: update Decision to reflect web surface joining host-console CLI in the shared content-addressed ingestion pipeline. Spec (AgentRole.lean): unchanged — storage mechanism is OPEN, web installation is one implementation of it.
306 lines
9.0 KiB
Svelte
306 lines
9.0 KiB
Svelte
<script lang="ts">
|
||
import type { AgentSkillRow, SkillFileEntry } from '$lib/api';
|
||
import { api } from '$lib/api';
|
||
import { fmtDate } from '$lib/format';
|
||
import Icon from '$lib/components/Icon.svelte';
|
||
import { toastError, toastSuccess } from '$lib/toast';
|
||
|
||
let {
|
||
slug,
|
||
skill,
|
||
oninstalled,
|
||
ondisabled,
|
||
}: {
|
||
slug: string;
|
||
skill: AgentSkillRow;
|
||
oninstalled: (result: { id: string; name: string; contentDigest: string }) => void;
|
||
ondisabled: (name: string) => void;
|
||
} = $props();
|
||
|
||
type FileNode = { path: string; content: string };
|
||
|
||
let files = $state<FileNode[]>([]);
|
||
let selectedPath = $state<string | null>(null);
|
||
let version = $state(skill.version);
|
||
let description = $state(skill.description ?? '');
|
||
let loading = $state(false);
|
||
let saving = $state(false);
|
||
let dirty = $state(false);
|
||
let newFilePath = $state('');
|
||
let showNewFile = $state(false);
|
||
|
||
const selectedFile = $derived(files.find((f) => f.path === selectedPath) ?? null);
|
||
const hasManifest = $derived(files.some((f) => f.path === 'SKILL.md'));
|
||
const sortedFiles = $derived([...files].sort((a, b) => a.path.localeCompare(b.path)));
|
||
|
||
async function loadFiles() {
|
||
loading = true;
|
||
try {
|
||
const res = await api.agentSkillFiles(slug, skill.name);
|
||
files = res.files.map((f) => ({ path: f.path, content: f.content }));
|
||
if (files.length > 0 && selectedPath === null) {
|
||
selectedPath = files[0]!.path;
|
||
}
|
||
dirty = false;
|
||
} catch (err) {
|
||
toastError(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
loading = false;
|
||
}
|
||
}
|
||
|
||
function selectFile(path: string) {
|
||
selectedPath = path;
|
||
}
|
||
|
||
function updateContent(path: string, content: string) {
|
||
const file = files.find((f) => f.path === path);
|
||
if (file) {
|
||
file.content = content;
|
||
dirty = true;
|
||
if (path === 'SKILL.md') {
|
||
const desc = parseDescription(content);
|
||
if (desc !== null) description = desc;
|
||
}
|
||
}
|
||
}
|
||
|
||
function addFile() {
|
||
const path = newFilePath.trim();
|
||
if (path === '') {
|
||
toastError('文件路径不能为空');
|
||
return;
|
||
}
|
||
if (files.some((f) => f.path === path)) {
|
||
toastError(`文件已存在:${path}`);
|
||
return;
|
||
}
|
||
if (path.startsWith('/') || path.includes('..')) {
|
||
toastError('文件路径必须为相对路径');
|
||
return;
|
||
}
|
||
files.push({ path, content: '' });
|
||
selectedPath = path;
|
||
newFilePath = '';
|
||
showNewFile = false;
|
||
dirty = true;
|
||
}
|
||
|
||
function deleteFile(path: string) {
|
||
if (path === 'SKILL.md') {
|
||
toastError('SKILL.md 是必需的 manifest,不能删除');
|
||
return;
|
||
}
|
||
files = files.filter((f) => f.path !== path);
|
||
if (selectedPath === path) {
|
||
selectedPath = files.length > 0 ? files[0]!.path : null;
|
||
}
|
||
dirty = true;
|
||
}
|
||
|
||
function parseDescription(manifest: string): string | null {
|
||
const match = /^description:\s*['"]?([^'"\r\n]+)['"]?\s*$/m.exec(manifest);
|
||
return match ? match[1]!.trim() : null;
|
||
}
|
||
|
||
async function save() {
|
||
if (!hasManifest) {
|
||
toastError('缺少 SKILL.md manifest 文件');
|
||
return;
|
||
}
|
||
const trimmedVersion = version.trim();
|
||
if (trimmedVersion === '') {
|
||
toastError('版本号不能为空');
|
||
return;
|
||
}
|
||
saving = true;
|
||
try {
|
||
const fileEntries: SkillFileEntry[] = files.map((f) => ({ path: f.path, content: f.content }));
|
||
const result = await api.installAgentSkill(slug, skill.name, {
|
||
version: trimmedVersion,
|
||
files: fileEntries,
|
||
});
|
||
dirty = false;
|
||
toastSuccess('技能已保存');
|
||
oninstalled(result);
|
||
await loadFiles();
|
||
} catch (err) {
|
||
toastError(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
saving = false;
|
||
}
|
||
}
|
||
|
||
async function disable() {
|
||
saving = true;
|
||
try {
|
||
await api.patchAgentSkill(slug, skill.name, { disabled: true });
|
||
toastSuccess('技能已禁用');
|
||
ondisabled(skill.name);
|
||
} catch (err) {
|
||
toastError(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
saving = false;
|
||
}
|
||
}
|
||
|
||
function saveDescription() {
|
||
const manifest = files.find((f) => f.path === 'SKILL.md');
|
||
if (!manifest) return;
|
||
const updated = updateFrontmatter(manifest.content, 'description', description.trim());
|
||
manifest.content = updated;
|
||
dirty = true;
|
||
}
|
||
|
||
function updateFrontmatter(content: string, key: string, value: string): string {
|
||
const regex = new RegExp(`^(${key}:\\s*)(.*?)(\\s*)$`, 'm');
|
||
if (regex.test(content)) {
|
||
return content.replace(regex, `${key}: ${value}`);
|
||
}
|
||
const lines = content.split('\n');
|
||
if (lines[0] === '---') {
|
||
lines.splice(1, 0, `${key}: ${value}`);
|
||
} else {
|
||
lines.unshift('---', `${key}: ${value}`, '---');
|
||
}
|
||
return lines.join('\n');
|
||
}
|
||
|
||
$effect(() => {
|
||
if (skill.name) loadFiles();
|
||
});
|
||
</script>
|
||
|
||
<div class="saas-card-pad">
|
||
<div class="mb-4 flex flex-wrap items-center gap-2">
|
||
<span class="saas-badge-primary font-mono">{skill.name}</span>
|
||
<span class="text-sm text-surface-700">v{skill.version}</span>
|
||
{#if skill.disabledAt}
|
||
<span class="saas-badge-error">已禁用</span>
|
||
{/if}
|
||
</div>
|
||
|
||
<div class="mb-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||
<div>
|
||
<label for="skill-version-{skill.id}" class="saas-label">版本号</label>
|
||
<input id="skill-version-{skill.id}" class="saas-input" bind:value={version} placeholder="如 0.1.0" />
|
||
</div>
|
||
<div>
|
||
<label for="skill-desc-{skill.id}" class="saas-label">描述(同步到 SKILL.md frontmatter)</label>
|
||
<div class="flex gap-2">
|
||
<input id="skill-desc-{skill.id}" class="saas-input" bind:value={description} onchange={saveDescription} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mb-4 flex flex-wrap items-center gap-2 text-xs text-surface-600">
|
||
<span>digest: <code class="font-mono">{skill.contentDigest.slice(0, 12)}</code></span>
|
||
<span>·</span>
|
||
<span>更新于 {fmtDate(skill.updatedAt)}</span>
|
||
{#if skill.boundRoleIds.length > 0}
|
||
<span>·</span>
|
||
<span>绑定角色: {skill.boundRoleIds.join(', ')}</span>
|
||
{/if}
|
||
{#if dirty}
|
||
<span>·</span>
|
||
<span class="font-medium text-warning-700">未保存</span>
|
||
{/if}
|
||
</div>
|
||
|
||
{#if loading}
|
||
<div class="py-8 text-center text-sm text-surface-600">加载文件中…</div>
|
||
{:else}
|
||
<div class="grid grid-cols-1 gap-4 md:grid-cols-[16rem_1fr]">
|
||
<div class="border border-surface-300 bg-surface-100">
|
||
<div class="flex items-center justify-between border-b border-surface-300 px-3 py-2">
|
||
<span class="text-xs font-medium text-surface-700">文件</span>
|
||
<button
|
||
type="button"
|
||
class="text-xs text-primary-700 hover:text-primary-900"
|
||
onclick={() => (showNewFile = !showNewFile)}
|
||
>
|
||
{showNewFile ? '取消' : '+ 新增'}
|
||
</button>
|
||
</div>
|
||
{#if showNewFile}
|
||
<div class="border-b border-surface-300 px-3 py-2">
|
||
<input
|
||
class="saas-input text-xs"
|
||
placeholder="如 reference.md"
|
||
bind:value={newFilePath}
|
||
onkeydown={(e) => {
|
||
if (e.key === 'Enter') addFile();
|
||
}}
|
||
/>
|
||
</div>
|
||
{/if}
|
||
<div class="max-h-80 overflow-y-auto">
|
||
{#each sortedFiles as f (f.path)}
|
||
<button
|
||
type="button"
|
||
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm transition hover:bg-surface-200
|
||
{selectedPath === f.path ? 'bg-primary-100 text-primary-900' : 'text-surface-800'}"
|
||
onclick={() => selectFile(f.path)}
|
||
>
|
||
<Icon name="file" class="h-3.5 w-3.5 shrink-0 opacity-60" />
|
||
<span class="truncate font-mono text-xs">{f.path}</span>
|
||
{#if f.path === 'SKILL.md'}
|
||
<span class="ml-auto text-[10px] text-primary-700">manifest</span>
|
||
{:else}
|
||
<span
|
||
class="ml-auto text-xs text-surface-500 hover:text-error-700"
|
||
role="button"
|
||
tabindex="0"
|
||
onclick={(e) => {
|
||
e.stopPropagation();
|
||
deleteFile(f.path);
|
||
}}
|
||
onkeydown={(e) => {
|
||
if (e.key === 'Enter' || e.key === ' ') {
|
||
e.stopPropagation();
|
||
deleteFile(f.path);
|
||
}
|
||
}}
|
||
>
|
||
×
|
||
</span>
|
||
{/if}
|
||
</button>
|
||
{/each}
|
||
{#if files.length === 0}
|
||
<div class="px-3 py-4 text-center text-xs text-surface-600">暂无文件</div>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
{#if selectedFile}
|
||
<div class="mb-2 flex items-center gap-2">
|
||
<span class="font-mono text-xs text-surface-600">{selectedFile.path}</span>
|
||
</div>
|
||
<textarea
|
||
class="h-80 w-full resize-y border border-surface-300 bg-white p-3 font-mono text-xs text-surface-900 focus:border-primary-500 focus:outline-none"
|
||
bind:value={selectedFile.content}
|
||
oninput={() => (dirty = true)}
|
||
></textarea>
|
||
{:else}
|
||
<div class="flex h-80 items-center justify-center border border-surface-300 bg-surface-100 text-sm text-surface-600">
|
||
选择一个文件或新建文件
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mt-4 flex flex-wrap items-center gap-3 border-t border-surface-100 pt-4">
|
||
<button class="saas-btn-primary" onclick={save} disabled={saving || !dirty}>
|
||
{saving ? '保存中…' : '保存'}
|
||
</button>
|
||
{#if !skill.disabledAt}
|
||
<button class="saas-btn-danger" onclick={disable} disabled={saving}>
|
||
禁用
|
||
</button>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
</div>
|