forked from EduCraft/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:
@@ -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