From 0726dc13c89eddb81dc3d2ba9616ef7d25a24337 Mon Sep 17 00:00:00 2001 From: ChickenPige0n <2336983354@qq.com> Date: Wed, 15 Jul 2026 18:16:34 +0800 Subject: [PATCH] feat(admin): restore org Agent role/skill management and fix 404 after project create MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - explorer POST /projects now returns {id,name} matching the SPA contract (previously returned ProjectOnboardingResult.projectId, so res.id was undefined and the redirect to /projects/undefined 404'd) - add OrganizationAgentConfiguration.listRoles/listSkills + AgentRoleRow/ AgentSkillRow exports; upsertRole now returns the full row - new agentConfigRoutes: GET/PUT /agent-roles, PUT /agent-roles/:id/skills, GET /agent-skills, GET /agent-models (env-default picker) - restore admin-web roles page + RoleCard rewired to ADR-0017/0018 backend (label, defaultModel, tools whitelist, skill binding, systemPrompt, sortOrder, default toggle); add 角色 nav item + roles icon - skill installation stays out-of-band (CLI/seed) per spec; the surface only lists installed skills and binds them to roles --- hub/admin-web/src/lib/api.ts | 59 ++++- hub/admin-web/src/lib/components/Icon.svelte | 29 ++- .../src/lib/components/RoleCard.svelte | 208 ++++++++++++++++++ hub/admin-web/src/routes/+layout.svelte | 7 +- .../admin/org/[slug]/roles/+page.svelte | 124 +++++++++++ hub/src/admin/routes/agentConfigRoutes.ts | 129 +++++++++++ hub/src/admin/routes/explorerRoutes.ts | 2 +- hub/src/admin/routes/orgRoutes.ts | 5 + hub/src/agent/configuration.ts | 123 ++++++++++- 9 files changed, 660 insertions(+), 26 deletions(-) create mode 100644 hub/admin-web/src/lib/components/RoleCard.svelte create mode 100644 hub/admin-web/src/routes/admin/org/[slug]/roles/+page.svelte create mode 100644 hub/src/admin/routes/agentConfigRoutes.ts diff --git a/hub/admin-web/src/lib/api.ts b/hub/admin-web/src/lib/api.ts index 5c35cbc..cc1614a 100644 --- a/hub/admin-web/src/lib/api.ts +++ b/hub/admin-web/src/lib/api.ts @@ -226,6 +226,39 @@ export interface CapacityPolicyView { dimensions: CapacityDimensionRow[]; } +export interface AgentRoleRow { + id: string; + roleId: string; + label: string; + defaultModel: string | null; + systemPrompt: string | null; + tools: readonly string[] | null; + sortOrder: number; + isDefault: boolean; + disabledAt: string | null; + createdAt: string; + updatedAt: string; + skillNames: readonly string[]; +} + +export interface AgentSkillRow { + id: string; + name: string; + version: string; + description: string | null; + contentDigest: string; + disabledAt: string | null; + createdAt: string; + updatedAt: string; + boundRoleIds: readonly string[]; +} + +export interface AgentModelRow { + id: string; + label: string; + toolCapable: boolean; +} + // --- API --- export const api = { @@ -261,8 +294,7 @@ export const api = { post(`${orgBase(slug)}/teams/${teamId}/members/${userId}/revoke`), explorer: (slug: string) => get(`${orgBase(slug)}/explorer`) as Promise, - myProjects: (slug: string) => - get(`${orgBase(slug)}/my-projects`) as Promise<{ projects: ExplorerProject[] }>, + myProjects: (slug: string) => get(`${orgBase(slug)}/my-projects`) as Promise<{ projects: ExplorerProject[] }>, createFolder: (slug: string, body: { name: string; parentId?: string; sortKey?: string }) => post(`${orgBase(slug)}/folders`, body) as Promise<{ id: string; @@ -338,8 +370,27 @@ export const api = { disableFeishuApplication: (slug: string) => del(`${orgBase(slug)}/feishu-application-connection`) as Promise, - capacityPolicy: (slug: string) => - get(`${orgBase(slug)}/capacity-policy`) as Promise, + capacityPolicy: (slug: string) => get(`${orgBase(slug)}/capacity-policy`) as Promise, setCapacityPolicy: (slug: string, body: { limits: Partial> }) => put(`${orgBase(slug)}/capacity-policy`, body) as Promise, + + agentRoles: (slug: string) => get(`${orgBase(slug)}/agent-roles`) as Promise<{ roles: AgentRoleRow[] }>, + upsertAgentRole: ( + slug: string, + roleId: string, + body: { + label: string; + defaultModel?: string | null; + systemPrompt?: string | null; + tools?: readonly string[] | null; + sortOrder?: number; + isDefault?: boolean; + }, + ) => put(`${orgBase(slug)}/agent-roles/${encodeURIComponent(roleId)}`, body) as Promise, + setAgentRoleSkills: (slug: string, roleId: string, skillNames: readonly string[]) => + put(`${orgBase(slug)}/agent-roles/${encodeURIComponent(roleId)}/skills`, { skillNames }) as Promise<{ + skillNames: string[]; + }>, + agentSkills: (slug: string) => get(`${orgBase(slug)}/agent-skills`) as Promise<{ skills: AgentSkillRow[] }>, + agentModels: (slug: string) => get(`${orgBase(slug)}/agent-models`) as Promise<{ models: AgentModelRow[] }>, }; diff --git a/hub/admin-web/src/lib/components/Icon.svelte b/hub/admin-web/src/lib/components/Icon.svelte index 7f6ca06..dadec19 100644 --- a/hub/admin-web/src/lib/components/Icon.svelte +++ b/hub/admin-web/src/lib/components/Icon.svelte @@ -11,14 +11,15 @@ | 'projects' | 'provider' | 'feishu' - | 'menu' - | 'logout' - | 'org' - | 'chevron' - | 'arrow-left' - | 'folder' + | 'menu' + | 'logout' + | 'org' + | 'chevron' + | 'arrow-left' + | 'folder' | 'file' - | 'check'; + | 'check' + | 'roles'; class?: string; } = $props(); @@ -103,11 +104,7 @@ {:else if name === 'arrow-left'} - + {:else if name === 'folder'} @@ -129,4 +126,12 @@ +{:else if name === 'roles'} + + + {/if} diff --git a/hub/admin-web/src/lib/components/RoleCard.svelte b/hub/admin-web/src/lib/components/RoleCard.svelte new file mode 100644 index 0000000..9c2e2f2 --- /dev/null +++ b/hub/admin-web/src/lib/components/RoleCard.svelte @@ -0,0 +1,208 @@ + + +
+
+ /{r.roleId} + {label || r.label} + {#if r.isDefault} + 默认 + {/if} +
+ +
+
+ 显示名 + +
+
+ 排序 + +
+
+ +
+

默认模型

+ +
+ +
+ 工具白名单 + +
+ + {#each Object.entries(groupedTools) as [group, tools]} +
+

{group}

+
+ {#each tools as t} + + {/each} +
+
+ {/each} +
+
+
+ +
+ 技能绑定 + {#if skills.length === 0} +

组织内暂无已安装技能。技能通过 CLI / seed 安装(ADR-0018)。

+ {:else} +
+ {#each skillItems as s} + + {/each} +
+ {/if} +
+ +
+ 系统提示词 + +
+ +
+ + 更新于 {fmtDate(r.updatedAt)} +
+ +
+
diff --git a/hub/admin-web/src/routes/+layout.svelte b/hub/admin-web/src/routes/+layout.svelte index b58118e..1413de6 100644 --- a/hub/admin-web/src/routes/+layout.svelte +++ b/hub/admin-web/src/routes/+layout.svelte @@ -25,6 +25,7 @@ { key: 'projects', label: '项目', icon: 'projects' as const }, { key: 'capacity', label: '容量', icon: 'overview' as const }, { key: 'provider', label: '供应方', icon: 'provider' as const }, + { key: 'roles', label: '角色', icon: 'roles' as const }, { key: 'feishu', label: '飞书', icon: 'feishu' as const }, ]; @@ -310,11 +311,7 @@
- +
diff --git a/hub/admin-web/src/routes/admin/org/[slug]/roles/+page.svelte b/hub/admin-web/src/routes/admin/org/[slug]/roles/+page.svelte new file mode 100644 index 0000000..c8211c5 --- /dev/null +++ b/hub/admin-web/src/routes/admin/org/[slug]/roles/+page.svelte @@ -0,0 +1,124 @@ + + + + +{#if loading} + +{:else if error} + +{:else} +
+

新建角色

+
+ { + if (e.key === 'Enter') add(); + }} + /> + { + if (e.key === 'Enter') add(); + }} + /> + +
+

角色 ID 仅允许小写字母、数字、下划线与连字符,且以字母或数字开头。

+
+ + {#if roles.length === 0} +
+ +
+ {:else} +
+ {#each roles as r (r.roleId)} + + {/each} +
+ {/if} +{/if} diff --git a/hub/src/admin/routes/agentConfigRoutes.ts b/hub/src/admin/routes/agentConfigRoutes.ts new file mode 100644 index 0000000..7b56269 --- /dev/null +++ b/hub/src/admin/routes/agentConfigRoutes.ts @@ -0,0 +1,129 @@ +/** + * Org-admin Agent configuration routes (ADR-0017 / ADR-0018). + * + * Surface for browsing and editing Organization-scoped Agent roles and the + * skills bound to them. Roles are org-owned data; the default-model picker is + * constrained to the env-default model registry (ADR-0017: there is no + * org-scoped model list — `OrganizationAgentRole.defaultModel` selects from the + * platform-enabled set). Skill *installation* is out of band (CLI / seed) per + * spec `Skill 管理是 org-scoped; 存储机制 OPEN`; this route only lists installed + * skills and binds them to roles. + */ +import type { PrismaClient } from "@prisma/client"; +import type { FastifyInstance } from "fastify"; +import { OrganizationAgentConfiguration } from "../../agent/configuration.js"; +import { createDefaultModelRegistry } from "../../settings/runtime.js"; +import { requireOrgRole, type GuardDeps } from "../auth/guards.js"; +import { handleRouteError } from "../errors.js"; + +export interface AgentConfigRouteConfig { + readonly prisma: PrismaClient; + readonly sessionSecret: string; +} + +export async function registerAgentConfigRoutes( + app: FastifyInstance, + config: AgentConfigRouteConfig, +): Promise { + const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret }; + // skillStoreRoot is null: this surface does not install skills (see header). + const agentConfig = new OrganizationAgentConfiguration(config.prisma, null); + + app.get("/api/org/:orgSlug/agent-roles", async (request, reply) => { + try { + const { orgSlug } = request.params as { orgSlug: string }; + const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); + if (auth === null) return; + const roles = await agentConfig.listRoles({ organizationId: auth.organization.id }); + return { roles }; + } catch (err) { + return handleRouteError(reply, err); + } + }); + + app.put("/api/org/:orgSlug/agent-roles/:roleId", async (request, reply) => { + try { + const { orgSlug, roleId } = request.params as { orgSlug: string; roleId: string }; + const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); + if (auth === null) return; + const body = request.body as { + label?: unknown; + defaultModel?: unknown; + systemPrompt?: unknown; + tools?: unknown; + sortOrder?: unknown; + isDefault?: unknown; + }; + if (typeof body.label !== "string" || body.label.trim() === "") { + return reply.status(400).send({ + error: { code: "bad_request", message: "label is required" }, + }); + } + const role = await agentConfig.upsertRole({ + organizationId: auth.organization.id, + roleId, + label: body.label, + ...(body.defaultModel === null || typeof body.defaultModel === "string" + ? { defaultModel: body.defaultModel as string | null } + : {}), + ...(body.systemPrompt === null || typeof body.systemPrompt === "string" + ? { systemPrompt: body.systemPrompt as string | null } + : {}), + ...(body.tools === null || Array.isArray(body.tools) + ? { tools: body.tools as readonly string[] | null } + : {}), + ...(typeof body.sortOrder === "number" ? { sortOrder: body.sortOrder } : {}), + ...(typeof body.isDefault === "boolean" ? { isDefault: body.isDefault } : {}), + }); + return role; + } catch (err) { + return handleRouteError(reply, err); + } + }); + + app.put("/api/org/:orgSlug/agent-roles/:roleId/skills", async (request, reply) => { + try { + const { orgSlug, roleId } = request.params as { orgSlug: string; roleId: string }; + const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); + if (auth === null) return; + const body = request.body as { skillNames?: unknown }; + if (!Array.isArray(body.skillNames) || body.skillNames.some((n) => typeof n !== "string")) { + return reply.status(400).send({ + error: { code: "bad_request", message: "skillNames must be a string array" }, + }); + } + await agentConfig.setRoleSkills({ + organizationId: auth.organization.id, + roleId, + skillNames: body.skillNames as readonly string[], + }); + return { skillNames: body.skillNames as string[] }; + } catch (err) { + return handleRouteError(reply, err); + } + }); + + app.get("/api/org/:orgSlug/agent-skills", async (request, reply) => { + try { + const { orgSlug } = request.params as { orgSlug: string }; + const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); + if (auth === null) return; + const skills = await agentConfig.listSkills({ organizationId: auth.organization.id }); + return { skills }; + } catch (err) { + return handleRouteError(reply, err); + } + }); + + app.get("/api/org/:orgSlug/agent-models", async (request, reply) => { + try { + const { orgSlug } = request.params as { orgSlug: string }; + const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); + if (auth === null) return; + const registry = createDefaultModelRegistry(process.env); + return { models: registry.listModels() }; + } catch (err) { + return handleRouteError(reply, err); + } + }); +} diff --git a/hub/src/admin/routes/explorerRoutes.ts b/hub/src/admin/routes/explorerRoutes.ts index 7861ad9..9b10e97 100644 --- a/hub/src/admin/routes/explorerRoutes.ts +++ b/hub/src/admin/routes/explorerRoutes.ts @@ -155,7 +155,7 @@ export async function registerExplorerRoutes( workspaceRoot: config.projectWorkspaceRoot, ...(typeof body.folderId === "string" ? { folderId: body.folderId } : {}), }); - return reply.status(201).send(result); + return reply.status(201).send({ id: result.projectId, name: body.name }); } catch (err) { return handleRouteError(reply, err); } diff --git a/hub/src/admin/routes/orgRoutes.ts b/hub/src/admin/routes/orgRoutes.ts index cb6e342..5c7ad14 100644 --- a/hub/src/admin/routes/orgRoutes.ts +++ b/hub/src/admin/routes/orgRoutes.ts @@ -16,6 +16,7 @@ import { registerSessionsAndUsageRoutes } from "./sessionsRoutes.js"; import { registerTeamsRoutes } from "./teamsRoutes.js"; import { registerProviderConnectionRoutes } from "./providerConnectionRoutes.js"; import { registerCapacityRoutes } from "./capacityRoutes.js"; +import { registerAgentConfigRoutes } from "./agentConfigRoutes.js"; import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js"; import type { ProviderReadinessProbe } from "../../connections/providerReadiness.js"; import type { FeishuReadinessProbe } from "../../connections/feishuReadiness.js"; @@ -110,6 +111,10 @@ export async function registerOrgRoutes(app: FastifyInstance, config: OrgRouteCo prisma: config.prisma, sessionSecret: config.sessionSecret, }); + await registerAgentConfigRoutes(app, { + prisma: config.prisma, + sessionSecret: config.sessionSecret, + }); await registerSessionsAndUsageRoutes(app, { prisma: config.prisma, sessionSecret: config.sessionSecret, diff --git a/hub/src/agent/configuration.ts b/hub/src/agent/configuration.ts index 598d7d3..c7b544c 100644 --- a/hub/src/agent/configuration.ts +++ b/hub/src/agent/configuration.ts @@ -5,6 +5,33 @@ import { importSkillDirectory } from "./skillStore.js"; const ROLE_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/; +export interface AgentRoleRow { + readonly id: string; + readonly roleId: string; + readonly label: string; + readonly defaultModel: string | null; + readonly systemPrompt: string | null; + readonly tools: readonly string[] | null; + readonly sortOrder: number; + readonly isDefault: boolean; + readonly disabledAt: string | null; + readonly createdAt: string; + readonly updatedAt: string; + readonly skillNames: readonly string[]; +} + +export interface AgentSkillRow { + readonly id: string; + readonly name: string; + readonly version: string; + readonly description: string | null; + readonly contentDigest: string; + readonly disabledAt: string | null; + readonly createdAt: string; + readonly updatedAt: string; + readonly boundRoleIds: readonly string[]; +} + /** * Deep module for controlled host-console Agent configuration. It owns the * filesystem/DB ordering, Organization checks and role-skill composition so @@ -13,14 +40,57 @@ const ROLE_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/; export class OrganizationAgentConfiguration { constructor( private readonly prisma: PrismaClient, - private readonly skillStoreRoot: string, + private readonly skillStoreRoot: string | null, ) {} + async listRoles(input: { readonly organizationId: string }): Promise { + await this.requireActiveOrganization(input.organizationId); + const roles = await this.prisma.organizationAgentRole.findMany({ + where: { organizationId: input.organizationId, disabledAt: null }, + orderBy: [{ sortOrder: "asc" }, { roleId: "asc" }], + include: { + skillBindings: { + orderBy: [{ sortOrder: "asc" }, { agentSkillId: "asc" }], + select: { skill: { select: { name: true, disabledAt: true } } }, + }, + }, + }); + return roles.map((role) => toRoleRow(role)); + } + + async listSkills(input: { readonly organizationId: string }): Promise { + await this.requireActiveOrganization(input.organizationId); + const skills = await this.prisma.organizationAgentSkill.findMany({ + where: { organizationId: input.organizationId, disabledAt: null }, + orderBy: [{ name: "asc" }], + include: { + roleBindings: { + where: { role: { disabledAt: null } }, + select: { role: { select: { roleId: true } } }, + }, + }, + }); + return skills.map((skill) => ({ + id: skill.id, + name: skill.name, + version: skill.version, + description: skill.description, + contentDigest: skill.contentDigest, + disabledAt: skill.disabledAt === null ? null : skill.disabledAt.toISOString(), + createdAt: skill.createdAt.toISOString(), + updatedAt: skill.updatedAt.toISOString(), + boundRoleIds: skill.roleBindings.map((binding) => binding.role.roleId), + })); + } + async installSkill(input: { readonly organizationId: string; readonly sourceDir: string; readonly version: string; }): Promise<{ readonly id: string; readonly name: string; readonly contentDigest: string }> { + if (this.skillStoreRoot === null) { + throw new Error("skill store root is not configured for this OrganizationAgentConfiguration"); + } await this.requireActiveOrganization(input.organizationId); const version = nonEmpty(input.version, "skill version"); const imported = await importSkillDirectory({ @@ -90,7 +160,7 @@ export class OrganizationAgentConfiguration { readonly tools?: readonly string[] | null | undefined; readonly sortOrder?: number | undefined; readonly isDefault?: boolean | undefined; - }): Promise<{ readonly id: string; readonly roleId: string }> { + }): Promise { await this.requireActiveOrganization(input.organizationId); if (!ROLE_ID_PATTERN.test(input.roleId)) throw new Error(`invalid role id: ${input.roleId}`); const label = nonEmpty(input.label, "role label"); @@ -150,7 +220,12 @@ export class OrganizationAgentConfiguration { isDefault: effectiveIsDefault, disabledAt: null, }, - select: { id: true, roleId: true }, + include: { + skillBindings: { + orderBy: [{ sortOrder: "asc" }, { agentSkillId: "asc" }], + select: { skill: { select: { name: true, disabledAt: true } } }, + }, + }, }); const executionSurfaceChanged = previous !== null && ( (input.defaultModel !== undefined && normalizeOptionalText(input.defaultModel) !== previous.defaultModel) || @@ -182,7 +257,7 @@ export class OrganizationAgentConfiguration { }, }, }); - return role; + return toRoleRow(role); }); } @@ -295,3 +370,43 @@ function normalizeOptionalText(value: string | null | undefined): string | null const normalized = value.trim(); return normalized === "" ? null : normalized; } + +function parseTools(value: Prisma.JsonValue): readonly string[] | null { + if (value === null) return null; + if (!Array.isArray(value)) return null; + return value.filter((t): t is string => typeof t === "string"); +} + +function toRoleRow(role: { + readonly id: string; + readonly roleId: string; + readonly label: string; + readonly defaultModel: string | null; + readonly systemPrompt: string | null; + readonly tools: Prisma.JsonValue; + readonly sortOrder: number; + readonly isDefault: boolean; + readonly disabledAt: Date | null; + readonly createdAt: Date; + readonly updatedAt: Date; + readonly skillBindings: ReadonlyArray<{ + readonly skill: { readonly name: string; readonly disabledAt: Date | null }; + }>; +}): AgentRoleRow { + return { + id: role.id, + roleId: role.roleId, + label: role.label, + defaultModel: role.defaultModel, + systemPrompt: role.systemPrompt, + tools: parseTools(role.tools), + sortOrder: role.sortOrder, + isDefault: role.isDefault, + disabledAt: role.disabledAt === null ? null : role.disabledAt.toISOString(), + createdAt: role.createdAt.toISOString(), + updatedAt: role.updatedAt.toISOString(), + skillNames: role.skillBindings + .filter((binding) => binding.skill.disabledAt === null) + .map((binding) => binding.skill.name), + }; +}