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.
This commit is contained in:
2026-07-19 13:49:28 +08:00
parent 7f09fb1f13
commit 854c6189bb
16 changed files with 1367 additions and 188 deletions
@@ -0,0 +1,160 @@
<script lang="ts">
import type { AgentConfigFolderRow } from '$lib/api';
import Icon from './Icon.svelte';
/**
* ADR-0028 left folder-tree nav for the agent config pages (roles/skills).
* Transparent grouping only: selection filters the item list, it never
* affects role/skill identity or run resolution.
*/
let {
folders,
selected,
counts,
totalCount,
unfiledCount,
onselect,
oncreate,
onrename,
ondelete,
}: {
folders: AgentConfigFolderRow[];
/** 'all' | 'unfiled' | folder id */
selected: string;
/** item count per folder id (roles or skills, depending on the page) */
counts: Record<string, number>;
totalCount: number;
unfiledCount: number;
onselect: (id: string) => void;
oncreate: (parentId: string | null) => void;
onrename: (folder: AgentConfigFolderRow) => void;
ondelete: (folder: AgentConfigFolderRow) => void;
} = $props();
type Row = { folder: AgentConfigFolderRow; depth: number; hasChildren: boolean };
let collapsed = $state<ReadonlySet<string>>(new Set());
function flatten(list: AgentConfigFolderRow[], collapsedSet: ReadonlySet<string>): Row[] {
const rows: Row[] = [];
const walk = (parentId: string | null, depth: number) => {
const siblings = list
.filter((f) => f.parentId === parentId)
.sort((a, b) => a.name.localeCompare(b.name));
for (const folder of siblings) {
const hasChildren = list.some((f) => f.parentId === folder.id);
rows.push({ folder, depth, hasChildren });
if (hasChildren && !collapsedSet.has(folder.id)) walk(folder.id, depth + 1);
}
};
walk(null, 0);
return rows;
}
const rows = $derived(flatten(folders, collapsed));
function toggleCollapse(folderId: string) {
const next = new Set(collapsed);
if (next.has(folderId)) next.delete(folderId);
else next.add(folderId);
collapsed = next;
}
function childFolderCount(folderId: string): number {
return folders.filter((f) => f.parentId === folderId).length;
}
const rowClass = (active: boolean) =>
`group flex w-full items-center gap-1.5 px-2 py-1.5 text-left text-sm transition hover:bg-surface-100 ${
active ? 'bg-primary-100 font-medium text-primary-900' : 'text-surface-800'
}`;
</script>
<nav class="saas-card p-2" aria-label="文件夹导航">
<div class="flex items-center justify-between px-2 py-1.5">
<span class="text-xs font-semibold uppercase tracking-wide text-surface-600">文件夹</span>
<button
type="button"
class="text-xs text-primary-700 hover:text-primary-900"
onclick={() => oncreate(null)}
>
+ 新建
</button>
</div>
<button type="button" class={rowClass(selected === 'all')} onclick={() => onselect('all')}>
<span class="w-3.5"></span>
<span class="min-w-0 flex-1 truncate">全部</span>
<span class="saas-badge-neutral">{totalCount}</span>
</button>
<button type="button" class={rowClass(selected === 'unfiled')} onclick={() => onselect('unfiled')}>
<span class="w-3.5"></span>
<span class="min-w-0 flex-1 truncate">未分类</span>
<span class="saas-badge-neutral">{unfiledCount}</span>
</button>
{#each rows as row (row.folder.id)}
{@const itemCount = counts[row.folder.id] ?? 0}
{@const childCount = childFolderCount(row.folder.id)}
<div class={rowClass(selected === row.folder.id)} style:padding-left="{0.5 + row.depth * 1}rem">
{#if row.hasChildren}
<button
type="button"
class="w-3.5 shrink-0 text-center text-xs text-surface-600"
aria-label={collapsed.has(row.folder.id) ? '展开' : '折叠'}
onclick={(e) => {
e.stopPropagation();
toggleCollapse(row.folder.id);
}}
>
{collapsed.has(row.folder.id) ? '▸' : '▾'}
</button>
{:else}
<span class="w-3.5 shrink-0"></span>
{/if}
<button type="button" class="flex min-w-0 flex-1 items-center gap-1.5 text-left" onclick={() => onselect(row.folder.id)}>
<Icon name="folder" class="h-3.5 w-3.5 shrink-0 opacity-60" />
<span class="min-w-0 flex-1 truncate">{row.folder.name}</span>
{#if itemCount > 0}
<span class="saas-badge-neutral">{itemCount}</span>
{/if}
</button>
<span
class="flex shrink-0 items-center gap-0.5 opacity-0 transition group-hover:opacity-100 focus-within:opacity-100"
>
<button
type="button"
class="flex h-5 w-5 items-center justify-center text-surface-500 hover:text-primary-700"
title="在此新建子文件夹"
aria-label="在 {row.folder.name} 内新建子文件夹"
onclick={() => oncreate(row.folder.id)}
>
<Icon name="folder-plus" class="h-3.5 w-3.5" />
</button>
<button
type="button"
class="flex h-5 w-5 items-center justify-center text-surface-500 hover:text-primary-700"
title="重命名 / 移动"
aria-label="重命名或移动 {row.folder.name}"
onclick={() => onrename(row.folder)}
>
</button>
<button
type="button"
class="flex h-5 w-5 items-center justify-center text-surface-500 hover:text-error-700 disabled:cursor-not-allowed disabled:opacity-30"
title={itemCount > 0 || childCount > 0 ? '仅可删除空文件夹' : '删除文件夹'}
aria-label="删除 {row.folder.name}"
disabled={itemCount > 0 || childCount > 0}
onclick={() => ondelete(row.folder)}
>
×
</button>
</span>
</div>
{/each}
{#if folders.length === 0}
<p class="px-2 py-3 text-xs text-surface-600">还没有文件夹。新建一个来分组管理。</p>
{/if}
</nav>
@@ -14,15 +14,20 @@
models,
skills,
slug,
folderItems,
onupdated,
onskillschanged,
onfolderchanged,
}: {
r: AgentRoleRow;
models: AgentModelRow[];
skills: AgentSkillRow[];
slug: string;
/** ADR-0028 folder choices ('' = 未分类); transparent grouping only */
folderItems: { value: string; label: string }[];
onupdated: (updated: AgentRoleRow) => void;
onskillschanged: (roleId: string, skillNames: string[]) => void;
onfolderchanged: (roleId: string, folderId: string | null) => void;
} = $props();
const initial = {
@@ -44,6 +49,8 @@
let isDefault = $state(initial.isDefault);
let selectedSkills = $state<string[]>([...initial.skillNames]);
let saving = $state(false);
let folderValue = $state(r.folderId ?? '');
let savingFolder = $state(false);
const groupedTools = TOOL_OPTIONS.reduce(
(acc, t) => {
@@ -105,6 +112,24 @@
function sortKeyDirty(): boolean {
return Number(sortOrder) !== r.sortOrder;
}
// ADR-0028: folder assignment is a label-class change — instant-apply, no
// session archival, independent of the configuration save button.
async function saveFolder(next: string) {
const folderId = next === '' ? null : next;
if (folderId === r.folderId) return;
savingFolder = true;
try {
await api.setAgentRoleFolder(slug, r.roleId, folderId);
onfolderchanged(r.roleId, folderId);
toastSuccess('已更新所属文件夹');
} catch (err) {
folderValue = r.folderId ?? '';
toastError(err instanceof Error ? err.message : String(err));
} finally {
savingFolder = false;
}
}
</script>
<div class="saas-card-pad">
@@ -114,6 +139,12 @@
{#if r.isDefault}
<span class="saas-badge-success">默认</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="grid grid-cols-1 gap-4 md:grid-cols-2">
@@ -3,18 +3,24 @@
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 };
@@ -28,6 +34,8 @@
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'));
@@ -152,6 +160,24 @@
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)) {
@@ -178,6 +204,12 @@
{#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">