forked from bai/curriculum-project-hub
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:
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user