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,