feat(ai-roles): add importable, multilingual agent roles catalog

Admins can browse a curated catalog of agent roles, import roles/bundles
into a workspace, and update an imported role when the catalog ships a
newer version.

Catalog: a set of JSON files (index.json manifest + bundles/<id>/<lang>.json)
served from a local folder (dev) or a remote http(s) base URL via
AI_AGENT_ROLES_CATALOG_URL. Seeded with the existing 7 RU roles (editorial +
research bundles) plus EN translations.

Server:
- migration: nullable jsonb `source` column on ai_agent_roles
  ({ slug, language, version }; null => manually created)
- catalog provider: remote fetch with timeout + streaming size cap, or local
  read; ^[a-z0-9-]+$ segment guard against path-traversal/SSRF
- admin endpoints: catalog, catalog/bundle, import, update-from-catalog
- import/update match by slug+language; update preserves `enabled`

Client:
- catalog modal with language selector and Import/Installed/Update states
- "Import from catalog" button + empty-state CTA in the roles settings panel
- en-US/ru-RU strings

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude_code
2026-06-26 19:26:30 +03:00
committed by claude code agent 227
parent 0fc9c4a998
commit 19f84ca0e7
29 changed files with 2643 additions and 29 deletions

View File

@@ -132,6 +132,13 @@ MCP_DOCMOST_PASSWORD=
# NEVER set is_agent on a human or shared account — every action by that account
# (including normal human edits) would then be mis-attributed as AI.
# Agent-roles catalog source: an http(s):// base URL => the catalog is fetched
# remotely (e.g. the raw GitHub base URL of the catalog repo); any other value
# => a local filesystem directory. Empty (default) => the in-repo
# ./agent-roles-catalog folder (dev). Used by the admin "import role from
# catalog" feature only.
# AI_AGENT_ROLES_CATALOG_URL=
# Per-embedding-call timeout in milliseconds for the RAG indexer.
# A slow/hung embeddings endpoint fails after this and the batch continues.
# AI_EMBEDDING_TIMEOUT_MS=120000

View File

@@ -0,0 +1,130 @@
# Agent roles catalog
This directory is **data, not application code**. It holds the content of an
"agent roles catalog": reusable agent role definitions (system prompts plus a
little metadata), grouped into bundles and translated into one or more
languages. A separate server reads these files and serves them; nothing here is
executable application logic except the validation script.
## File layout
```
agent-roles-catalog/
index.json # the catalog manifest: bundles, languages, role versions
bundles/
<bundle-id>/
<lang>.json # one file per declared language (e.g. ru.json, en.json)
scripts/
check.mjs # validates the catalog (no dependencies)
package.json # defines the `check` script
README.md
```
Currently shipped bundles:
- `editorial` — the editorial suite (structural-editor, line-editor,
copy-editor, fact-checker, proofreader, narrator), languages `ru`, `en`.
- `research` — a single `researcher` role, languages `ru`, `en`.
## `index.json` schema
```jsonc
{
"schemaVersion": 1,
"bundles": [
{
"id": "editorial", // unique bundle id; matches bundles/<id>/
"name": { "ru": "...", "en": "..." }, // localized display name
"description": { "ru": "...", "en": "..." },
"languages": ["ru", "en"], // which <lang>.json files must exist
"roles": [
{ "slug": "structural-editor", "version": 1 }
// ...
]
}
]
}
```
`version` lives **here, in index.json**, per role. Bump it whenever a role's
content (instructions, name, description, etc.) changes, so consumers can detect
updates.
## Bundle (`<lang>.json`) schema
```jsonc
{
"schemaVersion": 1,
"language": "ru",
"roles": [
{
"slug": "structural-editor", // REQUIRED, unique across the whole catalog
"emoji": "🧱",
"name": "...", // REQUIRED, localized
"description": "...", // localized
"instructions": "...", // REQUIRED, the system prompt, localized
"autoStart": true, // whether the role starts working immediately
"launchMessage": "..." // first message sent on launch (or null)
}
]
}
```
Notes:
- `modelConfig` is intentionally absent; the server treats an absent
`modelConfig` as `null`.
- A role's `slug`, `emoji`, and `autoStart` are identical across all language
files of the same bundle. Only `name`, `description`, `instructions`, and
`launchMessage` are translated.
## Slug uniqueness
**Every `slug` must be UNIQUE ACROSS THE WHOLE CATALOG**, not just within a
bundle. A slug appears once per language file of its bundle (same slug in
`ru.json` and `en.json`), but no two different bundles may share a slug.
`scripts/check.mjs` enforces this.
## How to add things
### Add a role to an existing bundle
1. Add an entry to that bundle's `roles[]` in `index.json` with a new unique
`slug` and `version: 1`.
2. Add a role object with the same `slug` to **every** `<lang>.json` of the
bundle, translating `name`, `description`, `instructions`, and
`launchMessage`.
3. Run the check (see below).
### Add a bundle
1. Add a bundle object to `index.json` (`id`, `name`, `description`,
`languages`, `roles`).
2. Create `bundles/<id>/<lang>.json` for each declared language, with one role
object per `roles[]` entry.
3. Run the check.
### Add a language to a bundle
1. Add the language code to that bundle's `languages[]` in `index.json`.
2. Create `bundles/<id>/<lang>.json` containing every role of the bundle,
translated.
3. Run the check.
### Change a role's content
Edit the role in the relevant `<lang>.json` file(s) and **bump that role's
`version`** in `index.json`.
## Validating
From this directory:
```sh
node scripts/check.mjs # or: npm run check
```
It fails (exit code 1) if any slug is duplicated across the catalog, if a
bundle's index `roles[]` don't match the slugs present in each language file, if
a declared language file is missing, or if any role is missing a required field
(`slug`, `name`, `instructions`). It prints `OK` on success.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,32 @@
{
"schemaVersion": 1,
"bundles": [
{
"id": "editorial",
"name": { "ru": "Редакторский набор", "en": "Editorial suite" },
"description": {
"ru": "Полный цикл редактуры статьи: структура, стиль, грамматика, факты, корректура и нарратив.",
"en": "The full article-editing cycle: structure, style, grammar, facts, proofreading, and narrative."
},
"languages": ["ru", "en"],
"roles": [
{ "slug": "structural-editor", "version": 1 },
{ "slug": "line-editor", "version": 1 },
{ "slug": "copy-editor", "version": 1 },
{ "slug": "fact-checker", "version": 1 },
{ "slug": "proofreader", "version": 1 },
{ "slug": "narrator", "version": 1 }
]
},
{
"id": "research",
"name": { "ru": "Исследование", "en": "Research" },
"description": {
"ru": "Глубокое исследование темы с подготовкой отчёта.",
"en": "Deep research on a topic with a prepared report."
},
"languages": ["ru", "en"],
"roles": [ { "slug": "researcher", "version": 1 } ]
}
]
}

View File

@@ -0,0 +1,8 @@
{
"name": "agent-roles-catalog",
"private": true,
"type": "module",
"scripts": {
"check": "node scripts/check.mjs"
}
}

View File

@@ -0,0 +1,131 @@
#!/usr/bin/env node
// Validates the agent roles catalog.
// Fails (exit 1) on: duplicate slugs across the whole catalog, mismatches
// between a bundle's index roles[] and the slugs present in each language
// file, a missing declared language file, or a role missing required fields.
import { readFileSync, existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const catalogDir = join(__dirname, "..");
const errors = [];
function readJson(path) {
try {
return JSON.parse(readFileSync(path, "utf8"));
} catch (err) {
errors.push(`Cannot read/parse ${path}: ${err.message}`);
return null;
}
}
const indexPath = join(catalogDir, "index.json");
if (!existsSync(indexPath)) {
console.error(`Missing index.json at ${indexPath}`);
process.exit(1);
}
const index = readJson(indexPath);
if (!index) {
for (const e of errors) console.error(e);
process.exit(1);
}
const bundles = Array.isArray(index.bundles) ? index.bundles : [];
if (bundles.length === 0) {
errors.push("index.json has no bundles[]");
}
// Track every slug seen across the whole catalog to detect duplicates.
const slugSeen = new Map(); // slug -> "bundleId/lang"
for (const bundle of bundles) {
const bundleId = bundle.id;
if (!bundleId) {
errors.push("A bundle in index.json is missing an id");
continue;
}
const indexSlugs = (bundle.roles || []).map((r) => r.slug);
// Duplicate slugs inside the bundle index roles[].
const indexSlugSet = new Set(indexSlugs);
if (indexSlugSet.size !== indexSlugs.length) {
errors.push(`Bundle "${bundleId}" index.json roles[] contains duplicate slugs`);
}
const languages = Array.isArray(bundle.languages) ? bundle.languages : [];
if (languages.length === 0) {
errors.push(`Bundle "${bundleId}" declares no languages`);
}
for (const lang of languages) {
const langPath = join(catalogDir, "bundles", bundleId, `${lang}.json`);
if (!existsSync(langPath)) {
errors.push(`Bundle "${bundleId}" declares language "${lang}" but ${langPath} is missing`);
continue;
}
const langFile = readJson(langPath);
if (!langFile) continue;
const roles = Array.isArray(langFile.roles) ? langFile.roles : [];
const fileSlugs = roles.map((r) => r && r.slug);
// (d) Required fields per role.
for (const role of roles) {
for (const field of ["slug", "name", "instructions"]) {
if (role == null || role[field] == null || role[field] === "") {
errors.push(
`Bundle "${bundleId}/${lang}" has a role missing required field "${field}" (slug=${role && role.slug})`
);
}
}
}
// (b) index roles[] must match the slugs present in each language file.
const fileSlugSet = new Set(fileSlugs);
const missingInFile = indexSlugs.filter((s) => !fileSlugSet.has(s));
const extraInFile = fileSlugs.filter((s) => !indexSlugSet.has(s));
if (missingInFile.length > 0) {
errors.push(
`Bundle "${bundleId}/${lang}" is missing roles declared in index.json: ${missingInFile.join(", ")}`
);
}
if (extraInFile.length > 0) {
errors.push(
`Bundle "${bundleId}/${lang}" has roles not declared in index.json: ${extraInFile.join(", ")}`
);
}
// (a) Duplicate slugs across the whole catalog.
for (const slug of fileSlugs) {
if (!slug) continue;
const where = `${bundleId}/${lang}`;
// Only flag duplicates across DIFFERENT bundles or files; the same slug
// is expected to appear once per language file of the same bundle.
const key = slug;
if (slugSeen.has(key)) {
const prev = slugSeen.get(key);
const prevBundle = prev.split("/")[0];
if (prevBundle !== bundleId) {
errors.push(
`Slug "${slug}" is duplicated across the catalog: ${prev} and ${where}`
);
}
} else {
slugSeen.set(key, where);
}
}
}
}
if (errors.length > 0) {
console.error("Catalog check FAILED:");
for (const e of errors) console.error(` - ${e}`);
process.exit(1);
}
console.log("OK");

View File

@@ -1346,5 +1346,22 @@
"Could not generate a title": "Could not generate a title",
"AI title generation is disabled": "AI title generation is disabled",
"AI is not configured": "AI is not configured",
"Too many requests, please try again later": "Too many requests, please try again later"
"Too many requests, please try again later": "Too many requests, please try again later",
"Import from catalog": "Import from catalog",
"Browse the catalog": "Browse the catalog",
"Role catalog": "Role catalog",
"On name conflict": "On name conflict",
"Skip": "Skip",
"Import": "Import",
"Installed": "Installed",
"v{{from}} → v{{to}}": "v{{from}} → v{{to}}",
"Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}": "Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}",
"Failed to import {{count}} role(s)": "Failed to import {{count}} role(s)",
"The role catalog is unavailable": "The role catalog is unavailable",
"Please try again later.": "Please try again later.",
"No bundles available": "No bundles available",
"Already up to date": "Already up to date",
"Updated to the latest version": "Updated to the latest version",
"This role is no longer in the catalog": "This role is no longer in the catalog",
"This language is no longer available in the catalog": "This language is no longer available in the catalog"
}

View File

@@ -1203,5 +1203,23 @@
"Could not generate a title": "Не удалось придумать название",
"AI title generation is disabled": "Генерация названий через AI отключена",
"AI is not configured": "AI не настроен",
"Too many requests, please try again later": "Слишком много запросов, попробуйте позже"
"Too many requests, please try again later": "Слишком много запросов, попробуйте позже",
"Import from catalog": "Импорт из каталога",
"Browse the catalog": "Открыть каталог",
"Role catalog": "Каталог ролей",
"On name conflict": "При конфликте имён",
"Skip": "Пропустить",
"Import": "Импортировать",
"Installed": "Установлено",
"v{{from}} → v{{to}}": "v{{from}} → v{{to}}",
"Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}": "Импортировано: {{created}}, переименовано: {{renamed}}, пропущено: {{skipped}}",
"Failed to import {{count}} role(s)": "Не удалось импортировать ролей: {{count}}",
"The role catalog is unavailable": "Каталог ролей недоступен",
"Please try again later.": "Попробуйте позже.",
"No bundles available": "Наборы недоступны",
"No roles configured": "Роли не настроены",
"Already up to date": "Уже актуальна",
"Updated to the latest version": "Обновлено до последней версии",
"This role is no longer in the catalog": "Эта роль больше не представлена в каталоге",
"This language is no longer available in the catalog": "Этот язык больше не доступен в каталоге"
}

View File

@@ -13,21 +13,40 @@ import {
deleteAiRole,
getAiChatMessages,
getAiChats,
getAiRoleCatalog,
getAiRoleCatalogBundle,
getAiRoles,
importAiRolesFromCatalog,
renameAiChat,
updateAiRole,
updateAiRoleFromCatalog,
} from "@/features/ai-chat/services/ai-chat-service.ts";
import {
IAiChat,
IAiChatMessageRow,
IAiRole,
IAiRoleCatalog,
IAiRoleCatalogBundle,
IAiRoleCreate,
IAiRoleImportPayload,
IAiRoleImportResult,
IAiRoleUpdate,
IAiRoleUpdateFromCatalogResult,
} from "@/features/ai-chat/types/ai-chat.types.ts";
import { IPagination } from "@/lib/types.ts";
export const AI_CHATS_RQ_KEY = ["ai-chats"];
export const AI_ROLES_RQ_KEY = ["ai-roles"];
// Catalog reads resolve bundle names per language, so the language is part of
// the cache key (a language switch refetches rather than reusing stale names).
export const AI_ROLE_CATALOG_RQ_KEY = (language: string) => [
"ai-role-catalog",
language,
];
export const AI_ROLE_CATALOG_BUNDLE_RQ_KEY = (
bundleId: string,
language: string,
) => ["ai-role-catalog-bundle", bundleId, language];
export const AI_CHAT_MESSAGES_RQ_KEY = (chatId: string) => [
"ai-chat-messages",
chatId,
@@ -223,3 +242,105 @@ export function useDeleteAiRoleMutation() {
},
});
}
/**
* Browse the role catalog for a language. Gated by `enabled` so the (admin-only)
* fetch runs only when the catalog modal is open. The catalog can 502 when the
* curated source is unreachable; callers handle the error state in the UI.
*/
export function useAiRoleCatalogQuery(language: string, enabled: boolean) {
return useQuery<IAiRoleCatalog, Error>({
queryKey: AI_ROLE_CATALOG_RQ_KEY(language),
queryFn: () => getAiRoleCatalog(language),
enabled,
});
}
/**
* Open one catalog bundle (role content + versions). Gated by `enabled` so the
* fetch only runs when a bundle is actually expanded.
*/
export function useAiRoleCatalogBundleQuery(
bundleId: string,
language: string,
enabled: boolean,
) {
return useQuery<IAiRoleCatalogBundle, Error>({
queryKey: AI_ROLE_CATALOG_BUNDLE_RQ_KEY(bundleId, language),
queryFn: () => getAiRoleCatalogBundle(bundleId, language),
enabled,
});
}
export function useImportAiRolesFromCatalogMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<IAiRoleImportResult, Error, IAiRoleImportPayload>({
mutationFn: (payload) => importAiRolesFromCatalog(payload),
onSuccess: (result) => {
notifications.show({
message: t("Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}", {
created: result.created,
renamed: result.renamed,
skipped: result.skipped,
}),
});
// Surface partial failures (e.g. unique-name races) as a red warning.
if (result.errors.length > 0) {
notifications.show({
color: "red",
message: t("Failed to import {{count}} role(s)", {
count: result.errors.length,
}),
});
}
queryClient.invalidateQueries({ queryKey: AI_ROLES_RQ_KEY });
// Imported roles can appear in the chat picker / badges.
queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY });
},
onError: (error) => {
const message = error["response"]?.data?.message;
notifications.show({
message: message ?? t("Failed to update data"),
color: "red",
});
},
});
}
export function useUpdateAiRoleFromCatalogMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<IAiRoleUpdateFromCatalogResult, Error, string>({
mutationFn: (id) => updateAiRoleFromCatalog(id),
onSuccess: (result) => {
// The server returns updated:false with a reason for a no-op (already
// up to date / removed from catalog / language no longer offered). Map
// each reason to a specific message instead of a generic "up to date".
let message: string;
if (result.updated) {
message = t("Updated to the latest version");
} else if (result.reason === "not-in-catalog") {
message = t("This role is no longer in the catalog");
} else if (result.reason === "language-unavailable") {
message = t("This language is no longer available in the catalog");
} else {
// "up-to-date" and any unexpected reason.
message = t("Already up to date");
}
notifications.show({ message });
queryClient.invalidateQueries({ queryKey: AI_ROLES_RQ_KEY });
// The role badge denormalized onto the chat list may have changed.
queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY });
},
onError: (error) => {
const message = error["response"]?.data?.message;
notifications.show({
message: message ?? t("Failed to update data"),
color: "red",
});
},
});
}

View File

@@ -6,8 +6,13 @@ import {
IAiChatMessageRow,
IAiChatMessagesParams,
IAiRole,
IAiRoleCatalog,
IAiRoleCatalogBundle,
IAiRoleCreate,
IAiRoleImportPayload,
IAiRoleImportResult,
IAiRoleUpdate,
IAiRoleUpdateFromCatalogResult,
} from "@/features/ai-chat/types/ai-chat.types.ts";
/**
@@ -112,3 +117,54 @@ export async function deleteAiRole(id: string): Promise<{ success: true }> {
});
return req.data;
}
/**
* Role catalog API (`/ai-chat/roles/*`, admin-only — the server enforces this).
* Browse a curated catalog, import roles/bundles into the workspace, and update
* an imported role when the catalog ships a newer version. Same `{ data }`
* unwrap convention as above.
*/
/** Browse the catalog, optionally localized to `language`. */
export async function getAiRoleCatalog(
language?: string,
): Promise<IAiRoleCatalog> {
const req = await api.post<IAiRoleCatalog>("/ai-chat/roles/catalog", {
language,
});
return req.data;
}
/** Open one catalog bundle in a language (role content + versions). */
export async function getAiRoleCatalogBundle(
bundleId: string,
language: string,
): Promise<IAiRoleCatalogBundle> {
const req = await api.post<IAiRoleCatalogBundle>(
"/ai-chat/roles/catalog/bundle",
{ bundleId, language },
);
return req.data;
}
/** Import roles from a catalog bundle into the workspace (admin). */
export async function importAiRolesFromCatalog(
payload: IAiRoleImportPayload,
): Promise<IAiRoleImportResult> {
const req = await api.post<IAiRoleImportResult>(
"/ai-chat/roles/import",
payload,
);
return req.data;
}
/** Update an already-imported role from its catalog source (admin). */
export async function updateAiRoleFromCatalog(
id: string,
): Promise<IAiRoleUpdateFromCatalogResult> {
const req = await api.post<IAiRoleUpdateFromCatalogResult>(
"/ai-chat/roles/update-from-catalog",
{ id },
);
return req.data;
}

View File

@@ -57,10 +57,74 @@ export interface IAiRole {
autoStart: boolean;
// Custom auto-start text; null/empty => the default launch message is sent.
launchMessage: string | null;
// Catalog origin of an imported role, or null for a manually-created one.
// Admin-only (present only in the admin list view); the picker view omits it.
// The admin UI compares `version` against the catalog to offer an update.
source?: { slug: string; language: string; version: number } | null;
createdAt?: string;
updatedAt?: string;
}
/** One bundle's summary in the catalog index (mirrors `getCatalog().bundles[]`). */
export interface IAiRoleCatalogBundleSummary {
id: string;
name: string;
description: string | null;
languages: string[];
roles: { slug: string; version: number }[];
}
/** The browsable catalog index (mirrors `getCatalog()`). */
export interface IAiRoleCatalog {
languages: string[];
bundles: IAiRoleCatalogBundleSummary[];
}
/** A single role inside an opened catalog bundle (localized content + version). */
export interface IAiRoleCatalogRole {
slug: string;
emoji: string | null;
name: string;
description: string | null;
instructions: string;
autoStart: boolean;
launchMessage: string | null;
version: number;
}
/** An opened catalog bundle (mirrors `getCatalogBundle()`). */
export interface IAiRoleCatalogBundle {
bundleId: string;
language: string;
roles: IAiRoleCatalogRole[];
}
/** Import payload (mirrors the server `ImportFromCatalogDto`). */
export interface IAiRoleImportPayload {
bundleId: string;
language: string;
// Omitted => import the whole bundle; otherwise only these slugs.
slugs?: string[];
conflict: "skip" | "rename";
}
/** Import result counts (mirrors `importFromCatalog()`). */
export interface IAiRoleImportResult {
created: number;
skipped: number;
renamed: number;
errors: { slug: string; message: string }[];
}
/** Update-from-catalog result (mirrors `updateFromCatalog()`). */
export interface IAiRoleUpdateFromCatalogResult {
updated: boolean;
fromVersion?: number;
toVersion?: number;
reason?: string;
role?: IAiRole;
}
/** Admin create payload for a role. */
export interface IAiRoleCreate {
name: string;

View File

@@ -0,0 +1,404 @@
import { useEffect, useMemo, useState } from "react";
import {
Accordion,
Alert,
Badge,
Button,
Center,
Checkbox,
Group,
Loader,
Modal,
Radio,
Select,
Stack,
Text,
} from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import {
useAiRoleCatalogBundleQuery,
useAiRoleCatalogQuery,
useImportAiRolesFromCatalogMutation,
useUpdateAiRoleFromCatalogMutation,
} from "@/features/ai-chat/queries/ai-chat-query.ts";
import {
IAiRole,
IAiRoleCatalogBundleSummary,
IAiRoleCatalogRole,
} from "@/features/ai-chat/types/ai-chat.types.ts";
interface AiAgentRolesCatalogModalProps {
opened: boolean;
onClose: () => void;
// The current admin role list (full view, including `source`). Used to compute
// each catalog role's install state (import / installed / update available).
roles: IAiRole[];
}
/** How a name collision with an existing role is handled on import. */
type Conflict = "skip" | "rename";
/**
* Admin modal: browse the curated role catalog, import roles, and update an
* imported role when the catalog ships a newer version.
*
* Import is per-bundle (the endpoint takes a single bundleId). Each bundle's
* Accordion panel has its own "Import" button that imports only that bundle's
* checked roles — the simplest mapping to the one-bundle-per-call API and the
* clearest UX. Selection state is tracked per bundle.
*/
export default function AiAgentRolesCatalogModal({
opened,
onClose,
roles,
}: AiAgentRolesCatalogModalProps) {
const { t, i18n } = useTranslation();
// Fetch the catalog only while the modal is open. `language` drives both the
// catalog query (bundle names) and bundle reads (role content). Seed it
// synchronously from the i18n base subtag (e.g. "ru-RU" => "ru") so the first
// fetch already uses the user's language; the effect below still reconciles
// against the catalog's offered languages once they load.
const [language, setLanguage] = useState<string>(
() => (i18n.language || "en").split("-")[0].toLowerCase(),
);
const catalogQuery = useAiRoleCatalogQuery(language || "en", opened);
// On name conflict: Skip (default) or Rename to a free " (N)" name.
const [conflict, setConflict] = useState<Conflict>("skip");
// The currently expanded bundle id (Accordion is single-open: one bundle's
// roles are fetched at a time).
const [expanded, setExpanded] = useState<string | null>(null);
// Per-bundle selected slugs (import-state roles checked for import).
const [selected, setSelected] = useState<Record<string, Set<string>>>({});
const languages = catalogQuery.data?.languages;
// Pick a sensible default language from the catalog once it loads: the i18n
// base subtag (e.g. "ru-RU" => "ru") if offered, else "en", else the first.
useEffect(() => {
if (!languages || languages.length === 0) return;
if (language && languages.includes(language)) return;
const base = (i18n.language || "en").split("-")[0].toLowerCase();
const preferred = languages.includes(base)
? base
: languages.includes("en")
? "en"
: languages[0];
setLanguage(preferred);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [languages]);
// Reset per-language UI state when the language changes (the bundle content,
// hence the install computations, are language-specific).
useEffect(() => {
setExpanded(null);
setSelected({});
}, [language]);
return (
<Modal
opened={opened}
onClose={onClose}
title={t("Role catalog")}
size="lg"
>
<Stack>
<Select
label={t("Language")}
data={languages ?? []}
value={language || null}
onChange={(value) => value && setLanguage(value)}
allowDeselect={false}
disabled={!languages || languages.length === 0}
comboboxProps={{ withinPortal: true }}
/>
<Radio.Group
label={t("On name conflict")}
value={conflict}
onChange={(value) => setConflict(value as Conflict)}
>
<Group mt="xs">
<Radio value="skip" label={t("Skip")} />
<Radio value="rename" label={t("Rename")} />
</Group>
</Radio.Group>
{catalogQuery.isLoading && (
<Center py="lg">
<Loader size="sm" />
</Center>
)}
{catalogQuery.isError && (
<Alert
color="red"
icon={<IconAlertTriangle size={16} />}
title={t("The role catalog is unavailable")}
>
{t("Please try again later.")}
</Alert>
)}
{catalogQuery.data && catalogQuery.data.bundles.length === 0 && (
<Text size="sm" c="dimmed">
{t("No bundles available")}
</Text>
)}
{catalogQuery.data && catalogQuery.data.bundles.length > 0 && (
<Accordion
variant="separated"
value={expanded}
onChange={setExpanded}
>
{catalogQuery.data.bundles.map((bundle) => (
<BundlePanel
key={bundle.id}
bundle={bundle}
language={language}
expanded={expanded === bundle.id}
roles={roles}
conflict={conflict}
selected={selected[bundle.id]}
onToggleSlug={(slug, checked) =>
setSelected((prev) => {
const next = new Set(prev[bundle.id] ?? []);
if (checked) next.add(slug);
else next.delete(slug);
return { ...prev, [bundle.id]: next };
})
}
onSetSelected={(slugs) =>
setSelected((prev) => ({
...prev,
[bundle.id]: new Set(slugs),
}))
}
/>
))}
</Accordion>
)}
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={onClose}>
{t("Close")}
</Button>
</Group>
</Stack>
</Modal>
);
}
interface BundlePanelProps {
bundle: IAiRoleCatalogBundleSummary;
language: string;
expanded: boolean;
roles: IAiRole[];
conflict: Conflict;
selected: Set<string> | undefined;
onToggleSlug: (slug: string, checked: boolean) => void;
onSetSelected: (slugs: string[]) => void;
}
/** One catalog bundle: its roles (fetched when expanded) + a per-bundle import. */
function BundlePanel({
bundle,
language,
expanded,
roles,
conflict,
selected,
onToggleSlug,
onSetSelected,
}: BundlePanelProps) {
const { t } = useTranslation();
// Only fetch this bundle's roles once it is actually expanded.
const bundleQuery = useAiRoleCatalogBundleQuery(
bundle.id,
language,
expanded && !!language,
);
const importMutation = useImportAiRolesFromCatalogMutation();
const updateMutation = useUpdateAiRoleFromCatalogMutation();
// Compute each catalog role's install state against the current workspace
// roles: an importable role matched by source.slug + source.language.
const computed = useMemo(() => {
const list = bundleQuery.data?.roles ?? [];
return list.map((role) => {
const installed = roles.find(
(r) => r.source?.slug === role.slug && r.source?.language === language,
);
if (!installed) return { role, state: "import" as const };
if ((installed.source?.version ?? 0) >= role.version) {
return { role, state: "installed" as const, installed };
}
return { role, state: "update" as const, installed };
});
}, [bundleQuery.data, roles, language]);
// Default-check every importable role once the bundle content arrives (unless
// the user already touched the selection for this bundle).
useEffect(() => {
if (!bundleQuery.data || selected !== undefined) return;
onSetSelected(
computed.filter((c) => c.state === "import").map((c) => c.role.slug),
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bundleQuery.data]);
const importableSlugs = computed
.filter((c) => c.state === "import")
.map((c) => c.role.slug);
const checkedSlugs = importableSlugs.filter((slug) => selected?.has(slug));
function handleImport() {
importMutation.mutate({
bundleId: bundle.id,
language,
slugs: checkedSlugs,
conflict,
});
}
return (
<Accordion.Item value={bundle.id}>
<Accordion.Control>
<Stack gap={2}>
<Text fw={500}>{bundle.name}</Text>
{bundle.description && (
<Text size="xs" c="dimmed">
{bundle.description}
</Text>
)}
</Stack>
</Accordion.Control>
<Accordion.Panel>
{bundleQuery.isLoading && (
<Center py="md">
<Loader size="sm" />
</Center>
)}
{bundleQuery.isError && (
<Alert
color="red"
icon={<IconAlertTriangle size={16} />}
title={t("The role catalog is unavailable")}
>
{t("Please try again later.")}
</Alert>
)}
{bundleQuery.data && (
<Stack gap="xs">
{computed.map(({ role, state, installed }) => (
<CatalogRoleRow
key={role.slug}
role={role}
state={state}
checked={state === "import" ? !!selected?.has(role.slug) : false}
onToggle={(checked) => onToggleSlug(role.slug, checked)}
fromVersion={installed?.source?.version}
onUpdate={
state === "update" && installed
? () => updateMutation.mutate(installed.id)
: undefined
}
updating={updateMutation.isPending}
/>
))}
<Group justify="flex-end" mt="xs">
<Button
size="xs"
onClick={handleImport}
loading={importMutation.isPending}
disabled={checkedSlugs.length === 0}
>
{t("Import")}
</Button>
</Group>
</Stack>
)}
</Accordion.Panel>
</Accordion.Item>
);
}
interface CatalogRoleRowProps {
role: IAiRoleCatalogRole;
state: "import" | "installed" | "update";
checked: boolean;
onToggle: (checked: boolean) => void;
// The installed role's current source version (only set in the "update" state).
fromVersion?: number;
onUpdate?: () => void;
updating: boolean;
}
/** A single catalog role row with its install-state affordance. */
function CatalogRoleRow({
role,
state,
checked,
onToggle,
fromVersion,
onUpdate,
updating,
}: CatalogRoleRowProps) {
const { t } = useTranslation();
return (
<Group justify="space-between" wrap="nowrap" align="flex-start">
<Group gap="xs" wrap="nowrap" align="flex-start" style={{ minWidth: 0 }}>
{state === "import" && (
<Checkbox
checked={checked}
onChange={(event) => onToggle(event.currentTarget.checked)}
aria-label={role.name}
/>
)}
<Stack gap={2} style={{ minWidth: 0 }}>
<Text fw={500} truncate>
{role.emoji ? `${role.emoji} ` : ""}
{role.name}
</Text>
{role.description && (
<Text size="xs" c="dimmed">
{role.description}
</Text>
)}
</Stack>
</Group>
<Group gap="xs" wrap="nowrap" style={{ flex: "none" }}>
{state === "installed" && (
<Badge size="sm" variant="light" color="gray">
{t("Installed")}
</Badge>
)}
{state === "update" && (
<>
<Badge size="sm" variant="light" color="blue">
{t("v{{from}} → v{{to}}", {
from: fromVersion ?? 0,
to: role.version,
})}
</Badge>
<Button size="xs" variant="light" onClick={onUpdate} loading={updating}>
{t("Update")}
</Button>
</>
)}
</Group>
</Group>
);
}

View File

@@ -13,7 +13,12 @@ import {
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { modals } from "@mantine/modals";
import { IconPencil, IconPlus, IconTrash } from "@tabler/icons-react";
import {
IconPackageImport,
IconPencil,
IconPlus,
IconTrash,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import useUserRole from "@/hooks/use-user-role.tsx";
import {
@@ -23,6 +28,7 @@ import {
} from "@/features/ai-chat/queries/ai-chat-query.ts";
import { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
import AiAgentRoleForm from "./ai-agent-role-form.tsx";
import AiAgentRolesCatalogModal from "./ai-agent-roles-catalog-modal.tsx";
/**
* Admin section: list / add / edit / delete reusable agent roles. A role
@@ -39,6 +45,9 @@ export default function AiAgentRoles() {
const deleteMutation = useDeleteAiRoleMutation();
const [opened, { open, close }] = useDisclosure(false);
// Separate disclosure for the catalog (import/update) modal.
const [catalogOpened, { open: openCatalog, close: closeCatalog }] =
useDisclosure(false);
// The role being edited; undefined => the modal is in "create" mode.
const [editing, setEditing] = useState<IAiRole | undefined>(undefined);
@@ -86,14 +95,24 @@ export default function AiAgentRoles() {
/>
<Text fw={600}>{t("Agent roles")}</Text>
</Group>
<Button
leftSection={<IconPlus size={16} />}
variant="default"
size="xs"
onClick={openCreate}
>
{t("Add role")}
</Button>
<Group gap="xs" wrap="nowrap">
<Button
leftSection={<IconPackageImport size={16} />}
variant="default"
size="xs"
onClick={openCatalog}
>
{t("Import from catalog")}
</Button>
<Button
leftSection={<IconPlus size={16} />}
variant="default"
size="xs"
onClick={openCreate}
>
{t("Add role")}
</Button>
</Group>
</Group>
<Text size="xs" c="dimmed" mt={4}>
{t(
@@ -102,9 +121,19 @@ export default function AiAgentRoles() {
</Text>
{!isLoading && (!roles || roles.length === 0) && (
<Text size="sm" c="dimmed" mt="sm">
{t("No roles configured")}
</Text>
<Group gap="sm" mt="sm" align="center">
<Text size="sm" c="dimmed">
{t("No roles configured")}
</Text>
<Button
leftSection={<IconPackageImport size={16} />}
variant="light"
size="xs"
onClick={openCatalog}
>
{t("Browse the catalog")}
</Button>
</Group>
)}
<Stack gap="xs" mt="sm">
@@ -170,6 +199,12 @@ export default function AiAgentRoles() {
{/* Remount the form per target so its internal state re-hydrates. */}
<AiAgentRoleForm key={editing?.id ?? "new"} role={editing} onClose={close} />
</Modal>
<AiAgentRolesCatalogModal
opened={catalogOpened}
onClose={closeCatalog}
roles={roles ?? []}
/>
</Paper>
);
}

View File

@@ -22,6 +22,12 @@ import {
CreateAgentRoleDto,
UpdateAgentRoleDto,
} from './dto/agent-role.dto';
import {
CatalogBundleDto,
CatalogQueryDto,
ImportFromCatalogDto,
UpdateFromCatalogDto,
} from './dto/agent-role-catalog.dto';
/** Path/body param for the per-role routes (update/delete). */
class AgentRoleIdDto {
@@ -113,4 +119,54 @@ export class AiAgentRolesController {
this.assertAdmin(user, workspace);
return this.rolesService.remove(workspace.id, idDto.id);
}
// --- Catalog (admin-only): browse + import + update imported roles. ---
/** Browse the curated catalog (localized to dto.language). */
@HttpCode(HttpStatus.OK)
@Post('catalog')
async catalog(
@Body() dto: CatalogQueryDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
this.assertAdmin(user, workspace);
return this.rolesService.getCatalog(dto.language);
}
/** Open one catalog bundle in a language (role content + versions). */
@HttpCode(HttpStatus.OK)
@Post('catalog/bundle')
async catalogBundle(
@Body() dto: CatalogBundleDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
this.assertAdmin(user, workspace);
return this.rolesService.getCatalogBundle(dto.bundleId, dto.language);
}
/** Import roles from a catalog bundle into the workspace. */
@HttpCode(HttpStatus.OK)
@Post('import')
async import(
@Body() dto: ImportFromCatalogDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
this.assertAdmin(user, workspace);
return this.rolesService.importFromCatalog(workspace.id, user.id, dto);
}
/** Update an already-imported role from its catalog source. */
@HttpCode(HttpStatus.OK)
@Post('update-from-catalog')
async updateFromCatalog(
@Body() dto: UpdateFromCatalogDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
this.assertAdmin(user, workspace);
return this.rolesService.updateFromCatalog(workspace.id, dto);
}
}

View File

@@ -1,16 +1,19 @@
import { Module } from '@nestjs/common';
import { AiAgentRolesController } from './ai-agent-roles.controller';
import { AiAgentRolesService } from './ai-agent-roles.service';
import { AiAgentRolesCatalogProvider } from './catalog/ai-agent-roles-catalog.provider';
/**
* Agent roles unit (v1). Admin CRUD + member-visible listing for the chat
* role picker. AiAgentRoleRepo (DatabaseModule, global) and
* WorkspaceAbilityFactory (CaslModule, global) are resolved without explicit
* imports. The stream-time role resolution + model override live in
* AiChatService / AiService; this module only hosts the management API.
* role picker, plus the admin catalog (browse/import/update). AiAgentRoleRepo
* (DatabaseModule, global), WorkspaceAbilityFactory (CaslModule, global) and
* EnvironmentService (EnvironmentModule, global — used by the catalog provider)
* are resolved without explicit imports. The stream-time role resolution +
* model override live in AiChatService / AiService; this module only hosts the
* management API.
*/
@Module({
controllers: [AiAgentRolesController],
providers: [AiAgentRolesService],
providers: [AiAgentRolesService, AiAgentRolesCatalogProvider],
})
export class AiAgentRolesModule {}

View File

@@ -27,12 +27,22 @@ describe('AiAgentRolesService guards', () => {
enabled: true,
autoStart: true,
launchMessage: null,
source: null,
createdAt: new Date(),
updatedAt: new Date(),
...over,
} as AiAgentRole;
}
// A stubbed catalog provider; the CRUD tests never reach it (they exercise
// create/update/remove/list only), so the methods just reject if hit.
function makeCatalog() {
return {
fetchIndex: jest.fn(),
fetchBundle: jest.fn(),
};
}
function makeService(opts: { existing?: AiAgentRole | undefined } = {}) {
const repo = {
findById: jest.fn().mockResolvedValue(opts.existing),
@@ -41,8 +51,9 @@ describe('AiAgentRolesService guards', () => {
softDelete: jest.fn().mockResolvedValue(undefined),
listByWorkspace: jest.fn().mockResolvedValue([]),
};
const service = new AiAgentRolesService(repo as never);
return { service, repo };
const catalog = makeCatalog();
const service = new AiAgentRolesService(repo as never, catalog as never);
return { service, repo, catalog };
}
describe('update', () => {
@@ -163,6 +174,7 @@ describe('AiAgentRolesService guards', () => {
enabled: false,
autoStart: true,
launchMessage: null,
source: null,
createdAt,
updatedAt,
});
@@ -397,7 +409,7 @@ describe('AiAgentRolesService guards', () => {
softDelete: jest.fn(),
listByWorkspace: jest.fn().mockResolvedValue(rows),
};
const service = new AiAgentRolesService(repo as never);
const service = new AiAgentRolesService(repo as never, makeCatalog() as never);
return { service, repo };
}
@@ -461,4 +473,317 @@ describe('AiAgentRolesService guards', () => {
).rejects.toBeInstanceOf(ConflictException);
});
});
// ---------------------------------------------------------------------------
// Catalog: import (skip / rename / already-installed) and update reconciliation
// against a MOCKED catalog provider + mocked repo (mirrors the CRUD style).
// ---------------------------------------------------------------------------
describe('importFromCatalog', () => {
function catalogRole(over: Record<string, unknown> = {}) {
return {
slug: 'researcher',
name: 'Researcher',
instructions: 'be a researcher',
...over,
};
}
function makeImportService(opts: {
indexRoles?: { slug: string; version: number }[];
bundleRoles?: Record<string, unknown>[];
existing?: AiAgentRole[];
}) {
const index = {
schemaVersion: 1,
bundles: [
{
id: 'general',
name: { en: 'General' },
languages: ['en'],
roles: opts.indexRoles ?? [{ slug: 'researcher', version: 3 }],
},
],
};
const bundle = {
schemaVersion: 1,
language: 'en',
roles: opts.bundleRoles ?? [catalogRole()],
};
const repo = {
findById: jest.fn(),
insert: jest.fn().mockImplementation((v) => Promise.resolve(makeRow(v))),
update: jest.fn().mockResolvedValue(undefined),
softDelete: jest.fn(),
listByWorkspace: jest.fn().mockResolvedValue(opts.existing ?? []),
};
const catalog = {
fetchIndex: jest.fn().mockResolvedValue(index),
fetchBundle: jest.fn().mockResolvedValue(bundle),
};
const service = new AiAgentRolesService(repo as never, catalog as never);
return { service, repo, catalog };
}
const dto = (over: Record<string, unknown> = {}) =>
({
bundleId: 'general',
language: 'en',
conflict: 'skip',
...over,
}) as never;
it('inserts a new role with source { slug, language, version } from the index', async () => {
const { service, repo } = makeImportService({});
const res = await service.importFromCatalog('ws-1', 'u1', dto());
expect(res).toMatchObject({ created: 1, skipped: 0, renamed: 0 });
expect(res.errors).toEqual([]);
const values = repo.insert.mock.calls[0][0];
expect(values.source).toEqual({
slug: 'researcher',
language: 'en',
version: 3,
});
expect(values.enabled).toBe(true);
});
it('already-installed catalog slug => skipped (no insert)', async () => {
const existing = [
makeRow({
id: 'r-existing',
name: 'Old researcher',
source: { slug: 'researcher', language: 'en', version: 1 } as never,
}),
];
const { service, repo } = makeImportService({ existing });
const res = await service.importFromCatalog('ws-1', 'u1', dto());
expect(res).toMatchObject({ created: 0, skipped: 1, renamed: 0 });
expect(repo.insert).not.toHaveBeenCalled();
});
it('same slug installed in a DIFFERENT language => NOT skipped (separate install)', async () => {
// Installed as `ru`; importing the `en` variant of the same slug must
// still import (dedup key is slug+language, matching the client UI).
const existing = [
makeRow({
id: 'r-ru',
name: 'Исследователь',
source: { slug: 'researcher', language: 'ru', version: 1 } as never,
}),
];
const { service, repo } = makeImportService({ existing });
const res = await service.importFromCatalog('ws-1', 'u1', dto());
expect(res).toMatchObject({ created: 1, skipped: 0, renamed: 0 });
expect(repo.insert).toHaveBeenCalledTimes(1);
expect(repo.insert.mock.calls[0][0].source).toEqual({
slug: 'researcher',
language: 'en',
version: 3,
});
});
it('name collision + conflict:skip => skipped (no insert)', async () => {
const existing = [makeRow({ id: 'r-x', name: 'Researcher' })];
const { service, repo } = makeImportService({ existing });
const res = await service.importFromCatalog(
'ws-1',
'u1',
dto({ conflict: 'skip' }),
);
expect(res).toMatchObject({ created: 0, skipped: 1, renamed: 0 });
expect(repo.insert).not.toHaveBeenCalled();
});
it('name collision + conflict:rename => inserts under " (2)"', async () => {
const existing = [makeRow({ id: 'r-x', name: 'Researcher' })];
const { service, repo } = makeImportService({ existing });
const res = await service.importFromCatalog(
'ws-1',
'u1',
dto({ conflict: 'rename' }),
);
expect(res).toMatchObject({ created: 1, skipped: 0, renamed: 1 });
expect(repo.insert.mock.calls[0][0].name).toBe('Researcher (2)');
});
it('dto.slugs filters; an unknown slug becomes an error entry', async () => {
const { service, repo } = makeImportService({
bundleRoles: [catalogRole()],
});
const res = await service.importFromCatalog(
'ws-1',
'u1',
dto({ slugs: ['researcher', 'ghost'] }),
);
expect(res.created).toBe(1);
expect(res.errors).toEqual([
{ slug: 'ghost', message: 'Role not found in catalog bundle' },
]);
expect(repo.insert).toHaveBeenCalledTimes(1);
});
it('insert unique-violation (23505) is recorded as an error, import continues', async () => {
const { service, repo } = makeImportService({
bundleRoles: [
catalogRole({ slug: 'a', name: 'A' }),
catalogRole({ slug: 'b', name: 'B' }),
],
indexRoles: [
{ slug: 'a', version: 1 },
{ slug: 'b', version: 1 },
],
});
repo.insert
.mockRejectedValueOnce({ code: '23505' })
.mockImplementationOnce((v) => Promise.resolve(makeRow(v)));
const res = await service.importFromCatalog('ws-1', 'u1', dto());
expect(res.created).toBe(1);
expect(res.errors).toEqual([
{ slug: 'a', message: 'A role with this name already exists' },
]);
});
});
describe('updateFromCatalog', () => {
function makeUpdateService(opts: {
role?: AiAgentRole;
indexBundles?: unknown[];
bundleRoles?: Record<string, unknown>[];
others?: AiAgentRole[];
}) {
const index = {
schemaVersion: 1,
bundles: opts.indexBundles ?? [
{
id: 'general',
name: { en: 'General' },
languages: ['en'],
roles: [{ slug: 'researcher', version: 5 }],
},
],
};
const bundle = {
schemaVersion: 1,
language: 'en',
roles: opts.bundleRoles ?? [
{ slug: 'researcher', name: 'Researcher v5', instructions: 'new' },
],
};
const repo = {
findById: jest.fn().mockResolvedValue(opts.role),
insert: jest.fn(),
update: jest.fn().mockResolvedValue(undefined),
softDelete: jest.fn(),
listByWorkspace: jest.fn().mockResolvedValue(opts.others ?? []),
};
const catalog = {
fetchIndex: jest.fn().mockResolvedValue(index),
fetchBundle: jest.fn().mockResolvedValue(bundle),
};
const service = new AiAgentRolesService(repo as never, catalog as never);
return { service, repo, catalog };
}
const imported = (version: number, over: Partial<AiAgentRole> = {}) =>
makeRow({
id: 'r1',
name: 'Researcher',
source: { slug: 'researcher', language: 'en', version } as never,
...over,
});
it('role not imported from catalog (source null) => BadRequest', async () => {
const { service } = makeUpdateService({ role: makeRow({ source: null }) });
await expect(
service.updateFromCatalog('ws-1', { id: 'r1' } as never),
).rejects.toBeInstanceOf(BadRequestException);
});
it('role not found => BadRequest', async () => {
const { service } = makeUpdateService({ role: undefined });
await expect(
service.updateFromCatalog('ws-1', { id: 'r1' } as never),
).rejects.toBeInstanceOf(BadRequestException);
});
it('catalog version <= source.version => up-to-date (no update)', async () => {
const { service, repo } = makeUpdateService({ role: imported(5) });
const res = await service.updateFromCatalog('ws-1', { id: 'r1' } as never);
expect(res).toEqual({ updated: false, reason: 'up-to-date' });
expect(repo.update).not.toHaveBeenCalled();
});
it('slug no longer listed in any bundle => not-in-catalog', async () => {
const { service, repo } = makeUpdateService({
role: imported(1),
indexBundles: [
{
id: 'general',
name: { en: 'General' },
languages: ['en'],
roles: [{ slug: 'other', version: 9 }],
},
],
});
const res = await service.updateFromCatalog('ws-1', { id: 'r1' } as never);
expect(res).toEqual({ updated: false, reason: 'not-in-catalog' });
expect(repo.update).not.toHaveBeenCalled();
});
it('source.language no longer offered by the bundle => language-unavailable', async () => {
const { service, repo } = makeUpdateService({
role: imported(1, {
source: { slug: 'researcher', language: 'ru', version: 1 } as never,
}),
indexBundles: [
{
id: 'general',
name: { en: 'General' },
languages: ['en'],
roles: [{ slug: 'researcher', version: 5 }],
},
],
});
const res = await service.updateFromCatalog('ws-1', { id: 'r1' } as never);
expect(res).toEqual({ updated: false, reason: 'language-unavailable' });
expect(repo.update).not.toHaveBeenCalled();
});
it('newer version => updates content + bumps source.version, returns versions', async () => {
const role = imported(1);
const { service, repo } = makeUpdateService({ role });
// The post-update re-fetch returns the bumped row.
repo.findById
.mockResolvedValueOnce(role)
.mockResolvedValueOnce(
imported(5, { name: 'Researcher v5', instructions: 'new' }),
);
const res = await service.updateFromCatalog('ws-1', { id: 'r1' } as never);
expect(res).toMatchObject({
updated: true,
fromVersion: 1,
toVersion: 5,
});
const patch = repo.update.mock.calls[0][2];
expect(patch.source).toEqual({
slug: 'researcher',
language: 'en',
version: 5,
});
expect(patch.name).toBe('Researcher v5');
// enabled is never touched by an update-from-catalog.
expect('enabled' in patch).toBe(false);
});
it('new catalog name collides with another live role => keeps current name', async () => {
const role = imported(1);
const other = makeRow({ id: 'r2', name: 'Researcher v5' });
const { service, repo } = makeUpdateService({ role, others: [role, other] });
repo.findById
.mockResolvedValueOnce(role)
.mockResolvedValueOnce(imported(5));
await service.updateFromCatalog('ws-1', { id: 'r1' } as never);
// The colliding catalog name is dropped; the current name is kept.
expect(repo.update.mock.calls[0][2].name).toBe('Researcher');
});
});
});

View File

@@ -1,4 +1,5 @@
import {
BadGatewayException,
BadRequestException,
ConflictException,
Injectable,
@@ -6,7 +7,17 @@ import {
import { AiAgentRoleRepo } from '@docmost/db/repos/ai-agent-roles/ai-agent-roles.repo';
import { AiAgentRole } from '@docmost/db/types/entity.types';
import { CreateAgentRoleDto, UpdateAgentRoleDto } from './dto/agent-role.dto';
import { ImportFromCatalogDto, UpdateFromCatalogDto } from './dto/agent-role-catalog.dto';
import { RoleModelConfig } from './role-model-config';
import { AiAgentRolesCatalogProvider } from './catalog/ai-agent-roles-catalog.provider';
import { CatalogBundleMeta } from './catalog/catalog-types';
/** The `source` jsonb shape that links an imported role to its catalog origin. */
interface RoleSource {
slug: string;
language: string;
version: number;
}
/**
* Full (admin) view of an agent role. There are no secret columns on this table
@@ -24,6 +35,10 @@ export interface AgentRoleView {
enabled: boolean;
autoStart: boolean;
launchMessage: string | null;
// Catalog origin of an imported role, or null for a manually-created one. The
// admin UI uses `version` to offer an UPDATE when the catalog ships a newer
// revision. Admin-only (deliberately absent from AgentRolePickerView).
source: { slug: string; language: string; version: number } | null;
createdAt: Date;
updatedAt: Date;
}
@@ -56,7 +71,10 @@ export interface AgentRolePickerView {
*/
@Injectable()
export class AiAgentRolesService {
constructor(private readonly repo: AiAgentRoleRepo) {}
constructor(
private readonly repo: AiAgentRoleRepo,
private readonly catalog: AiAgentRolesCatalogProvider,
) {}
/**
* List the workspace's roles. Admins get the full view (the settings page needs
@@ -165,6 +183,291 @@ export class AiAgentRolesService {
return { success: true };
}
// -------------------------------------------------------------------------
// Catalog (admin-only). The catalog is curated, untrusted JSON fetched +
// validated by AiAgentRolesCatalogProvider; this layer resolves localized
// text and reconciles a bundle against the workspace's existing roles.
// -------------------------------------------------------------------------
/**
* Browse the catalog. Returns the union of every bundle's languages (sorted)
* plus per-bundle metadata with `name` / `description` resolved to the
* requested `language` (fallback: 'en', then the first available locale).
*/
async getCatalog(language?: string): Promise<{
languages: string[];
bundles: {
id: string;
name: string;
description: string | null;
languages: string[];
roles: { slug: string; version: number }[];
}[];
}> {
const index = await this.catalog.fetchIndex();
const languages = Array.from(
new Set(index.bundles.flatMap((b) => b.languages)),
).sort();
const bundles = index.bundles.map((b) => ({
id: b.id,
name: localized(b.name, language) ?? b.id,
description: b.description ? localized(b.description, language) : null,
languages: b.languages,
roles: b.roles.map((r) => ({ slug: r.slug, version: r.version })),
}));
return { languages, bundles };
}
/**
* Open one bundle in a language: returns each role's content plus the version
* taken from the index (so the client can compare against an imported role's
* source.version). A missing bundle/language => BadGateway (catalog issue).
*/
async getCatalogBundle(
bundleId: string,
language: string,
): Promise<{
bundleId: string;
language: string;
roles: {
slug: string;
emoji: string | null;
name: string;
description: string | null;
instructions: string;
autoStart: boolean;
launchMessage: string | null;
version: number;
}[];
}> {
const index = await this.catalog.fetchIndex();
const meta = index.bundles.find((b) => b.id === bundleId);
if (!meta) {
throw new BadGatewayException('Catalog bundle not found');
}
const file = await this.catalog.fetchBundle(bundleId, language);
const versions = versionMap(meta);
return {
bundleId,
language,
roles: file.roles.map((r) => ({
slug: r.slug,
emoji: r.emoji ?? null,
name: r.name,
description: r.description ?? null,
instructions: r.instructions,
autoStart: r.autoStart ?? true,
launchMessage: r.launchMessage ?? null,
version: versions.get(r.slug) ?? 1,
})),
};
}
/**
* Import a bundle's roles into the workspace. Roles whose `source.slug` is
* already installed are skipped (updates are a separate action). A name
* collision with an existing role is either skipped or imported under a free
* " (N)" name, per `dto.conflict`. Inserts run sequentially (the repo exposes
* no batch insert and the volume is tiny); a unique-name race still surfaces
* as an error entry rather than aborting the whole import.
*/
async importFromCatalog(
workspaceId: string,
creatorId: string,
dto: ImportFromCatalogDto,
): Promise<{
created: number;
skipped: number;
renamed: number;
errors: { slug: string; message: string }[];
}> {
const index = await this.catalog.fetchIndex();
const meta = index.bundles.find((b) => b.id === dto.bundleId);
if (!meta) {
throw new BadGatewayException('Catalog bundle not found');
}
const file = await this.catalog.fetchBundle(dto.bundleId, dto.language);
const versions = versionMap(meta);
const errors: { slug: string; message: string }[] = [];
// Resolve the selected catalog roles (honor dto.slugs; flag unknown ones).
let selected = file.roles;
if (dto.slugs && dto.slugs.length > 0) {
const wanted = new Set(dto.slugs);
const present = new Set(file.roles.map((r) => r.slug));
for (const slug of dto.slugs) {
if (!present.has(slug)) {
errors.push({ slug, message: 'Role not found in catalog bundle' });
}
}
selected = file.roles.filter((r) => wanted.has(r.slug));
}
const existingRoles = await this.repo.listByWorkspace(workspaceId);
// Catalog roles already installed in this workspace, keyed by slug+language
// (skip; never duplicate). The key MUST match the client install-state and
// updateFromCatalog (both match by source.slug AND source.language): the
// `ru` variant of a slug already installed as `en` is a separate install.
const installedKeys = new Set(
existingRoles
.map((r) => roleSource(r))
.filter((s): s is RoleSource => s !== null)
.map((s) => `${s.slug}:${s.language}`),
);
// Live role names (lowercased) for collision detection. Mutated as we
// insert so two imported roles cannot both grab the same name.
const takenNames = new Set(
existingRoles.map((r) => r.name.trim().toLowerCase()),
);
let created = 0;
let skipped = 0;
let renamed = 0;
for (const role of selected) {
// Already installed from the catalog in THIS language => skip (use
// update-from-catalog). A different language of the same slug still imports.
const installKey = `${role.slug}:${dto.language}`;
if (installedKeys.has(installKey)) {
skipped++;
continue;
}
let name = role.name.trim();
let didRename = false;
if (takenNames.has(name.toLowerCase())) {
if (dto.conflict === 'skip') {
skipped++;
continue;
}
// conflict === 'rename': find a free " (N)" suffix.
name = freeName(name, takenNames);
didRename = true;
}
const version = versions.get(role.slug) ?? 1;
try {
await this.repo.insert({
workspaceId,
creatorId,
name,
emoji: emptyToNull(role.emoji),
description: emptyToNull(role.description),
instructions: role.instructions,
modelConfig: normalizeModelConfig(role.modelConfig) as
| Record<string, unknown>
| null,
enabled: true,
autoStart: role.autoStart ?? true,
launchMessage: emptyToNull(role.launchMessage ?? undefined),
source: { slug: role.slug, language: dto.language, version },
});
created++;
if (didRename) renamed++;
takenNames.add(name.toLowerCase());
installedKeys.add(installKey);
} catch (err) {
errors.push({ slug: role.slug, message: importErrorMessage(err) });
}
}
return { created, skipped, renamed, errors };
}
/**
* Update an already-imported role from its catalog source when the catalog
* ships a newer version. Returns a discriminated result so the UI can explain
* a no-op (up-to-date / removed from catalog / language no longer offered).
* Never touches `enabled`; keeps the current name if the catalog's new name
* would collide with another role (avoiding the unique-name 409).
*/
async updateFromCatalog(
workspaceId: string,
dto: UpdateFromCatalogDto,
): Promise<
| { updated: false; reason: 'not-in-catalog' | 'up-to-date' | 'language-unavailable' }
| { updated: true; fromVersion: number; toVersion: number; role: AgentRoleView }
> {
const role = await this.repo.findById(dto.id, workspaceId);
if (!role) throw new BadRequestException('Role not found');
const source = roleSource(role);
if (!source || !source.slug) {
throw new BadRequestException('Role was not imported from the catalog');
}
const index = await this.catalog.fetchIndex();
// Find the bundle whose meta lists this slug, and its catalog version.
let meta: CatalogBundleMeta | undefined;
let currentVersion: number | undefined;
for (const b of index.bundles) {
const m = b.roles.find((r) => r.slug === source.slug);
if (m) {
meta = b;
currentVersion = m.version;
break;
}
}
if (!meta || currentVersion === undefined) {
return { updated: false, reason: 'not-in-catalog' };
}
if (currentVersion <= source.version) {
return { updated: false, reason: 'up-to-date' };
}
if (!meta.languages.includes(source.language)) {
return { updated: false, reason: 'language-unavailable' };
}
const file = await this.catalog.fetchBundle(meta.id, source.language);
const fresh = file.roles.find((r) => r.slug === source.slug);
if (!fresh) {
return { updated: false, reason: 'not-in-catalog' };
}
// Keep the current name when the catalog's new name would collide with
// another live role (avoids the unique-name 409). Same-name (case-insensitive)
// means "no rename needed".
const newName = fresh.name.trim();
let name = newName;
if (newName.toLowerCase() !== role.name.trim().toLowerCase()) {
const others = await this.repo.listByWorkspace(workspaceId);
const collision = others.some(
(r) =>
r.id !== role.id &&
r.name.trim().toLowerCase() === newName.toLowerCase(),
);
if (collision) name = role.name;
}
await this.repo.update(dto.id, workspaceId, {
name,
emoji: emptyToNull(fresh.emoji),
description: emptyToNull(fresh.description),
instructions: fresh.instructions,
modelConfig: normalizeModelConfig(fresh.modelConfig) as
| Record<string, unknown>
| null,
autoStart: fresh.autoStart ?? true,
launchMessage: emptyToNull(fresh.launchMessage ?? undefined),
// enabled is deliberately NOT changed.
source: {
slug: source.slug,
language: source.language,
version: currentVersion,
},
});
const updated = await this.repo.findById(dto.id, workspaceId);
if (!updated) throw new BadRequestException('Role not found');
return {
updated: true,
fromVersion: source.version,
toVersion: currentVersion,
role: this.toView(updated),
};
}
private toView(row: AiAgentRole): AgentRoleView {
return {
id: row.id,
@@ -176,6 +479,7 @@ export class AiAgentRolesService {
enabled: row.enabled,
autoStart: row.autoStart,
launchMessage: row.launchMessage ?? null,
source: (row.source ?? null) as AgentRoleView['source'],
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
@@ -217,13 +521,71 @@ function rethrowDuplicateName(err: unknown, name: string): never {
throw err;
}
/** '' / whitespace-only / undefined => null; otherwise the trimmed value. */
function emptyToNull(value: string | undefined): string | null {
if (value === undefined) return null;
/** '' / whitespace-only / undefined / null => null; otherwise the trimmed value. */
function emptyToNull(value: string | null | undefined): string | null {
if (value === undefined || value === null) return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
/** Read + shape-check a role's `source` jsonb into a RoleSource, or null. */
function roleSource(role: AiAgentRole): RoleSource | null {
const s = role.source as unknown;
if (!s || typeof s !== 'object' || Array.isArray(s)) return null;
const obj = s as Record<string, unknown>;
if (typeof obj.slug !== 'string') return null;
if (typeof obj.language !== 'string') return null;
if (typeof obj.version !== 'number') return null;
return { slug: obj.slug, language: obj.language, version: obj.version };
}
/** slug -> version map from a bundle's index metadata. */
function versionMap(meta: CatalogBundleMeta): Map<string, number> {
return new Map(meta.roles.map((r) => [r.slug, r.version]));
}
/**
* Resolve a localized value `{ en, ru, ... }` to `language`, falling back to
* 'en', then the first available locale. Returns null only for an empty map.
*/
function localized(
map: Record<string, string>,
language?: string,
): string | null {
if (language && typeof map[language] === 'string') return map[language];
if (typeof map.en === 'string') return map.en;
const first = Object.values(map)[0];
return typeof first === 'string' ? first : null;
}
/**
* Find a free display name by appending " (2)", " (3)", ... when `base` is
* already taken (case-insensitive against `taken`). Caller adds the result to
* `taken` after a successful insert.
*/
function freeName(base: string, taken: Set<string>): string {
let n = 2;
// Cap the search defensively; the loop always terminates well before this.
while (n < 1000) {
const candidate = `${base} (${n})`;
if (!taken.has(candidate.toLowerCase())) return candidate;
n++;
}
return `${base} (${Date.now()})`;
}
/** A short, safe message for an import insert failure (409 vs other). */
function importErrorMessage(err: unknown): string {
if (
err &&
typeof err === 'object' &&
(err as { code?: unknown }).code === '23505'
) {
return 'A role with this name already exists';
}
return 'Failed to import role';
}
/**
* Normalize an incoming modelConfig DTO to the persisted shape, or null when
* there is no usable override (no driver and no chatModel). The DTO's @IsIn

View File

@@ -0,0 +1,218 @@
import { promises as fs } from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { BadGatewayException, BadRequestException } from '@nestjs/common';
import { AiAgentRolesCatalogProvider } from './ai-agent-roles-catalog.provider';
/**
* Provider tests against a LOCAL fixture directory (no network). They cover the
* happy read path (fetchIndex / fetchBundle), the malformed-shape rejection, a
* missing file => unavailable, and — most importantly — the `^[a-z0-9-]+$`
* path-traversal guard that runs BEFORE any path is built.
*/
describe('AiAgentRolesCatalogProvider (local fixtures)', () => {
let dir: string;
function makeProvider(source: string) {
const env = {
getAiAgentRolesCatalogSource: () => source,
};
return new AiAgentRolesCatalogProvider(env as never);
}
beforeAll(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-roles-catalog-'));
await fs.writeFile(
path.join(dir, 'index.json'),
JSON.stringify({
schemaVersion: 1,
bundles: [
{
id: 'general',
name: { en: 'General', ru: 'Общие' },
languages: ['en'],
roles: [{ slug: 'researcher', version: 2 }],
},
],
}),
'utf8',
);
await fs.mkdir(path.join(dir, 'bundles', 'general'), { recursive: true });
await fs.writeFile(
path.join(dir, 'bundles', 'general', 'en.json'),
JSON.stringify({
schemaVersion: 1,
language: 'en',
roles: [
{
slug: 'researcher',
name: 'Researcher',
instructions: 'be a researcher',
},
],
}),
'utf8',
);
// A malformed bundle (a role missing `instructions`) to test rejection.
await fs.writeFile(
path.join(dir, 'bundles', 'general', 'fr.json'),
JSON.stringify({
schemaVersion: 1,
language: 'fr',
roles: [{ slug: 'researcher', name: 'Chercheur' }],
}),
'utf8',
);
});
afterAll(async () => {
await fs.rm(dir, { recursive: true, force: true });
});
it('fetchIndex reads + validates index.json', async () => {
const provider = makeProvider(dir);
const index = await provider.fetchIndex();
expect(index.schemaVersion).toBe(1);
expect(index.bundles[0].id).toBe('general');
expect(index.bundles[0].roles[0]).toEqual({
slug: 'researcher',
version: 2,
});
});
it('fetchBundle reads + validates a language file', async () => {
const provider = makeProvider(dir);
const bundle = await provider.fetchBundle('general', 'en');
expect(bundle.language).toBe('en');
expect(bundle.roles[0].slug).toBe('researcher');
expect(bundle.roles[0].instructions).toBe('be a researcher');
});
it('malformed bundle (missing instructions) => BadGateway', async () => {
const provider = makeProvider(dir);
await expect(provider.fetchBundle('general', 'fr')).rejects.toBeInstanceOf(
BadGatewayException,
);
});
it('missing file => BadGateway (unavailable)', async () => {
const provider = makeProvider(dir);
await expect(
provider.fetchBundle('general', 'de'),
).rejects.toBeInstanceOf(BadGatewayException);
});
it('empty source resolves to the in-repo folder (no throw building the path)', async () => {
// With an empty source the provider targets ./agent-roles-catalog under the
// cwd; that folder is created by a separate task, so a read here surfaces as
// BadGateway (unavailable) rather than a path-build error.
const provider = makeProvider('');
await expect(provider.fetchIndex()).rejects.toBeInstanceOf(
BadGatewayException,
);
});
describe('remote fetch streaming size cap', () => {
const realFetch = global.fetch;
afterEach(() => {
global.fetch = realFetch;
});
/** A web ReadableStream that yields `chunks` (each a Uint8Array). */
function streamOf(chunks: Uint8Array[]): ReadableStream<Uint8Array> {
let i = 0;
return new ReadableStream<Uint8Array>({
pull(controller) {
if (i < chunks.length) controller.enqueue(chunks[i++]);
else controller.close();
},
// The provider cancels the reader on the too-large path; no-op here.
cancel() {},
});
}
function mockResponse(opts: {
ok?: boolean;
status?: number;
headers?: Record<string, string>;
body: ReadableStream<Uint8Array> | null;
}): Response {
return {
ok: opts.ok ?? true,
status: opts.status ?? 200,
headers: { get: (k: string) => opts.headers?.[k.toLowerCase()] ?? null },
body: opts.body,
text: async () => 'unused',
} as unknown as Response;
}
it('declared Content-Length over the cap => BadGateway before reading the body', async () => {
global.fetch = jest.fn().mockResolvedValue(
mockResponse({
headers: { 'content-length': String(2_000_000) },
body: streamOf([new Uint8Array(10)]),
}),
) as never;
const provider = makeProvider('https://catalog.example.com');
await expect(provider.fetchIndex()).rejects.toBeInstanceOf(
BadGatewayException,
);
});
it('streamed body exceeding the cap (no/under-reported Content-Length) => BadGateway', async () => {
// 1.5 MB streamed in 256 KB chunks, with no Content-Length header.
const chunks = Array.from(
{ length: 6 },
() => new Uint8Array(256 * 1024),
);
global.fetch = jest
.fn()
.mockResolvedValue(mockResponse({ body: streamOf(chunks) })) as never;
const provider = makeProvider('https://catalog.example.com');
await expect(provider.fetchIndex()).rejects.toBeInstanceOf(
BadGatewayException,
);
});
it('small streamed body parses normally (cap not hit)', async () => {
const json = JSON.stringify({
schemaVersion: 1,
bundles: [
{
id: 'general',
name: { en: 'General' },
languages: ['en'],
roles: [{ slug: 'researcher', version: 2 }],
},
],
});
const body = streamOf([new TextEncoder().encode(json)]);
global.fetch = jest
.fn()
.mockResolvedValue(mockResponse({ body })) as never;
const provider = makeProvider('https://catalog.example.com');
const index = await provider.fetchIndex();
expect(index.bundles[0].id).toBe('general');
});
});
describe('path-traversal / SSRF guard (^[a-z0-9-]+$)', () => {
const bad = ['../etc', 'a/b', 'A', 'foo.bar', 'foo_bar', '', '..'];
for (const value of bad) {
it(`rejects bundleId="${value}" with BadRequest`, async () => {
const provider = makeProvider(dir);
await expect(
provider.fetchBundle(value, 'en'),
).rejects.toBeInstanceOf(BadRequestException);
});
it(`rejects language="${value}" with BadRequest`, async () => {
const provider = makeProvider(dir);
await expect(
provider.fetchBundle('general', value),
).rejects.toBeInstanceOf(BadRequestException);
});
}
});
});

View File

@@ -0,0 +1,266 @@
import { promises as fs } from 'node:fs';
import * as path from 'node:path';
import {
BadGatewayException,
BadRequestException,
Injectable,
} from '@nestjs/common';
import { EnvironmentService } from '../../../../integrations/environment/environment.service';
import {
CatalogBundleFile,
CatalogBundleMeta,
CatalogIndex,
CatalogRole,
} from './catalog-types';
/** Identifier shape allowed in any path/URL segment (bundleId, language). The
* ONLY characters that can appear in a fetched path — the path-traversal and
* SSRF guard. Anything else is rejected before a path/URL is built. */
const SEGMENT_RE = /^[a-z0-9-]+$/;
/** Remote fetch timeout and response-size cap. A curated catalog file is tiny;
* the cap stops a hostile/misconfigured source from streaming unbounded data. */
const FETCH_TIMEOUT_MS = 10_000;
const MAX_BYTES = 1_000_000;
/**
* Fetches + validates the agent-roles catalog from its configured source. The
* source location (EnvironmentService.getAiAgentRolesCatalogSource()) is either
* an http(s):// base URL (REMOTE) or a local filesystem directory (LOCAL; the
* empty default resolves to the in-repo `agent-roles-catalog/` folder).
*
* The catalog is UNTRUSTED input: every file is JSON-parsed and run through a
* hand-written type guard before any field is exposed, and every dynamic path
* segment is validated against SEGMENT_RE up front (path-traversal + SSRF).
*/
@Injectable()
export class AiAgentRolesCatalogProvider {
constructor(private readonly environmentService: EnvironmentService) {}
/** Read + validate the top-level index (`index.json`). */
async fetchIndex(): Promise<CatalogIndex> {
const raw = await this.readRelative('index.json');
const parsed = this.parseJson(raw, 'index.json');
if (!isCatalogIndex(parsed)) {
throw new BadGatewayException(
'Agent roles catalog index is malformed (index.json)',
);
}
return parsed;
}
/** Read + validate one language file (`bundles/<bundleId>/<language>.json`). */
async fetchBundle(
bundleId: string,
language: string,
): Promise<CatalogBundleFile> {
// SECURITY: validate BEFORE building any path/URL (path-traversal + SSRF).
this.assertSegment(bundleId, 'bundleId');
this.assertSegment(language, 'language');
const rel = `bundles/${bundleId}/${language}.json`;
const raw = await this.readRelative(rel);
const parsed = this.parseJson(raw, rel);
if (!isCatalogBundleFile(parsed)) {
throw new BadGatewayException(
`Agent roles catalog bundle is malformed (${rel})`,
);
}
return parsed;
}
/** Reject a segment that is not a safe `[a-z0-9-]+` identifier. */
private assertSegment(value: string, field: string): void {
if (typeof value !== 'string' || !SEGMENT_RE.test(value)) {
throw new BadRequestException(`Invalid ${field}`);
}
}
/** JSON.parse with a clear BadGateway on malformed content. */
private parseJson(raw: string, rel: string): unknown {
try {
return JSON.parse(raw);
} catch {
throw new BadGatewayException(
`Agent roles catalog file is not valid JSON (${rel})`,
);
}
}
/** Read a relative catalog path as text from the configured source. */
private async readRelative(rel: string): Promise<string> {
const source = this.environmentService
.getAiAgentRolesCatalogSource()
.trim();
if (/^https?:\/\//i.test(source)) {
return this.fetchRemote(source, rel);
}
const dir = source || path.join(process.cwd(), 'agent-roles-catalog');
return this.readLocal(dir, rel);
}
/** Read a local catalog file. Missing => the catalog is unavailable. */
private async readLocal(dir: string, rel: string): Promise<string> {
try {
return await fs.readFile(path.join(dir, rel), 'utf8');
} catch {
throw new BadGatewayException('Agent roles catalog is unavailable');
}
}
/**
* Fetch a remote catalog file with a timeout + a STREAMING size cap. The body
* is never buffered in full before the check: we reject on a too-large
* Content-Length up front, then read the stream chunk-by-chunk and abort the
* moment the running total exceeds MAX_BYTES, so a hostile/misconfigured
* source cannot make us hold an unbounded body in memory.
*/
private async fetchRemote(base: string, rel: string): Promise<string> {
const url = `${base.replace(/\/+$/, '')}/${rel}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
let response: Response;
try {
response = await fetch(url, { signal: controller.signal });
} catch {
throw new BadGatewayException('Agent roles catalog is unavailable');
}
if (!response.ok) {
throw new BadGatewayException(
`Agent roles catalog returned ${response.status}`,
);
}
// Reject a too-large declared size before reading any body bytes.
const declared = Number(response.headers.get('content-length'));
if (Number.isFinite(declared) && declared > MAX_BYTES) {
throw new BadGatewayException('Agent roles catalog file is too large');
}
// Bound the actual read: a missing/lying Content-Length is caught here.
if (response.body) {
return await readStreamCapped(response.body, MAX_BYTES);
}
// Edge: no readable stream — fall back to a buffered read + length check.
const text = await response.text();
if (text.length > MAX_BYTES) {
throw new BadGatewayException('Agent roles catalog file is too large');
}
return text;
} finally {
clearTimeout(timer);
}
}
}
/**
* Read a web ReadableStream into a UTF-8 string, throwing as soon as the
* accumulated byte count exceeds `maxBytes` (the reader is cancelled so the
* underlying connection is released). Never buffers more than the cap + the
* final chunk before bailing out.
*/
async function readStreamCapped(
body: ReadableStream<Uint8Array>,
maxBytes: number,
): Promise<string> {
const reader = body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
total += value.length;
if (total > maxBytes) {
throw new BadGatewayException('Agent roles catalog file is too large');
}
chunks.push(value);
}
} finally {
// Release the stream on both the normal and the too-large/abort paths.
await reader.cancel().catch(() => undefined);
}
const merged = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
merged.set(chunk, offset);
offset += chunk.length;
}
return new TextDecoder('utf-8').decode(merged);
}
// ---------------------------------------------------------------------------
// Hand-written type guards (no zod / new deps). Each validates the exact wire
// shape declared in catalog-types.ts; anything else is rejected by the caller.
// ---------------------------------------------------------------------------
function isObject(v: unknown): v is Record<string, unknown> {
return v !== null && typeof v === 'object' && !Array.isArray(v);
}
function isStringMap(v: unknown): v is Record<string, string> {
if (!isObject(v)) return false;
return Object.values(v).every((x) => typeof x === 'string');
}
function isStringArray(v: unknown): v is string[] {
return Array.isArray(v) && v.every((x) => typeof x === 'string');
}
export function isCatalogRole(v: unknown): v is CatalogRole {
if (!isObject(v)) return false;
if (typeof v.slug !== 'string') return false;
if (typeof v.name !== 'string') return false;
if (typeof v.instructions !== 'string') return false;
if (v.emoji !== undefined && typeof v.emoji !== 'string') return false;
if (v.description !== undefined && typeof v.description !== 'string') {
return false;
}
if (v.autoStart !== undefined && typeof v.autoStart !== 'boolean') {
return false;
}
if (
v.launchMessage !== undefined &&
v.launchMessage !== null &&
typeof v.launchMessage !== 'string'
) {
return false;
}
if (
v.modelConfig !== undefined &&
v.modelConfig !== null &&
!isObject(v.modelConfig)
) {
return false;
}
return true;
}
export function isCatalogBundleFile(v: unknown): v is CatalogBundleFile {
if (!isObject(v)) return false;
if (typeof v.schemaVersion !== 'number') return false;
if (typeof v.language !== 'string') return false;
if (!Array.isArray(v.roles)) return false;
return v.roles.every(isCatalogRole);
}
function isCatalogBundleMeta(v: unknown): v is CatalogBundleMeta {
if (!isObject(v)) return false;
if (typeof v.id !== 'string') return false;
if (!isStringMap(v.name)) return false;
if (v.description !== undefined && !isStringMap(v.description)) return false;
if (!isStringArray(v.languages)) return false;
if (!Array.isArray(v.roles)) return false;
return v.roles.every(
(r) =>
isObject(r) &&
typeof r.slug === 'string' &&
typeof r.version === 'number',
);
}
export function isCatalogIndex(v: unknown): v is CatalogIndex {
if (!isObject(v)) return false;
if (typeof v.schemaVersion !== 'number') return false;
if (!Array.isArray(v.bundles)) return false;
return v.bundles.every(isCatalogBundleMeta);
}

View File

@@ -0,0 +1,47 @@
/**
* Catalog wire shapes. The catalog is curated, untrusted JSON (a GitHub repo or
* a local folder), so every shape is validated by a hand-written type guard in
* the provider before any field is used — no zod / new deps on the server.
*
* Localized fields (`name` / `description` at the bundle level) are
* `Record<language, string>` so one bundle serves many UI languages; per-role
* `name` / `description` are already language-specific (the bundle file is keyed
* by language).
*/
/** One role's content as shipped in a per-language bundle file. */
export interface CatalogRole {
slug: string;
emoji?: string;
name: string;
description?: string;
instructions: string;
autoStart?: boolean;
launchMessage?: string | null;
// Optional model override; same loose object shape as ai_agent_roles.model_config.
modelConfig?: Record<string, unknown> | null;
}
/** A single language file: `bundles/<id>/<language>.json`. */
export interface CatalogBundleFile {
schemaVersion: number;
language: string;
roles: CatalogRole[];
}
/** Bundle metadata as listed in the top-level index. Versions live here (per
* slug), so an UPDATE check needs only the index, not every language file. */
export interface CatalogBundleMeta {
id: string;
// Localized display name/description: { en: '...', ru: '...' }.
name: Record<string, string>;
description?: Record<string, string>;
languages: string[];
roles: { slug: string; version: number }[];
}
/** Top-level catalog index: `index.json`. */
export interface CatalogIndex {
schemaVersion: number;
bundles: CatalogBundleMeta[];
}

View File

@@ -0,0 +1,62 @@
import {
IsArray,
IsIn,
IsOptional,
IsString,
IsUUID,
Matches,
MaxLength,
} from 'class-validator';
/** Safe identifier shape for any catalog path segment (bundleId / language).
* Mirrors SEGMENT_RE in the catalog provider — the path-traversal/SSRF guard
* is enforced both at the API boundary (here) and in the provider. */
const SEGMENT_RE = /^[a-z0-9-]+$/;
/** Browse the catalog, optionally localized to `language` (defaults applied in
* the service: fall back to 'en', then the first available language). */
export class CatalogQueryDto {
@IsOptional()
@IsString()
@MaxLength(16)
language?: string;
}
/** Open one catalog bundle in a specific language. */
export class CatalogBundleDto {
@IsString()
@Matches(SEGMENT_RE)
bundleId: string;
@IsString()
@Matches(SEGMENT_RE)
language: string;
}
/** Import roles from a catalog bundle into the workspace. */
export class ImportFromCatalogDto {
@IsString()
@Matches(SEGMENT_RE)
bundleId: string;
@IsString()
@Matches(SEGMENT_RE)
language: string;
// Omitted => import the whole bundle; otherwise only these slugs.
@IsOptional()
@IsArray()
@IsString({ each: true })
slugs?: string[];
// How to handle a name collision with an existing (non-catalog) role:
// 'skip' leaves it; 'rename' imports under a free " (N)" name.
@IsIn(['skip', 'rename'])
conflict: 'skip' | 'rename';
}
/** Update an already-imported role from its catalog source. */
export class UpdateFromCatalogDto {
@IsUUID()
id: string;
}

View File

@@ -0,0 +1,19 @@
import { type Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
// `source` links an imported role back to its catalog origin
// `{ slug, language, version }`. Nullable: null => a manually-created role
// (no catalog provenance). The version lets the admin UI offer an UPDATE when
// the catalog ships a newer revision of the same slug.
await db.schema
.alterTable('ai_agent_roles')
.addColumn('source', 'jsonb', (col) => col)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('ai_agent_roles')
.dropColumn('source')
.execute();
}

View File

@@ -1,4 +1,4 @@
import { AiAgentRoleRepo } from './ai-agent-roles.repo';
import { AiAgentRoleRepo, parseSource } from './ai-agent-roles.repo';
import type { KyselyDB } from '../../types/kysely.types';
/**
@@ -132,4 +132,50 @@ describe('AiAgentRoleRepo insert/update auto-start columns', () => {
expect(set2.mock.calls[0][0].launchMessage).toBeNull();
expect('autoStart' in set2.mock.calls[0][0]).toBe(false);
});
it('insert binds `source` (jsonb); update sets it only when present', async () => {
const { repo, values } = makeInsertRepo();
await repo.insert({
workspaceId: 'ws-1',
name: 'R',
instructions: 'do',
source: { slug: 'researcher', language: 'en', version: 1 },
});
// jsonbBind returns a RawBuilder for a non-empty object (not null).
expect(values.mock.calls[0][0].source).not.toBeNull();
const { repo: repo2, set } = makeUpdateRepo();
await repo2.update('r-1', 'ws-1', { name: 'X' });
expect('source' in set.mock.calls[0][0]).toBe(false);
const { repo: repo3, set: set3 } = makeUpdateRepo();
await repo3.update('r-1', 'ws-1', {
source: { slug: 's', language: 'en', version: 2 },
});
expect('source' in set3.mock.calls[0][0]).toBe(true);
});
});
/**
* parseSource: a JSON-string (legacy double-encoded) is parsed; a real object
* passes through; null / a non-object / an array degrade to null (= manual role).
*/
describe('parseSource', () => {
it('parses a legacy double-encoded JSON string into the object', () => {
expect(
parseSource('{"slug":"researcher","language":"en","version":1}'),
).toEqual({ slug: 'researcher', language: 'en', version: 1 });
});
it('passes an already-parsed object through', () => {
const obj = { slug: 's', language: 'en', version: 2 };
expect(parseSource(obj)).toEqual(obj);
});
it('null / array / non-object / unparseable string => null', () => {
expect(parseSource(null)).toBeNull();
expect(parseSource([1, 2])).toBeNull();
expect(parseSource(42)).toBeNull();
expect(parseSource('not json')).toBeNull();
});
});

View File

@@ -81,6 +81,8 @@ export class AiAgentRoleRepo {
autoStart?: boolean;
// null/'' => stored as null (client default launch message).
launchMessage?: string | null;
// Catalog origin { slug, language, version } | null. null => manual role.
source?: Record<string, unknown> | null;
},
trx?: KyselyTransaction,
): Promise<AiAgentRole> {
@@ -103,6 +105,9 @@ export class AiAgentRoleRepo {
autoStart: values.autoStart ?? true,
// Empty string is treated as "no custom text" => null.
launchMessage: values.launchMessage || null,
// Same cast reason as modelConfig (see above).
// eslint-disable-next-line @typescript-eslint/no-explicit-any
source: jsonbBind(values.source) as any,
})
.returningAll()
.executeTakeFirst();
@@ -124,6 +129,8 @@ export class AiAgentRoleRepo {
autoStart?: boolean;
// undefined => unchanged; null/'' => clear to null; string => set.
launchMessage?: string | null;
// undefined => unchanged; null => clear; object => set.
source?: Record<string, unknown> | null;
},
trx?: KyselyTransaction,
): Promise<void> {
@@ -142,6 +149,9 @@ export class AiAgentRoleRepo {
// Empty string clears to null (client default launch message).
set.launchMessage = patch.launchMessage || null;
}
if (patch.source !== undefined) {
set.source = jsonbBind(patch.source);
}
await db
.updateTable('aiAgentRoles')
.set(set)
@@ -192,8 +202,23 @@ export function parseModelConfig(
);
}
/** Normalize a DB row so `modelConfig` is always an object or null. The cast
* bridges parseModelConfig's concrete `Record | null` to the column's broad
/**
* Parse the `source` jsonb value read from the DB into an object or null,
* analogous to {@link parseModelConfig}. Same legacy double-encoding self-heal
* (a JSON string is parsed once) and the same shape guard: not null, an object,
* and not an array. A corrupt / wrong-shaped value degrades to null (= manually
* created), so a bad row never breaks the read path.
*/
export function parseSource(value: unknown): Record<string, unknown> | null {
return parseJsonbValue(
value,
(v): v is Record<string, unknown> =>
v !== null && typeof v === 'object' && !Array.isArray(v),
);
}
/** Normalize a DB row so `modelConfig` and `source` are always an object or
* null. The casts bridge the concrete `Record | null` to the column's broad
* generated `JsonValue` type (an object is a valid JsonValue at runtime). */
function normalizeRow(row: AiAgentRole): AiAgentRole {
return {
@@ -201,5 +226,6 @@ function normalizeRow(row: AiAgentRole): AiAgentRole {
modelConfig: parseModelConfig(
row.modelConfig,
) as AiAgentRole['modelConfig'],
source: parseSource(row.source) as AiAgentRole['source'],
};
}

View File

@@ -618,6 +618,8 @@ export interface AiAgentRoles {
autoStart: Generated<boolean>;
// Optional custom auto-start text. null/empty => client default launch message.
launchMessage: string | null;
// Catalog origin of an imported role: { slug, language, version } | null. null => manually created.
source: Json | null;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
deletedAt: Timestamp | null;

View File

@@ -289,6 +289,15 @@ export class EnvironmentService {
// provider/model/key config now lives solely in workspace settings +
// ai_provider_credentials, with no env fallback. APP_SECRET stays (getAppSecret).
getAiAgentRolesCatalogSource(): string {
// Catalog location. http(s):// URL => fetched remotely; anything else => a
// local filesystem directory. Defaults to the in-repo folder (dev). In prod
// set this to the raw GitHub base URL of the catalog repo. Unlike the AI_*
// getters above this is INFRA config (where the catalog lives), not
// provider/model config — so an env var here is appropriate.
return this.configService.get<string>('AI_AGENT_ROLES_CATALOG_URL', '');
}
getEventStoreDriver(): string {
return this.configService
.get<string>('EVENT_STORE_DRIVER', 'postgres')