feat(admin): restore org Agent role/skill management and fix 404 after project create

- 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
This commit is contained in:
2026-07-15 18:16:34 +08:00
parent fb66614e38
commit 0726dc13c8
9 changed files with 660 additions and 26 deletions
+129
View File
@@ -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<void> {
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);
}
});
}
+1 -1
View File
@@ -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);
}
+5
View File
@@ -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,
+119 -4
View File
@@ -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<readonly AgentRoleRow[]> {
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<readonly AgentSkillRow[]> {
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<AgentRoleRow> {
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),
};
}