diff --git a/AGENTS.md b/AGENTS.md index 5691183..70238d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,10 @@ `/compact` 只能由卡片动作以未经包装的精确 prompt 转发。 `settingSources: []` 继续禁用项目/用户配置加载,不得把任意 workspace `.claude` 配置变成 运行时能力(见 ADR-0018)。 + skill/role 的管理面分组由一棵 org 内共用、可嵌套的 folder 树承载:透明组织节点, + 不进入身份、解析与授权——name/roleId 仍 org 内唯一,role→skill 绑定、run 加载与 + slash 命令均不引用 folder;folder 归属变更是 label 类变更,不归档会话;仅空 folder + 可删(见 ADR-0028 / `Spec.System.AgentRole`)。 - 项目发现由 `ProjectDiscovery` 模块统一承载:PostgreSQL `pg_trgm` 搜索派生文档、项目编号 归一化、完整 Folder breadcrumb、MANAGE 授权过滤与分页都在该模块内;飞书卡片只是 adapter。 `Project`/`Folder` 仍是事实来源,搜索文档必须可重建且由数据库触发器同步,禁止调用方双写。 diff --git a/docs/adr/0028-agent-config-folder-tree.md b/docs/adr/0028-agent-config-folder-tree.md new file mode 100644 index 0000000..29879e2 --- /dev/null +++ b/docs/adr/0028-agent-config-folder-tree.md @@ -0,0 +1,81 @@ +# ADR 0028: Agent Configuration Folder Tree + +## Status + +Accepted. + +## Context + +ADR-0017/0018 made Agent roles and skills Organization-scoped dynamic runtime +configuration, managed without process restarts. The org admin surfaces for +them (`/admin/roles` and `/admin/skills`) render every +role/skill as one large editor card in a single flat list. As an Organization +accumulates roles and skills, the management pages degrade into an endless +scroll with no grouping affordance. + +The project explorer already solves the analogous problem for projects with +transparent folders (ADR-0021): org-scoped navigation nodes that are not +permission resources. Roles and skills need the same affordance, but their +semantics differ from projects in one crucial way: skill names and role IDs +are referenced by role→skill bindings, run-time skill snapshot loading, and +Feishu slash commands. Any grouping mechanism must not leak into those +resolution paths. + +## Decision + +Introduce an Organization-scoped folder tree shared by Agent roles and Agent +skills: + +- One folder tree per Organization is shared by both roles and skills (e.g. a + "高三化学组" folder groups that team's roles and its skills together). It is + a distinct entity from the project explorer `Folder` of ADR-0021 — the two + trees are managed independently and never reference each other. +- Folders are **transparent organization nodes**, following the ADR-0021 + project-folder precedent: they exist for management-surface navigation and + grouping only, are not permission resources, and hold no grants. +- Folder membership is **not part of role/skill identity or resolution**: + - skill `name` and role `roleId` remain unique per Organization regardless + of folder membership; + - role→skill bindings, run admission's frozen role snapshot, run-scoped + skill loading, and Feishu slash commands never reference folders. +- Each role/skill sits in at most one folder; membership is optional + (unfiled items remain first-class). Folders nest arbitrarily. +- Folder assignment is a label-class change in the ADR-0017 sense: it never + archives Agent sessions, because the execution surface (model, prompt, + tools, skill content) is untouched. +- A folder can be deleted only when empty — no child folders, no roles, no + skills. Relocating items out of a folder is an explicit user action, so no + orphan-placement rule is needed yet. +- Admin web renders both pages as a left folder tree plus the item list of + the selected folder ("all" and "unfiled" included). The host-console CLI is + unchanged: folder management lives in the web surface, and CLI + `upsert-role`/`install-skill` never touch folder assignment. + +## Consequences + +- New DB entity `OrganizationAgentConfigFolder` (org-scoped, self-nesting via + `parentId`, delete restricted while referenced) plus nullable `folderId` on + `OrganizationAgentRole` and `OrganizationAgentSkill` (SetNull on folder + delete, though the service refuses to delete non-empty folders). +- The spec pins the transparency and single-membership semantics in + `Spec.System.AgentRole` (`AgentConfigFolder`), so future implementors do + not re-derive them differently (e.g. path-style names or per-folder + uniqueness). +- Org admin APIs gain folder CRUD plus role/skill folder-assignment endpoints + that skip session archival by construction. +- Moving a role/skill between folders changes nothing about authorization, + run resolution, or audit-visible configuration lineage beyond the folder + assignment event itself. + +## Open Questions / Deferred + +- Drag-and-drop assignment and bulk moves are deferred; assignment is a + per-item select for now. +- Folder-level usage aggregation for roles/skills is deferred (project + folders already aggregate usage under ADR-0021; agent configuration has no + usage dimension yet). +- CLI flags for folder assignment are deferred until a console workflow asks + for them. +- Folder-scoped default-role policies (e.g. per-folder defaults) are rejected + for now: the Organization keeps exactly one active default role + (ADR-0017/0018 invariant) regardless of folder structure. diff --git a/hub/admin-web/src/lib/api.ts b/hub/admin-web/src/lib/api.ts index 87d65c3..9302072 100644 --- a/hub/admin-web/src/lib/api.ts +++ b/hub/admin-web/src/lib/api.ts @@ -317,6 +317,7 @@ export interface AgentRoleRow { createdAt: string; updatedAt: string; skillNames: readonly string[]; + folderId: string | null; } export interface AgentSkillRow { @@ -329,6 +330,14 @@ export interface AgentSkillRow { createdAt: string; updatedAt: string; boundRoleIds: readonly string[]; + folderId: string | null; +} + +/** ADR-0028 transparent folder node shared by agent roles and skills. */ +export interface AgentConfigFolderRow { + id: string; + name: string; + parentId: string | null; } export interface SkillFileEntry { @@ -515,4 +524,21 @@ export const api = { patchAgentSkill: (slug: string, name: string, body: { description?: string; disabled?: boolean }) => patch(`${orgBase(slug)}/agent-skills/${encodeURIComponent(name)}`, body) as Promise<{ disabled?: boolean; updated?: boolean }>, agentModels: (slug: string) => get(`${orgBase(slug)}/agent-models`) as Promise<{ models: AgentModelRow[] }>, + + agentConfigFolders: (slug: string) => + get(`${orgBase(slug)}/agent-config-folders`) as Promise<{ folders: AgentConfigFolderRow[] }>, + createAgentConfigFolder: (slug: string, body: { name: string; parentId?: string }) => + post(`${orgBase(slug)}/agent-config-folders`, body) as Promise, + patchAgentConfigFolder: (slug: string, folderId: string, body: { name?: string; parentId?: string | null }) => + patch(`${orgBase(slug)}/agent-config-folders/${encodeURIComponent(folderId)}`, body) as Promise, + deleteAgentConfigFolder: (slug: string, folderId: string) => + del(`${orgBase(slug)}/agent-config-folders/${encodeURIComponent(folderId)}`) as Promise<{ deleted: boolean }>, + setAgentRoleFolder: (slug: string, roleId: string, folderId: string | null) => + patch(`${orgBase(slug)}/agent-roles/${encodeURIComponent(roleId)}/folder`, { folderId }) as Promise<{ + folderId: string | null; + }>, + setAgentSkillFolder: (slug: string, name: string, folderId: string | null) => + patch(`${orgBase(slug)}/agent-skills/${encodeURIComponent(name)}/folder`, { folderId }) as Promise<{ + folderId: string | null; + }>, }; diff --git a/hub/admin-web/src/lib/components/AgentConfigFolderNav.svelte b/hub/admin-web/src/lib/components/AgentConfigFolderNav.svelte new file mode 100644 index 0000000..6985d85 --- /dev/null +++ b/hub/admin-web/src/lib/components/AgentConfigFolderNav.svelte @@ -0,0 +1,160 @@ + + + diff --git a/hub/admin-web/src/lib/components/RoleCard.svelte b/hub/admin-web/src/lib/components/RoleCard.svelte index 9c2e2f2..c24326a 100644 --- a/hub/admin-web/src/lib/components/RoleCard.svelte +++ b/hub/admin-web/src/lib/components/RoleCard.svelte @@ -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([...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; + } + }
@@ -114,6 +139,12 @@ {#if r.isDefault} 默认 {/if} +
+ 文件夹 +
+ +
+
diff --git a/hub/admin-web/src/lib/components/SkillEditor.svelte b/hub/admin-web/src/lib/components/SkillEditor.svelte index 3ff6a06..5435fa7 100644 --- a/hub/admin-web/src/lib/components/SkillEditor.svelte +++ b/hub/admin-web/src/lib/components/SkillEditor.svelte @@ -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} 已禁用 {/if} +
+ 文件夹 +
+ +
+
diff --git a/hub/admin-web/src/routes/admin/roles/+page.svelte b/hub/admin-web/src/routes/admin/roles/+page.svelte index e56e6a4..2b36384 100644 --- a/hub/admin-web/src/routes/admin/roles/+page.svelte +++ b/hub/admin-web/src/routes/admin/roles/+page.svelte @@ -1,6 +1,6 @@