forked from bai/curriculum-project-hub
854c6189bb
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.
338 lines
10 KiB
Svelte
338 lines
10 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 SelectField from '$lib/components/SelectField.svelte';
|
||
import { toastError, toastSuccess } from '$lib/toast';
|
||
|
||
let {
|
||
slug,
|
||
skill,
|
||
folderItems,
|
||
oninstalled,
|
||
ondisabled,
|
||
onfolderchanged,
|
||
}: {
|
||
slug: string;
|
||
skill: AgentSkillRow;
|
||
/** ADR-0028 folder choices ('' = 未分类); transparent grouping only */
|
||
folderItems: { value: string; label: string }[];
|
||
oninstalled: (result: { id: string; name: string; contentDigest: string }) => void;
|
||
ondisabled: (name: string) => void;
|
||
onfolderchanged: (name: string, folderId: string | null) => 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);
|
||
let folderValue = $state(skill.folderId ?? '');
|
||
let savingFolder = $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;
|
||
}
|
||
|
||
// ADR-0028: folder assignment is a label-class change — instant-apply, no
|
||
// session archival, independent of the content save button.
|
||
async function saveFolder(next: string) {
|
||
const folderId = next === '' ? null : next;
|
||
if (folderId === skill.folderId) return;
|
||
savingFolder = true;
|
||
try {
|
||
await api.setAgentSkillFolder(slug, skill.name, folderId);
|
||
onfolderchanged(skill.name, folderId);
|
||
toastSuccess('已更新所属文件夹');
|
||
} catch (err) {
|
||
folderValue = skill.folderId ?? '';
|
||
toastError(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
savingFolder = false;
|
||
}
|
||
}
|
||
|
||
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 class="ml-auto flex items-center gap-2">
|
||
<span class="text-xs text-surface-600">文件夹</span>
|
||
<div class="w-44">
|
||
<SelectField items={folderItems} bind:value={folderValue} disabled={savingFolder} onchange={saveFolder} />
|
||
</div>
|
||
</div>
|
||
</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>
|