Files
curriculum-project-hub/hub/admin-web/src/lib/components/SkillEditor.svelte
T
hongjr03 79f72ecca8 feat(admin): web-based skill management with file editor
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.
2026-07-16 01:11:49 +08:00

306 lines
9.0 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 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>