Compare commits
12 Commits
cf6b78bca1
...
e682bbccd1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e682bbccd1 | ||
|
|
9d2bec8eb8 | ||
| b6630deb32 | |||
|
|
7ef98a663b | ||
| 109ab10fc5 | |||
|
|
2b7c861f78 | ||
|
|
d181b5c4ff | ||
|
|
12ff76fb89 | ||
|
|
26ca19f89e | ||
|
|
50e79275e1 | ||
|
|
8be8279809 | ||
|
|
19f84ca0e7 |
@@ -132,6 +132,13 @@ MCP_DOCMOST_PASSWORD=
|
|||||||
# NEVER set is_agent on a human or shared account — every action by that account
|
# 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.
|
# (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.
|
# Per-embedding-call timeout in milliseconds for the RAG indexer.
|
||||||
# A slow/hung embeddings endpoint fails after this and the batch continues.
|
# A slow/hung embeddings endpoint fails after this and the batch continues.
|
||||||
# AI_EMBEDDING_TIMEOUT_MS=120000
|
# AI_EMBEDDING_TIMEOUT_MS=120000
|
||||||
|
|||||||
14
CHANGELOG.md
14
CHANGELOG.md
@@ -28,6 +28,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
answer was cut off and builds on it instead of restarting; the rest of the
|
answer was cut off and builds on it instead of restarting; the rest of the
|
||||||
queue still flushes normally afterward. (#198)
|
queue still flushes normally afterward. (#198)
|
||||||
|
|
||||||
|
- **Importable multilingual agent-roles catalog.** Admins can browse a curated
|
||||||
|
catalog of agent roles, grouped into bundles and offered in several languages,
|
||||||
|
and import the ones they want into the workspace (with skip-or-rename handling
|
||||||
|
for name collisions); the same role in a different language imports as a
|
||||||
|
separate install. An imported role remembers its catalog origin and offers a
|
||||||
|
one-click update when the catalog ships a newer revision. Backed by four new
|
||||||
|
admin endpoints — `POST /ai-chat/roles/catalog` (browse bundles),
|
||||||
|
`/catalog/bundle` (read one bundle's roles), `/import`, and
|
||||||
|
`/update-from-catalog` — and a new `source` column linking a role to its
|
||||||
|
catalog slug/language/version. The catalog source is configurable via the new
|
||||||
|
`AI_AGENT_ROLES_CATALOG_URL` env var (an `http(s)://` base URL fetches it
|
||||||
|
remotely; otherwise a local directory; empty defaults to the in-repo
|
||||||
|
`agent-roles-catalog/` folder — see `.env.example`). (#222)
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **A shared page now keeps EXACTLY ONE custom address (`/l/:alias`).** Editing a
|
- **A shared page now keeps EXACTLY ONE custom address (`/l/:alias`).** Editing a
|
||||||
|
|||||||
149
agent-roles-catalog/README.md
Normal file
149
agent-roles-catalog/README.md
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
# 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`.
|
||||||
|
|
||||||
|
## How it's served
|
||||||
|
|
||||||
|
The server does not bundle this data; it reads it at request time from a single
|
||||||
|
configured location, the `AI_AGENT_ROLES_CATALOG_URL` env var
|
||||||
|
(`EnvironmentService.getAiAgentRolesCatalogSource()`). The value selects one of
|
||||||
|
three sources:
|
||||||
|
|
||||||
|
- **`http(s)://…`** — a REMOTE base URL. The server fetches `<base>/index.json`
|
||||||
|
for the manifest and `<base>/bundles/<bundle-id>/<lang>.json` for each opened
|
||||||
|
bundle file (e.g. the raw GitHub base of the catalog repo in production).
|
||||||
|
- **any other non-empty value** — a LOCAL filesystem directory; the same
|
||||||
|
`index.json` / `bundles/<id>/<lang>.json` paths are read from disk.
|
||||||
|
- **empty / unset** (the default) — the in-repo `agent-roles-catalog/` folder
|
||||||
|
(this directory), i.e. local dev reads these files directly.
|
||||||
|
|
||||||
|
In every case the layout below is what the server expects, and the fetched JSON
|
||||||
|
is re-validated server-side (the catalog is treated as untrusted input). See
|
||||||
|
`.env.example` for the variable and the CHANGELOG for the rollout.
|
||||||
|
|
||||||
|
## `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.
|
||||||
60
agent-roles-catalog/bundles/editorial/en.json
Normal file
60
agent-roles-catalog/bundles/editorial/en.json
Normal file
File diff suppressed because one or more lines are too long
60
agent-roles-catalog/bundles/editorial/ru.json
Normal file
60
agent-roles-catalog/bundles/editorial/ru.json
Normal file
File diff suppressed because one or more lines are too long
15
agent-roles-catalog/bundles/research/en.json
Normal file
15
agent-roles-catalog/bundles/research/en.json
Normal file
File diff suppressed because one or more lines are too long
15
agent-roles-catalog/bundles/research/ru.json
Normal file
15
agent-roles-catalog/bundles/research/ru.json
Normal file
File diff suppressed because one or more lines are too long
32
agent-roles-catalog/index.json
Normal file
32
agent-roles-catalog/index.json
Normal 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 } ]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
8
agent-roles-catalog/package.json
Normal file
8
agent-roles-catalog/package.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "agent-roles-catalog",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"check": "node scripts/check.mjs"
|
||||||
|
}
|
||||||
|
}
|
||||||
130
agent-roles-catalog/scripts/check.mjs
Normal file
130
agent-roles-catalog/scripts/check.mjs
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
#!/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.
|
||||||
|
if (slugSeen.has(slug)) {
|
||||||
|
const prev = slugSeen.get(slug);
|
||||||
|
const prevBundle = prev.split("/")[0];
|
||||||
|
if (prevBundle !== bundleId) {
|
||||||
|
errors.push(
|
||||||
|
`Slug "${slug}" is duplicated across the catalog: ${prev} and ${where}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
slugSeen.set(slug, where);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
console.error("Catalog check FAILED:");
|
||||||
|
for (const e of errors) console.error(` - ${e}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("OK");
|
||||||
@@ -1346,5 +1346,22 @@
|
|||||||
"Could not generate a title": "Could not generate a title",
|
"Could not generate a title": "Could not generate a title",
|
||||||
"AI title generation is disabled": "AI title generation is disabled",
|
"AI title generation is disabled": "AI title generation is disabled",
|
||||||
"AI is not configured": "AI is not configured",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1203,5 +1203,23 @@
|
|||||||
"Could not generate a title": "Не удалось придумать название",
|
"Could not generate a title": "Не удалось придумать название",
|
||||||
"AI title generation is disabled": "Генерация названий через AI отключена",
|
"AI title generation is disabled": "Генерация названий через AI отключена",
|
||||||
"AI is not configured": "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": "Этот язык больше не доступен в каталоге"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,21 +13,40 @@ import {
|
|||||||
deleteAiRole,
|
deleteAiRole,
|
||||||
getAiChatMessages,
|
getAiChatMessages,
|
||||||
getAiChats,
|
getAiChats,
|
||||||
|
getAiRoleCatalog,
|
||||||
|
getAiRoleCatalogBundle,
|
||||||
getAiRoles,
|
getAiRoles,
|
||||||
|
importAiRolesFromCatalog,
|
||||||
renameAiChat,
|
renameAiChat,
|
||||||
updateAiRole,
|
updateAiRole,
|
||||||
|
updateAiRoleFromCatalog,
|
||||||
} from "@/features/ai-chat/services/ai-chat-service.ts";
|
} from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||||
import {
|
import {
|
||||||
IAiChat,
|
IAiChat,
|
||||||
IAiChatMessageRow,
|
IAiChatMessageRow,
|
||||||
IAiRole,
|
IAiRole,
|
||||||
|
IAiRoleCatalog,
|
||||||
|
IAiRoleCatalogBundle,
|
||||||
IAiRoleCreate,
|
IAiRoleCreate,
|
||||||
|
IAiRoleImportPayload,
|
||||||
|
IAiRoleImportResult,
|
||||||
IAiRoleUpdate,
|
IAiRoleUpdate,
|
||||||
|
IAiRoleUpdateFromCatalogResult,
|
||||||
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||||
import { IPagination } from "@/lib/types.ts";
|
import { IPagination } from "@/lib/types.ts";
|
||||||
|
|
||||||
export const AI_CHATS_RQ_KEY = ["ai-chats"];
|
export const AI_CHATS_RQ_KEY = ["ai-chats"];
|
||||||
export const AI_ROLES_RQ_KEY = ["ai-roles"];
|
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) => [
|
export const AI_CHAT_MESSAGES_RQ_KEY = (chatId: string) => [
|
||||||
"ai-chat-messages",
|
"ai-chat-messages",
|
||||||
chatId,
|
chatId,
|
||||||
@@ -223,3 +242,109 @@ 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".
|
||||||
|
// Narrow the discriminated union via `"reason" in result` (the `updated`
|
||||||
|
// boolean discriminant does not narrow under this project's
|
||||||
|
// strictNullChecks:false). Inside the branch, `reason` is the typed literal
|
||||||
|
// union, so the comparisons below are compiler-checked.
|
||||||
|
let message: string;
|
||||||
|
if (!("reason" in result)) {
|
||||||
|
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" (the only remaining 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",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import React from "react";
|
||||||
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import type { IAiRoleImportResult } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||||
|
|
||||||
|
// `useImportAiRolesFromCatalogMutation` always shows an Imported/renamed/skipped
|
||||||
|
// summary, and ADDITIONALLY a red "Failed to import N role(s)" notification when
|
||||||
|
// the result carries partial errors. These tests pin both branches via
|
||||||
|
// renderHook with a mocked service (twin precedent:
|
||||||
|
// update-from-catalog-message.test.tsx).
|
||||||
|
|
||||||
|
const notificationsShowMock = vi.fn();
|
||||||
|
vi.mock("@mantine/notifications", () => ({
|
||||||
|
notifications: { show: (opts: unknown) => notificationsShowMock(opts) },
|
||||||
|
}));
|
||||||
|
|
||||||
|
// `t` echoes the key with interpolated values so we assert against the exact
|
||||||
|
// English message strings (mirrors react-i18next's default interpolation).
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string, vars?: Record<string, unknown>) =>
|
||||||
|
vars
|
||||||
|
? key.replace(/\{\{(\w+)\}\}/g, (_m, name) => String(vars[name]))
|
||||||
|
: key,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/ai-chat/services/ai-chat-service.ts", () => ({
|
||||||
|
importAiRolesFromCatalog: vi.fn(),
|
||||||
|
// Other named exports referenced by ai-chat-query.ts must exist on the mock so
|
||||||
|
// the module import resolves; they are unused by these tests.
|
||||||
|
createAiRole: vi.fn(),
|
||||||
|
deleteAiChat: vi.fn(),
|
||||||
|
deleteAiRole: vi.fn(),
|
||||||
|
getAiChatMessages: vi.fn(),
|
||||||
|
getAiChats: vi.fn(),
|
||||||
|
getAiRoleCatalog: vi.fn(),
|
||||||
|
getAiRoleCatalogBundle: vi.fn(),
|
||||||
|
getAiRoles: vi.fn(),
|
||||||
|
renameAiChat: vi.fn(),
|
||||||
|
updateAiRole: vi.fn(),
|
||||||
|
updateAiRoleFromCatalog: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { importAiRolesFromCatalog } from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||||
|
import { useImportAiRolesFromCatalogMutation } from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||||
|
|
||||||
|
function createWrapper() {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||||
|
});
|
||||||
|
return function Wrapper({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runMutation(result: IAiRoleImportResult) {
|
||||||
|
vi.mocked(importAiRolesFromCatalog).mockResolvedValue(result);
|
||||||
|
const { result: hook } = renderHook(
|
||||||
|
() => useImportAiRolesFromCatalogMutation(),
|
||||||
|
{ wrapper: createWrapper() },
|
||||||
|
);
|
||||||
|
hook.current.mutate({
|
||||||
|
bundleId: "general",
|
||||||
|
language: "en",
|
||||||
|
conflict: "rename",
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(hook.current.isSuccess).toBe(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useImportAiRolesFromCatalogMutation — success notifications", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors:[] -> only the summary notification (counts interpolated)", async () => {
|
||||||
|
await runMutation({ created: 3, renamed: 1, skipped: 2, errors: [] });
|
||||||
|
expect(notificationsShowMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(notificationsShowMock).toHaveBeenCalledWith({
|
||||||
|
message: "Imported 3, renamed 1, skipped 2",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors.length > 0 -> summary PLUS the red failure notification", async () => {
|
||||||
|
await runMutation({
|
||||||
|
created: 1,
|
||||||
|
renamed: 0,
|
||||||
|
skipped: 0,
|
||||||
|
errors: [
|
||||||
|
{ slug: "a", message: "name taken" },
|
||||||
|
{ slug: "b", message: "name taken" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(notificationsShowMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(notificationsShowMock).toHaveBeenNthCalledWith(1, {
|
||||||
|
message: "Imported 1, renamed 0, skipped 0",
|
||||||
|
});
|
||||||
|
expect(notificationsShowMock).toHaveBeenNthCalledWith(2, {
|
||||||
|
color: "red",
|
||||||
|
message: "Failed to import 2 role(s)",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import React from "react";
|
||||||
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import type { IAiRoleUpdateFromCatalogResult } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||||
|
|
||||||
|
// `useUpdateAiRoleFromCatalogMutation` maps the server's discriminated result to
|
||||||
|
// a user-facing notification message. These tests pin each of the four branches
|
||||||
|
// (updated / not-in-catalog / language-unavailable / up-to-date) via renderHook
|
||||||
|
// with a mocked service (precedent: share-query.null-normalization.test.tsx).
|
||||||
|
|
||||||
|
const notificationsShowMock = vi.fn();
|
||||||
|
vi.mock("@mantine/notifications", () => ({
|
||||||
|
notifications: { show: (opts: unknown) => notificationsShowMock(opts) },
|
||||||
|
}));
|
||||||
|
|
||||||
|
// `t` echoes the key so we assert against the exact English message strings.
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({ t: (key: string) => key }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/ai-chat/services/ai-chat-service.ts", () => ({
|
||||||
|
updateAiRoleFromCatalog: vi.fn(),
|
||||||
|
// Other named exports referenced by ai-chat-query.ts must exist on the mock so
|
||||||
|
// the module import resolves; they are unused by these tests.
|
||||||
|
createAiRole: vi.fn(),
|
||||||
|
deleteAiChat: vi.fn(),
|
||||||
|
deleteAiRole: vi.fn(),
|
||||||
|
getAiChatMessages: vi.fn(),
|
||||||
|
getAiChats: vi.fn(),
|
||||||
|
getAiRoleCatalog: vi.fn(),
|
||||||
|
getAiRoleCatalogBundle: vi.fn(),
|
||||||
|
getAiRoles: vi.fn(),
|
||||||
|
importAiRolesFromCatalog: vi.fn(),
|
||||||
|
renameAiChat: vi.fn(),
|
||||||
|
updateAiRole: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { updateAiRoleFromCatalog } from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||||
|
import { useUpdateAiRoleFromCatalogMutation } from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||||
|
|
||||||
|
function createWrapper() {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||||
|
});
|
||||||
|
return function Wrapper({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runMutation(result: IAiRoleUpdateFromCatalogResult) {
|
||||||
|
vi.mocked(updateAiRoleFromCatalog).mockResolvedValue(result);
|
||||||
|
const { result: hook } = renderHook(
|
||||||
|
() => useUpdateAiRoleFromCatalogMutation(),
|
||||||
|
{ wrapper: createWrapper() },
|
||||||
|
);
|
||||||
|
hook.current.mutate("role-1");
|
||||||
|
await waitFor(() => expect(hook.current.isSuccess).toBe(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useUpdateAiRoleFromCatalogMutation — reason → message", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updated:true -> 'Updated to the latest version'", async () => {
|
||||||
|
await runMutation({
|
||||||
|
updated: true,
|
||||||
|
fromVersion: 1,
|
||||||
|
toVersion: 2,
|
||||||
|
role: { id: "role-1" } as never,
|
||||||
|
});
|
||||||
|
expect(notificationsShowMock).toHaveBeenCalledWith({
|
||||||
|
message: "Updated to the latest version",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("not-in-catalog -> 'This role is no longer in the catalog'", async () => {
|
||||||
|
await runMutation({ updated: false, reason: "not-in-catalog" });
|
||||||
|
expect(notificationsShowMock).toHaveBeenCalledWith({
|
||||||
|
message: "This role is no longer in the catalog",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("language-unavailable -> 'This language is no longer available in the catalog'", async () => {
|
||||||
|
await runMutation({ updated: false, reason: "language-unavailable" });
|
||||||
|
expect(notificationsShowMock).toHaveBeenCalledWith({
|
||||||
|
message: "This language is no longer available in the catalog",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("up-to-date -> 'Already up to date'", async () => {
|
||||||
|
await runMutation({ updated: false, reason: "up-to-date" });
|
||||||
|
expect(notificationsShowMock).toHaveBeenCalledWith({
|
||||||
|
message: "Already up to date",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,8 +6,13 @@ import {
|
|||||||
IAiChatMessageRow,
|
IAiChatMessageRow,
|
||||||
IAiChatMessagesParams,
|
IAiChatMessagesParams,
|
||||||
IAiRole,
|
IAiRole,
|
||||||
|
IAiRoleCatalog,
|
||||||
|
IAiRoleCatalogBundle,
|
||||||
IAiRoleCreate,
|
IAiRoleCreate,
|
||||||
|
IAiRoleImportPayload,
|
||||||
|
IAiRoleImportResult,
|
||||||
IAiRoleUpdate,
|
IAiRoleUpdate,
|
||||||
|
IAiRoleUpdateFromCatalogResult,
|
||||||
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
} 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;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -57,10 +57,79 @@ export interface IAiRole {
|
|||||||
autoStart: boolean;
|
autoStart: boolean;
|
||||||
// Custom auto-start text; null/empty => the default launch message is sent.
|
// Custom auto-start text; null/empty => the default launch message is sent.
|
||||||
launchMessage: string | null;
|
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;
|
createdAt?: string;
|
||||||
updatedAt?: 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 the server `updateFromCatalog()`). A
|
||||||
|
* discriminated union on `updated`: a no-op carries a typed `reason` the UI maps
|
||||||
|
* to a specific message; a successful update carries the version bump + new role.
|
||||||
|
* Keeping the union (not a widened `reason?: string`) lets the consumer's literal
|
||||||
|
* comparisons be compiler-checked.
|
||||||
|
*/
|
||||||
|
export type IAiRoleUpdateFromCatalogResult =
|
||||||
|
| {
|
||||||
|
updated: false;
|
||||||
|
reason: "not-in-catalog" | "up-to-date" | "language-unavailable";
|
||||||
|
}
|
||||||
|
| { updated: true; fromVersion: number; toVersion: number; role: IAiRole };
|
||||||
|
|
||||||
/** Admin create payload for a role. */
|
/** Admin create payload for a role. */
|
||||||
export interface IAiRoleCreate {
|
export interface IAiRoleCreate {
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { catalogRoleInstallState } from "./catalog-role-install-state.ts";
|
||||||
|
import type { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||||
|
|
||||||
|
// Build a workspace role with a catalog source. Fields irrelevant to the
|
||||||
|
// install-state decision are filled with harmless defaults.
|
||||||
|
function installedRole(
|
||||||
|
source: { slug: string; language: string; version: number },
|
||||||
|
overrides: Partial<IAiRole> = {},
|
||||||
|
): IAiRole {
|
||||||
|
return {
|
||||||
|
id: `role-${source.slug}-${source.language}`,
|
||||||
|
name: source.slug,
|
||||||
|
emoji: null,
|
||||||
|
description: null,
|
||||||
|
enabled: true,
|
||||||
|
autoStart: true,
|
||||||
|
launchMessage: null,
|
||||||
|
source,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const catalogRole = { slug: "writer", version: 3 };
|
||||||
|
|
||||||
|
// Mirrors the role-launch.ts precedent: the modal's role-state computation is a
|
||||||
|
// pure function so the import/installed/update decision is testable directly.
|
||||||
|
describe("catalogRoleInstallState", () => {
|
||||||
|
it("no matching installed role -> import", () => {
|
||||||
|
const result = catalogRoleInstallState(catalogRole, [], "en");
|
||||||
|
expect(result).toEqual({ state: "import" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("same slug + language, installed version > catalog -> installed", () => {
|
||||||
|
const installed = installedRole({
|
||||||
|
slug: "writer",
|
||||||
|
language: "en",
|
||||||
|
version: 5,
|
||||||
|
});
|
||||||
|
const result = catalogRoleInstallState(catalogRole, [installed], "en");
|
||||||
|
expect(result).toEqual({ state: "installed", installed });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("same slug + language, installed version == catalog -> installed", () => {
|
||||||
|
const installed = installedRole({
|
||||||
|
slug: "writer",
|
||||||
|
language: "en",
|
||||||
|
version: 3,
|
||||||
|
});
|
||||||
|
const result = catalogRoleInstallState(catalogRole, [installed], "en");
|
||||||
|
expect(result).toEqual({ state: "installed", installed });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("same slug + language, installed version < catalog -> update (from/to)", () => {
|
||||||
|
const installed = installedRole({
|
||||||
|
slug: "writer",
|
||||||
|
language: "en",
|
||||||
|
version: 1,
|
||||||
|
});
|
||||||
|
const result = catalogRoleInstallState(catalogRole, [installed], "en");
|
||||||
|
expect(result).toEqual({
|
||||||
|
state: "update",
|
||||||
|
installed,
|
||||||
|
fromVersion: 1,
|
||||||
|
toVersion: 3,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("same slug but DIFFERENT language -> import (a separate install)", () => {
|
||||||
|
// 'writer' is installed in 'ru'; browsing the 'en' catalog must offer it as a
|
||||||
|
// fresh import, not treat the ru copy as already installed.
|
||||||
|
const installed = installedRole({
|
||||||
|
slug: "writer",
|
||||||
|
language: "ru",
|
||||||
|
version: 5,
|
||||||
|
});
|
||||||
|
const result = catalogRoleInstallState(catalogRole, [installed], "en");
|
||||||
|
expect(result).toEqual({ state: "import" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches the right language when the same slug is installed in several", () => {
|
||||||
|
const ru = installedRole(
|
||||||
|
{ slug: "writer", language: "ru", version: 5 },
|
||||||
|
{ id: "ru-role" },
|
||||||
|
);
|
||||||
|
const en = installedRole(
|
||||||
|
{ slug: "writer", language: "en", version: 1 },
|
||||||
|
{ id: "en-role" },
|
||||||
|
);
|
||||||
|
const result = catalogRoleInstallState(catalogRole, [ru, en], "en");
|
||||||
|
expect(result).toEqual({
|
||||||
|
state: "update",
|
||||||
|
installed: en,
|
||||||
|
fromVersion: 1,
|
||||||
|
toVersion: 3,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores manually-created roles (no source) sharing the name", () => {
|
||||||
|
const manual = installedRole(
|
||||||
|
{ slug: "writer", language: "en", version: 9 },
|
||||||
|
{ source: null },
|
||||||
|
);
|
||||||
|
const result = catalogRoleInstallState(catalogRole, [manual], "en");
|
||||||
|
expect(result).toEqual({ state: "import" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type {
|
||||||
|
IAiRole,
|
||||||
|
IAiRoleCatalogRole,
|
||||||
|
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The install state of a single catalog role relative to the workspace's
|
||||||
|
* existing roles. Extracted as a pure function so the catalog modal's role-state
|
||||||
|
* computation is unit-testable without mounting the component (mirrors the
|
||||||
|
* `roleLaunchMessage` precedent in role-launch.ts).
|
||||||
|
*
|
||||||
|
* A catalog role is matched to an installed role by BOTH `source.slug` and
|
||||||
|
* `source.language`: the same slug in a different language is a separate install
|
||||||
|
* (so it shows as "import", not "installed"). When matched, the installed source
|
||||||
|
* version decides the state:
|
||||||
|
* - no match -> "import"
|
||||||
|
* - matched & installed version >= catalog version -> "installed"
|
||||||
|
* - matched & installed version < catalog version -> "update" (from -> to)
|
||||||
|
*/
|
||||||
|
export type CatalogRoleInstallState =
|
||||||
|
| { state: "import" }
|
||||||
|
| { state: "installed"; installed: IAiRole }
|
||||||
|
| {
|
||||||
|
state: "update";
|
||||||
|
installed: IAiRole;
|
||||||
|
fromVersion: number;
|
||||||
|
toVersion: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function catalogRoleInstallState(
|
||||||
|
role: Pick<IAiRoleCatalogRole, "slug" | "version">,
|
||||||
|
workspaceRoles: IAiRole[],
|
||||||
|
language: string,
|
||||||
|
): CatalogRoleInstallState {
|
||||||
|
const installed = workspaceRoles.find(
|
||||||
|
(r) => r.source?.slug === role.slug && r.source?.language === language,
|
||||||
|
);
|
||||||
|
if (!installed) return { state: "import" };
|
||||||
|
const fromVersion = installed.source?.version ?? 0;
|
||||||
|
if (fromVersion >= role.version) {
|
||||||
|
return { state: "installed", installed };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
state: "update",
|
||||||
|
installed,
|
||||||
|
fromVersion,
|
||||||
|
toVersion: role.version,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Button, Group, Menu, Text } from "@mantine/core";
|
import { Button, Menu, Stack, Text } from "@mantine/core";
|
||||||
import { IconHourglass, IconPlus } from "@tabler/icons-react";
|
import { IconHourglass, IconPlus } from "@tabler/icons-react";
|
||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
@@ -21,11 +21,15 @@ function CreateNoteButton({
|
|||||||
temporary,
|
temporary,
|
||||||
label,
|
label,
|
||||||
icon,
|
icon,
|
||||||
|
color,
|
||||||
}: {
|
}: {
|
||||||
writableSpaces: ISpace[];
|
writableSpaces: ISpace[];
|
||||||
temporary: boolean;
|
temporary: boolean;
|
||||||
label: string;
|
label: string;
|
||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
|
// Mantine color token; lets the temporary action tint toward the warm
|
||||||
|
// orange/amber used by the clock marker + banner while "New note" stays neutral.
|
||||||
|
color: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -54,7 +58,8 @@ function CreateNoteButton({
|
|||||||
<Button
|
<Button
|
||||||
size="md"
|
size="md"
|
||||||
variant="light"
|
variant="light"
|
||||||
color="gray"
|
color={color}
|
||||||
|
fullWidth
|
||||||
leftSection={icon}
|
leftSection={icon}
|
||||||
loading={isPending}
|
loading={isPending}
|
||||||
onClick={() => createNote(writableSpaces[0])}
|
onClick={() => createNote(writableSpaces[0])}
|
||||||
@@ -71,7 +76,8 @@ function CreateNoteButton({
|
|||||||
<Button
|
<Button
|
||||||
size="md"
|
size="md"
|
||||||
variant="light"
|
variant="light"
|
||||||
color="gray"
|
color={color}
|
||||||
|
fullWidth
|
||||||
leftSection={icon}
|
leftSection={icon}
|
||||||
loading={isPending}
|
loading={isPending}
|
||||||
>
|
>
|
||||||
@@ -109,8 +115,10 @@ function CreateNoteButton({
|
|||||||
// Prominent home-screen actions to create a new note (page). Because the home
|
// Prominent home-screen actions to create a new note (page). Because the home
|
||||||
// screen has no active space, the target space is resolved from the user's
|
// screen has no active space, the target space is resolved from the user's
|
||||||
// writable spaces: created directly when there is one, picked from a dropdown
|
// writable spaces: created directly when there is one, picked from a dropdown
|
||||||
// when there are several. Renders two equal-width buttons: a regular note and a
|
// when there are several. Renders two full-width, vertically stacked buttons: a
|
||||||
// temporary note (which auto-moves to Trash after the workspace lifetime).
|
// neutral regular note and an orange-tinted temporary note (which auto-moves to
|
||||||
|
// Trash after the workspace lifetime). Stacking full-width keeps the longer
|
||||||
|
// "New temporary note" label from clipping on narrow mobile widths.
|
||||||
export default function NewNoteButton() {
|
export default function NewNoteButton() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data } = useGetSpacesQuery({ limit: 100 });
|
const { data } = useGetSpacesQuery({ limit: 100 });
|
||||||
@@ -121,19 +129,21 @@ export default function NewNoteButton() {
|
|||||||
if (writableSpaces.length === 0) return null;
|
if (writableSpaces.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group grow gap="sm">
|
<Stack gap="sm">
|
||||||
<CreateNoteButton
|
<CreateNoteButton
|
||||||
writableSpaces={writableSpaces}
|
writableSpaces={writableSpaces}
|
||||||
temporary={false}
|
temporary={false}
|
||||||
label={t("New note")}
|
label={t("New note")}
|
||||||
icon={<IconPlus size={18} />}
|
icon={<IconPlus size={18} />}
|
||||||
|
color="gray"
|
||||||
/>
|
/>
|
||||||
<CreateNoteButton
|
<CreateNoteButton
|
||||||
writableSpaces={writableSpaces}
|
writableSpaces={writableSpaces}
|
||||||
temporary={true}
|
temporary={true}
|
||||||
label={t("New temporary note")}
|
label={t("New temporary note")}
|
||||||
icon={<IconHourglass size={18} />}
|
icon={<IconHourglass size={18} />}
|
||||||
|
color="orange"
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { getDefaultStore } from "jotai";
|
||||||
|
|
||||||
|
// Mock the app entry so importing the query module doesn't boot the whole app
|
||||||
|
// (it only needs queryClient's cache methods, which we stub here). The spies are
|
||||||
|
// declared via vi.hoisted so they exist before the hoisted vi.mock factory runs.
|
||||||
|
const { setQueryData, getQueryData, invalidateQueries } = vi.hoisted(() => ({
|
||||||
|
setQueryData: vi.fn(),
|
||||||
|
getQueryData: vi.fn(() => undefined as unknown),
|
||||||
|
invalidateQueries: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/main.tsx", () => ({
|
||||||
|
queryClient: { setQueryData, getQueryData, invalidateQueries },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { syncTemporaryExpiresInCache } from "./page-embed-query";
|
||||||
|
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||||
|
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||||
|
|
||||||
|
const mkNode = (id: string, slugId: string): SpaceTreeNode =>
|
||||||
|
({
|
||||||
|
id,
|
||||||
|
slugId,
|
||||||
|
name: id,
|
||||||
|
position: "a0",
|
||||||
|
spaceId: "space-1",
|
||||||
|
parentPageId: null,
|
||||||
|
hasChildren: false,
|
||||||
|
children: [],
|
||||||
|
}) as unknown as SpaceTreeNode;
|
||||||
|
|
||||||
|
describe("syncTemporaryExpiresInCache — treeDataAtom patch", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
getQueryData.mockReturnValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("patches the in-tree node's temporaryExpiresAt (sidebar marker updates without reload)", () => {
|
||||||
|
const store = getDefaultStore();
|
||||||
|
const tree = [mkNode("p1", "slug-1"), mkNode("p2", "slug-2")];
|
||||||
|
store.set(treeDataAtom, tree);
|
||||||
|
|
||||||
|
const deadline = "2026-07-01T00:00:00.000Z";
|
||||||
|
syncTemporaryExpiresInCache({ id: "p1", slugId: "slug-1" }, deadline);
|
||||||
|
|
||||||
|
const next = store.get(treeDataAtom);
|
||||||
|
// A new atom value was written...
|
||||||
|
expect(next).not.toBe(tree);
|
||||||
|
// ...the matching node gained the deadline...
|
||||||
|
expect(next.find((n) => n.id === "p1")?.temporaryExpiresAt).toBe(deadline);
|
||||||
|
// ...and the untouched sibling is unchanged.
|
||||||
|
expect(next.find((n) => n.id === "p2")?.temporaryExpiresAt).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the atom value at the SAME reference when the id is absent from the tree (no write)", () => {
|
||||||
|
const store = getDefaultStore();
|
||||||
|
const tree = [mkNode("p1", "slug-1")];
|
||||||
|
store.set(treeDataAtom, tree);
|
||||||
|
|
||||||
|
syncTemporaryExpiresInCache(
|
||||||
|
{ id: "not-in-tree", slugId: "missing" },
|
||||||
|
"2026-07-01T00:00:00.000Z",
|
||||||
|
);
|
||||||
|
|
||||||
|
// treeModel.update is a no-op (same reference) for an unknown id, so the
|
||||||
|
// guard skips the store write entirely — same reference back.
|
||||||
|
expect(store.get(treeDataAtom)).toBe(tree);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import { getDefaultStore } from "jotai";
|
||||||
import {
|
import {
|
||||||
toggleTemplate,
|
toggleTemplate,
|
||||||
toggleTemporary,
|
toggleTemporary,
|
||||||
@@ -9,6 +10,9 @@ import type {
|
|||||||
ToggleTemporaryResponse,
|
ToggleTemporaryResponse,
|
||||||
} from "@/features/page-embed/types/page-embed.types";
|
} from "@/features/page-embed/types/page-embed.types";
|
||||||
import { queryClient } from "@/main.tsx";
|
import { queryClient } from "@/main.tsx";
|
||||||
|
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||||
|
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||||
|
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* After toggling a note's temporary state, mirror the new deadline into the
|
* After toggling a note's temporary state, mirror the new deadline into the
|
||||||
@@ -30,6 +34,19 @@ export function syncTemporaryExpiresInCache(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Patch the in-memory sidebar tree node so its temporary clock marker
|
||||||
|
// appears/disappears immediately — WITHOUT a reload. The page cache update
|
||||||
|
// above only drives the in-page banner/menu; the sidebar reads
|
||||||
|
// `temporaryExpiresAt` straight off the `treeDataAtom` node. The app uses
|
||||||
|
// jotai's default store (no <Provider>), so `getDefaultStore()` is the same
|
||||||
|
// store the sidebar's hooks read from. `treeModel.update` returns the same
|
||||||
|
// reference (a no-op) when the page isn't in the currently loaded tree.
|
||||||
|
const store = getDefaultStore();
|
||||||
|
const prevTree = store.get(treeDataAtom);
|
||||||
|
const nextTree = treeModel.update(prevTree, page.id, {
|
||||||
|
temporaryExpiresAt,
|
||||||
|
} as Partial<SpaceTreeNode>);
|
||||||
|
if (nextTree !== prevTree) store.set(treeDataAtom, nextTree);
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
predicate: (item) =>
|
predicate: (item) =>
|
||||||
["sidebar-pages"].includes(item.queryKey[0] as string),
|
["sidebar-pages"].includes(item.queryKey[0] as string),
|
||||||
|
|||||||
@@ -176,8 +176,8 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
pageId: page.id,
|
pageId: page.id,
|
||||||
temporary: next,
|
temporary: next,
|
||||||
});
|
});
|
||||||
// Reflect the new deadline in the page cache so the menu label flips and
|
// Reflect the new deadline in the page cache (menu label + banner) AND in
|
||||||
// any banner updates. The sidebar icon refreshes via its own query.
|
// the sidebar tree node so its clock marker updates immediately, no reload.
|
||||||
syncTemporaryExpiresInCache(page, res.temporaryExpiresAt);
|
syncTemporaryExpiresInCache(page, res.temporaryExpiresAt);
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: next
|
message: next
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import {
|
|||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { IPagination, QueryParams } from "@/lib/types.ts";
|
import { IPagination, QueryParams } from "@/lib/types.ts";
|
||||||
import { queryClient } from "@/main.tsx";
|
import { queryClient } from "@/main.tsx";
|
||||||
import { buildTree } from "@/features/page/tree/utils";
|
import { buildTree, pageToTreeNode } from "@/features/page/tree/utils";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { validate as isValidUuid } from "uuid";
|
import { validate as isValidUuid } from "uuid";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -210,18 +210,15 @@ export function useRestorePageMutation() {
|
|||||||
|
|
||||||
// Check if the page already exists in the tree (it shouldn't)
|
// Check if the page already exists in the tree (it shouldn't)
|
||||||
if (!treeModel.find(currentTree, restoredPage.id)) {
|
if (!treeModel.find(currentTree, restoredPage.id)) {
|
||||||
// Create the tree node data with hasChildren from backend
|
// Create the tree node data with hasChildren from backend. Routed
|
||||||
const nodeData: SpaceTreeNode = {
|
// through the canonical mapper so the field copy stays in lockstep with
|
||||||
id: restoredPage.id,
|
// buildTree. The server NULLS `temporaryExpiresAt` on restore (a restored
|
||||||
slugId: restoredPage.slugId,
|
// page is made permanent), so the mapper carries that null through and
|
||||||
|
// the node correctly shows no clock marker.
|
||||||
|
const nodeData: SpaceTreeNode = pageToTreeNode(restoredPage, {
|
||||||
name: restoredPage.title || "Untitled",
|
name: restoredPage.title || "Untitled",
|
||||||
icon: restoredPage.icon,
|
|
||||||
position: restoredPage.position,
|
|
||||||
spaceId: restoredPage.spaceId,
|
|
||||||
parentPageId: restoredPage.parentPageId,
|
|
||||||
hasChildren: restoredPage.hasChildren || false,
|
hasChildren: restoredPage.hasChildren || false,
|
||||||
children: [],
|
});
|
||||||
};
|
|
||||||
|
|
||||||
// Determine the parent and index
|
// Determine the parent and index
|
||||||
const parentId = restoredPage.parentPageId || null;
|
const parentId = restoredPage.parentPageId || null;
|
||||||
@@ -410,6 +407,11 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
|
|||||||
slugId: data.slugId,
|
slugId: data.slugId,
|
||||||
spaceId: data.spaceId,
|
spaceId: data.spaceId,
|
||||||
title: data.title,
|
title: data.title,
|
||||||
|
// Carry the death-timer deadline so a note created as temporary keeps its
|
||||||
|
// sidebar clock marker when the tree is rebuilt from this cached entry
|
||||||
|
// (buildTree → mergeRootTrees). Omitting it overwrote the optimistic/socket
|
||||||
|
// node's marker with `undefined`, hiding it until a reload.
|
||||||
|
temporaryExpiresAt: data.temporaryExpiresAt,
|
||||||
};
|
};
|
||||||
|
|
||||||
let queryKey: QueryKey = null;
|
let queryKey: QueryKey = null;
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
} from "@/features/page-embed/queries/page-embed-query";
|
} from "@/features/page-embed/queries/page-embed-query";
|
||||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||||
import { treeModel } from "@/features/page/tree/model/tree-model";
|
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||||
|
import { pageToTreeNode } from "@/features/page/tree/utils";
|
||||||
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
|
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
|
||||||
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||||
import classes from "@/features/page/tree/styles/tree.module.css";
|
import classes from "@/features/page/tree/styles/tree.module.css";
|
||||||
@@ -130,18 +131,14 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
|||||||
const currentIndex = siblings?.index ?? 0;
|
const currentIndex = siblings?.index ?? 0;
|
||||||
const newIndex = currentIndex + 1;
|
const newIndex = currentIndex + 1;
|
||||||
|
|
||||||
const treeNodeData: SpaceTreeNode = {
|
// Routed through the canonical mapper so the field copy stays in lockstep
|
||||||
id: duplicatedPage.id,
|
// with buildTree. The server does NOT arm a death timer on duplicate (the
|
||||||
slugId: duplicatedPage.slugId,
|
// copy's `temporaryExpiresAt` defaults to null = permanent), so the mapper
|
||||||
name: duplicatedPage.title,
|
// carries that null through and the duplicated node correctly shows no
|
||||||
position: duplicatedPage.position,
|
// clock marker — matching the server without a reload.
|
||||||
spaceId: duplicatedPage.spaceId,
|
const treeNodeData: SpaceTreeNode = pageToTreeNode(duplicatedPage, {
|
||||||
parentPageId: duplicatedPage.parentPageId,
|
|
||||||
icon: duplicatedPage.icon,
|
|
||||||
hasChildren: duplicatedPage.hasChildren,
|
|
||||||
canEdit: true,
|
canEdit: true,
|
||||||
children: [],
|
});
|
||||||
};
|
|
||||||
|
|
||||||
setData((prev) =>
|
setData((prev) =>
|
||||||
treeModel.insert(prev, parentId, treeNodeData, newIndex),
|
treeModel.insert(prev, parentId, treeNodeData, newIndex),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { treeModel } from "@/features/page/tree/model/tree-model";
|
|||||||
import type { DropOp } from "@/features/page/tree/model/tree-model.types";
|
import type { DropOp } from "@/features/page/tree/model/tree-model.types";
|
||||||
import { dropOpToMovePayload } from "./drop-op-to-move-payload";
|
import { dropOpToMovePayload } from "./drop-op-to-move-payload";
|
||||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||||
|
import { pageToTreeNode } from "@/features/page/tree/utils";
|
||||||
import { IPage } from "@/features/page/types/page.types.ts";
|
import { IPage } from "@/features/page/types/page.types.ts";
|
||||||
import {
|
import {
|
||||||
useCreatePageMutation,
|
useCreatePageMutation,
|
||||||
@@ -139,18 +140,15 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
|||||||
throw new Error("Failed to create page");
|
throw new Error("Failed to create page");
|
||||||
}
|
}
|
||||||
|
|
||||||
const newNode: SpaceTreeNode = {
|
// Route through the canonical mapper so the field copy (esp.
|
||||||
id: createdPage.id,
|
// `temporaryExpiresAt`, which shows the temporary-note clock marker on
|
||||||
slugId: createdPage.slugId,
|
// optimistic insert) can't drift from buildTree. `name: ""` because a
|
||||||
|
// freshly created page is untitled; `hasChildren: false` because it has no
|
||||||
|
// children yet.
|
||||||
|
const newNode: SpaceTreeNode = pageToTreeNode(createdPage, {
|
||||||
name: "",
|
name: "",
|
||||||
position: createdPage.position,
|
|
||||||
spaceId: createdPage.spaceId,
|
|
||||||
parentPageId: createdPage.parentPageId,
|
|
||||||
hasChildren: false,
|
hasChildren: false,
|
||||||
// Show the temporary-note icon immediately on optimistic insert.
|
});
|
||||||
temporaryExpiresAt: createdPage.temporaryExpiresAt,
|
|
||||||
children: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Read latest tree at call time. Without this, callers that mutate the
|
// Read latest tree at call time. Without this, callers that mutate the
|
||||||
// tree (e.g. lazy-load children on expand) immediately before calling
|
// tree (e.g. lazy-load children on expand) immediately before calling
|
||||||
@@ -173,7 +171,22 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
|||||||
// optimistic node's id IS the real created page id (createdPage.id), so
|
// optimistic node's id IS the real created page id (createdPage.id), so
|
||||||
// the ids match exactly regardless of which path runs first.
|
// the ids match exactly regardless of which path runs first.
|
||||||
setData((prev) => {
|
setData((prev) => {
|
||||||
if (treeModel.find(prev, newNode.id)) return prev;
|
const existing = treeModel.find(prev, newNode.id);
|
||||||
|
if (existing) {
|
||||||
|
// The server `addTreeNode` broadcast won the race and already inserted
|
||||||
|
// this node. Older broadcasts could omit `temporaryExpiresAt`, leaving
|
||||||
|
// a temporary note WITHOUT its clock marker until reload; patch it on
|
||||||
|
// from the authoritative create response so the marker shows now.
|
||||||
|
if (
|
||||||
|
newNode.temporaryExpiresAt &&
|
||||||
|
!(existing as SpaceTreeNode).temporaryExpiresAt
|
||||||
|
) {
|
||||||
|
return treeModel.update(prev, newNode.id, {
|
||||||
|
temporaryExpiresAt: newNode.temporaryExpiresAt,
|
||||||
|
} as Partial<SpaceTreeNode>);
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
return treeModel.insert(prev, parentId, newNode, lastIndex);
|
return treeModel.insert(prev, parentId, newNode, lastIndex);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -393,6 +393,101 @@ describe("handleCreate optimistic-insert idempotency (find-then-skip)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// handleCreate race-guard temporaryExpiresAt patch: when the server's
|
||||||
|
// addTreeNode broadcast wins the race and inserts the node BEFORE the optimistic
|
||||||
|
// updater runs, the updater must not re-insert. Two sub-branches:
|
||||||
|
// (a) the node the broadcast inserted carries NO deadline (an older broadcast
|
||||||
|
// omitted it) while the authoritative create response DOES → patch the
|
||||||
|
// deadline on so the clock marker shows now, without a reload.
|
||||||
|
// (b) the existing node ALREADY has a deadline → do NOT overwrite it; return
|
||||||
|
// `prev` by reference (a no-op write).
|
||||||
|
describe("handleCreate race-guard temporaryExpiresAt patch", () => {
|
||||||
|
type TN = TreeNode<{ name: string; temporaryExpiresAt?: string | null }>;
|
||||||
|
|
||||||
|
// Mirrors the setData updater in use-tree-mutation handleCreate.
|
||||||
|
const applyOptimisticInsert = (
|
||||||
|
tree: TN[],
|
||||||
|
parentId: string | null,
|
||||||
|
node: TN,
|
||||||
|
index: number,
|
||||||
|
): TN[] => {
|
||||||
|
const existing = treeModel.find(tree, node.id) as TN | null;
|
||||||
|
if (existing) {
|
||||||
|
if (node.temporaryExpiresAt && !existing.temporaryExpiresAt) {
|
||||||
|
return treeModel.update(tree, node.id, {
|
||||||
|
temporaryExpiresAt: node.temporaryExpiresAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return tree;
|
||||||
|
}
|
||||||
|
return treeModel.insert(tree, parentId, node, index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fixtureTN: TN[] = [
|
||||||
|
{ id: "a", name: "A" },
|
||||||
|
{ id: "b", name: "B" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const deadline = "2026-07-01T00:00:00.000Z";
|
||||||
|
|
||||||
|
it("(a) patches temporaryExpiresAt when the existing node has none + the response carries a deadline", () => {
|
||||||
|
// Server broadcast won the race and inserted the node WITHOUT a deadline.
|
||||||
|
const afterServer = treeModel.insert(fixtureTN, null, {
|
||||||
|
id: "new",
|
||||||
|
name: "",
|
||||||
|
});
|
||||||
|
expect((treeModel.find(afterServer, "new") as TN).temporaryExpiresAt).toBe(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The authoritative create response carries the deadline.
|
||||||
|
const created: TN = { id: "new", name: "", temporaryExpiresAt: deadline };
|
||||||
|
const patched = applyOptimisticInsert(
|
||||||
|
afterServer,
|
||||||
|
null,
|
||||||
|
created,
|
||||||
|
afterServer.length,
|
||||||
|
);
|
||||||
|
|
||||||
|
// A new reference (the patch wrote) and the node now has the deadline...
|
||||||
|
expect(patched).not.toBe(afterServer);
|
||||||
|
expect((treeModel.find(patched, "new") as TN).temporaryExpiresAt).toBe(
|
||||||
|
deadline,
|
||||||
|
);
|
||||||
|
// ...and still exactly one node (no duplicate re-insert).
|
||||||
|
expect(patched.filter((n) => n.id === "new")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("(b) does NOT overwrite an existing deadline; returns prev by reference", () => {
|
||||||
|
const existingDeadline = deadline;
|
||||||
|
// The node already exists WITH a deadline (the broadcast carried it).
|
||||||
|
const afterServer = treeModel.insert(fixtureTN, null, {
|
||||||
|
id: "new",
|
||||||
|
name: "",
|
||||||
|
temporaryExpiresAt: existingDeadline,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The create response carries a DIFFERENT deadline; the guard must ignore it.
|
||||||
|
const created: TN = {
|
||||||
|
id: "new",
|
||||||
|
name: "",
|
||||||
|
temporaryExpiresAt: "2099-01-01T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
const after = applyOptimisticInsert(
|
||||||
|
afterServer,
|
||||||
|
null,
|
||||||
|
created,
|
||||||
|
afterServer.length,
|
||||||
|
);
|
||||||
|
|
||||||
|
// prev returned by reference (no write) and the original deadline is kept.
|
||||||
|
expect(after).toBe(afterServer);
|
||||||
|
expect((treeModel.find(after, "new") as TN).temporaryExpiresAt).toBe(
|
||||||
|
existingDeadline,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// moveTreeNode socket-handler semantics: the receiver must place the moved node
|
// moveTreeNode socket-handler semantics: the receiver must place the moved node
|
||||||
// by `position` (NOT index 0) and apply the `pageData` the payload carries so a
|
// by `position` (NOT index 0) and apply the `pageData` the payload carries so a
|
||||||
// moved node's title/icon/chevron stay correct. This mirrors the reducer in
|
// moved node's title/icon/chevron stay correct. This mirrors the reducer in
|
||||||
|
|||||||
@@ -9,13 +9,22 @@ export function sortPositionKeys(keys: any[]) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildTree(pages: IPage[]): SpaceTreeNode[] {
|
/**
|
||||||
const pageMap: Record<string, SpaceTreeNode> = {};
|
* Single canonical `IPage -> SpaceTreeNode` field mapper. Every place that
|
||||||
|
* materialises a tree node from a page (buildTree, the optimistic insert in
|
||||||
const tree: SpaceTreeNode[] = [];
|
* handleCreate, restore, duplicate) routes through here so the field copy —
|
||||||
|
* crucially `temporaryExpiresAt` — can never silently drift between sites. The
|
||||||
pages.forEach((page) => {
|
* `overrides` cover the small per-site differences (e.g. `name: ""` for an
|
||||||
pageMap[page.id] = {
|
* optimistic create, `name: title || "Untitled"` for restore, `canEdit: true`
|
||||||
|
* for duplicate). The default `temporaryExpiresAt` comes straight off the page,
|
||||||
|
* so restore (which the server nulls) stays permanent and a temporary create
|
||||||
|
* keeps its clock marker without a reload.
|
||||||
|
*/
|
||||||
|
export function pageToTreeNode(
|
||||||
|
page: IPage,
|
||||||
|
overrides?: Partial<SpaceTreeNode>,
|
||||||
|
): SpaceTreeNode {
|
||||||
|
return {
|
||||||
id: page.id,
|
id: page.id,
|
||||||
slugId: page.slugId,
|
slugId: page.slugId,
|
||||||
name: page.title,
|
name: page.title,
|
||||||
@@ -28,7 +37,17 @@ export function buildTree(pages: IPage[]): SpaceTreeNode[] {
|
|||||||
isTemplate: page.isTemplate,
|
isTemplate: page.isTemplate,
|
||||||
temporaryExpiresAt: page.temporaryExpiresAt,
|
temporaryExpiresAt: page.temporaryExpiresAt,
|
||||||
children: [],
|
children: [],
|
||||||
|
...overrides,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTree(pages: IPage[]): SpaceTreeNode[] {
|
||||||
|
const pageMap: Record<string, SpaceTreeNode> = {};
|
||||||
|
|
||||||
|
const tree: SpaceTreeNode[] = [];
|
||||||
|
|
||||||
|
pages.forEach((page) => {
|
||||||
|
pageMap[page.id] = pageToTreeNode(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Defense-in-depth: a duplicate id in `pages` would push two references to the
|
// Defense-in-depth: a duplicate id in `pages` would push two references to the
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button, Group } from "@mantine/core";
|
import { Button, Stack } from "@mantine/core";
|
||||||
import { IconHourglass, IconPlus } from "@tabler/icons-react";
|
import { IconHourglass, IconPlus } from "@tabler/icons-react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -45,12 +45,16 @@ export default function SpaceCreateNoteButtons() {
|
|||||||
.finally(() => setPending(null));
|
.finally(() => setPending(null));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Two full-width, vertically stacked buttons: a neutral regular note and an
|
||||||
|
// orange-tinted temporary note. Stacking full-width keeps the longer "New
|
||||||
|
// temporary note" label from clipping on narrow mobile widths.
|
||||||
return (
|
return (
|
||||||
<Group grow gap="sm">
|
<Stack gap="sm">
|
||||||
<Button
|
<Button
|
||||||
size="md"
|
size="md"
|
||||||
variant="light"
|
variant="light"
|
||||||
color="gray"
|
color="gray"
|
||||||
|
fullWidth
|
||||||
leftSection={<IconPlus size={18} />}
|
leftSection={<IconPlus size={18} />}
|
||||||
loading={pending === "regular"}
|
loading={pending === "regular"}
|
||||||
disabled={pending !== null}
|
disabled={pending !== null}
|
||||||
@@ -61,7 +65,8 @@ export default function SpaceCreateNoteButtons() {
|
|||||||
<Button
|
<Button
|
||||||
size="md"
|
size="md"
|
||||||
variant="light"
|
variant="light"
|
||||||
color="gray"
|
color="orange"
|
||||||
|
fullWidth
|
||||||
leftSection={<IconHourglass size={18} />}
|
leftSection={<IconHourglass size={18} />}
|
||||||
loading={pending === "temporary"}
|
loading={pending === "temporary"}
|
||||||
disabled={pending !== null}
|
disabled={pending !== null}
|
||||||
@@ -69,6 +74,6 @@ export default function SpaceCreateNoteButtons() {
|
|||||||
>
|
>
|
||||||
{t("New temporary note")}
|
{t("New temporary note")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -323,4 +323,18 @@ describe("applyAddTreeNode", () => {
|
|||||||
"child",
|
"child",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("carries temporaryExpiresAt onto the inserted node so the clock marker shows on create (no reload)", () => {
|
||||||
|
// A note created as temporary broadcasts addTreeNode with the death-timer
|
||||||
|
// deadline in its payload; the receiver's inserted node must keep it so
|
||||||
|
// space-tree-row renders the orange clock marker immediately.
|
||||||
|
const tree = roots();
|
||||||
|
const expiresAt = "2026-06-27T21:00:00.000Z";
|
||||||
|
const next = applyAddTreeNode(tree, {
|
||||||
|
parentId: null as unknown as string,
|
||||||
|
index: 0,
|
||||||
|
data: node("temp", { position: "a3", temporaryExpiresAt: expiresAt }),
|
||||||
|
});
|
||||||
|
expect(treeModel.find(next, "temp")?.temporaryExpiresAt).toBe(expiresAt);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,407 @@
|
|||||||
|
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";
|
||||||
|
import { catalogRoleInstallState } from "@/features/ai-chat/utils/catalog-role-install-state.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();
|
||||||
|
|
||||||
|
// The user's i18n base subtag (e.g. "ru-RU" => "ru"); the preferred catalog
|
||||||
|
// language both when seeding and when reconciling against offered languages.
|
||||||
|
const baseLang = (i18n.language || "en").split("-")[0].toLowerCase();
|
||||||
|
|
||||||
|
// 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 base subtag 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>(() => baseLang);
|
||||||
|
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 preferred = languages.includes(baseLang)
|
||||||
|
? baseLang
|
||||||
|
: 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 (matched by source.slug + source.language). The decision lives in the
|
||||||
|
// pure `catalogRoleInstallState` helper so it is unit-tested directly.
|
||||||
|
const computed = useMemo(() => {
|
||||||
|
const list = bundleQuery.data?.roles ?? [];
|
||||||
|
return list.map((role) => ({
|
||||||
|
role,
|
||||||
|
...catalogRoleInstallState(role, roles, language),
|
||||||
|
}));
|
||||||
|
}, [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((entry) => (
|
||||||
|
<CatalogRoleRow
|
||||||
|
key={entry.role.slug}
|
||||||
|
role={entry.role}
|
||||||
|
state={entry.state}
|
||||||
|
checked={
|
||||||
|
entry.state === "import"
|
||||||
|
? !!selected?.has(entry.role.slug)
|
||||||
|
: false
|
||||||
|
}
|
||||||
|
onToggle={(checked) => onToggleSlug(entry.role.slug, checked)}
|
||||||
|
fromVersion={
|
||||||
|
entry.state === "update" ? entry.fromVersion : undefined
|
||||||
|
}
|
||||||
|
onUpdate={
|
||||||
|
entry.state === "update"
|
||||||
|
? () => updateMutation.mutate(entry.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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,7 +13,12 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import { modals } from "@mantine/modals";
|
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 { useTranslation } from "react-i18next";
|
||||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||||
import {
|
import {
|
||||||
@@ -23,6 +28,7 @@ import {
|
|||||||
} from "@/features/ai-chat/queries/ai-chat-query.ts";
|
} from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||||
import { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
|
import { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||||
import AiAgentRoleForm from "./ai-agent-role-form.tsx";
|
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
|
* Admin section: list / add / edit / delete reusable agent roles. A role
|
||||||
@@ -39,6 +45,9 @@ export default function AiAgentRoles() {
|
|||||||
const deleteMutation = useDeleteAiRoleMutation();
|
const deleteMutation = useDeleteAiRoleMutation();
|
||||||
|
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
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.
|
// The role being edited; undefined => the modal is in "create" mode.
|
||||||
const [editing, setEditing] = useState<IAiRole | undefined>(undefined);
|
const [editing, setEditing] = useState<IAiRole | undefined>(undefined);
|
||||||
|
|
||||||
@@ -86,6 +95,15 @@ export default function AiAgentRoles() {
|
|||||||
/>
|
/>
|
||||||
<Text fw={600}>{t("Agent roles")}</Text>
|
<Text fw={600}>{t("Agent roles")}</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
<Group gap="xs" wrap="nowrap">
|
||||||
|
<Button
|
||||||
|
leftSection={<IconPackageImport size={16} />}
|
||||||
|
variant="default"
|
||||||
|
size="xs"
|
||||||
|
onClick={openCatalog}
|
||||||
|
>
|
||||||
|
{t("Import from catalog")}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
leftSection={<IconPlus size={16} />}
|
leftSection={<IconPlus size={16} />}
|
||||||
variant="default"
|
variant="default"
|
||||||
@@ -95,6 +113,7 @@ export default function AiAgentRoles() {
|
|||||||
{t("Add role")}
|
{t("Add role")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
</Group>
|
||||||
<Text size="xs" c="dimmed" mt={4}>
|
<Text size="xs" c="dimmed" mt={4}>
|
||||||
{t(
|
{t(
|
||||||
"Reusable presets that shape the agent's behavior (and optionally its model). Picked when starting a new chat.",
|
"Reusable presets that shape the agent's behavior (and optionally its model). Picked when starting a new chat.",
|
||||||
@@ -102,9 +121,19 @@ export default function AiAgentRoles() {
|
|||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{!isLoading && (!roles || roles.length === 0) && (
|
{!isLoading && (!roles || roles.length === 0) && (
|
||||||
<Text size="sm" c="dimmed" mt="sm">
|
<Group gap="sm" mt="sm" align="center">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
{t("No roles configured")}
|
{t("No roles configured")}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Button
|
||||||
|
leftSection={<IconPackageImport size={16} />}
|
||||||
|
variant="light"
|
||||||
|
size="xs"
|
||||||
|
onClick={openCatalog}
|
||||||
|
>
|
||||||
|
{t("Browse the catalog")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Stack gap="xs" mt="sm">
|
<Stack gap="xs" mt="sm">
|
||||||
@@ -170,6 +199,12 @@ export default function AiAgentRoles() {
|
|||||||
{/* Remount the form per target so its internal state re-hydrates. */}
|
{/* Remount the form per target so its internal state re-hydrates. */}
|
||||||
<AiAgentRoleForm key={editing?.id ?? "new"} role={editing} onClose={close} />
|
<AiAgentRoleForm key={editing?.id ?? "new"} role={editing} onClose={close} />
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<AiAgentRolesCatalogModal
|
||||||
|
opened={catalogOpened}
|
||||||
|
onClose={closeCatalog}
|
||||||
|
roles={roles ?? []}
|
||||||
|
/>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ describe('AiAgentRolesController admin gate', () => {
|
|||||||
create: jest.fn().mockResolvedValue({ id: 'r1' }),
|
create: jest.fn().mockResolvedValue({ id: 'r1' }),
|
||||||
update: jest.fn().mockResolvedValue({ id: 'r1' }),
|
update: jest.fn().mockResolvedValue({ id: 'r1' }),
|
||||||
remove: jest.fn().mockResolvedValue({ success: true }),
|
remove: jest.fn().mockResolvedValue({ success: true }),
|
||||||
|
getCatalog: jest.fn().mockResolvedValue({ languages: [], bundles: [] }),
|
||||||
|
getCatalogBundle: jest.fn().mockResolvedValue({ roles: [] }),
|
||||||
|
importFromCatalog: jest.fn().mockResolvedValue({ created: 0 }),
|
||||||
|
updateFromCatalog: jest.fn().mockResolvedValue({ updated: false }),
|
||||||
};
|
};
|
||||||
const controller = new AiAgentRolesController(
|
const controller = new AiAgentRolesController(
|
||||||
rolesService as never,
|
rolesService as never,
|
||||||
@@ -109,6 +113,90 @@ describe('AiAgentRolesController admin gate', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Catalog routes (browse + import) are ALL admin-only: a non-admin caller must
|
||||||
|
// get ForbiddenException with the service untouched; an admin delegates with
|
||||||
|
// the right arguments (import/update-from-catalog carry workspace.id).
|
||||||
|
describe('catalog routes admin gate', () => {
|
||||||
|
const catalogDto = { language: 'en' } as never;
|
||||||
|
const bundleDto = { bundleId: 'general', language: 'en' } as never;
|
||||||
|
const importDto = {
|
||||||
|
bundleId: 'general',
|
||||||
|
language: 'en',
|
||||||
|
conflict: 'skip',
|
||||||
|
} as never;
|
||||||
|
const updateDto = { id: 'r1' } as never;
|
||||||
|
|
||||||
|
describe('non-admin is rejected and the service is NOT called', () => {
|
||||||
|
it('catalog', async () => {
|
||||||
|
const { controller, rolesService } = makeController(false);
|
||||||
|
await expect(
|
||||||
|
controller.catalog(catalogDto, user, workspace),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
expect(rolesService.getCatalog).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('catalog/bundle', async () => {
|
||||||
|
const { controller, rolesService } = makeController(false);
|
||||||
|
await expect(
|
||||||
|
controller.catalogBundle(bundleDto, user, workspace),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
expect(rolesService.getCatalogBundle).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('import', async () => {
|
||||||
|
const { controller, rolesService } = makeController(false);
|
||||||
|
await expect(
|
||||||
|
controller.import(importDto, user, workspace),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
expect(rolesService.importFromCatalog).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update-from-catalog', async () => {
|
||||||
|
const { controller, rolesService } = makeController(false);
|
||||||
|
await expect(
|
||||||
|
controller.updateFromCatalog(updateDto, user, workspace),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
expect(rolesService.updateFromCatalog).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('admin delegates to the service', () => {
|
||||||
|
it('catalog passes the requested language', async () => {
|
||||||
|
const { controller, rolesService } = makeController(true);
|
||||||
|
await controller.catalog(catalogDto, user, workspace);
|
||||||
|
expect(rolesService.getCatalog).toHaveBeenCalledWith('en');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('catalog/bundle passes bundleId + language', async () => {
|
||||||
|
const { controller, rolesService } = makeController(true);
|
||||||
|
await controller.catalogBundle(bundleDto, user, workspace);
|
||||||
|
expect(rolesService.getCatalogBundle).toHaveBeenCalledWith(
|
||||||
|
'general',
|
||||||
|
'en',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('import passes workspace.id + user.id + dto', async () => {
|
||||||
|
const { controller, rolesService } = makeController(true);
|
||||||
|
await controller.import(importDto, user, workspace);
|
||||||
|
expect(rolesService.importFromCatalog).toHaveBeenCalledWith(
|
||||||
|
'ws-1',
|
||||||
|
'u1',
|
||||||
|
importDto,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update-from-catalog passes workspace.id + dto', async () => {
|
||||||
|
const { controller, rolesService } = makeController(true);
|
||||||
|
await controller.updateFromCatalog(updateDto, user, workspace);
|
||||||
|
expect(rolesService.updateFromCatalog).toHaveBeenCalledWith(
|
||||||
|
'ws-1',
|
||||||
|
updateDto,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('list (member-reachable)', () => {
|
describe('list (member-reachable)', () => {
|
||||||
it('non-admin reaches list and the service is asked for the picker view (isAdmin=false)', async () => {
|
it('non-admin reaches list and the service is asked for the picker view (isAdmin=false)', async () => {
|
||||||
const { controller, rolesService } = makeController(false);
|
const { controller, rolesService } = makeController(false);
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ import {
|
|||||||
CreateAgentRoleDto,
|
CreateAgentRoleDto,
|
||||||
UpdateAgentRoleDto,
|
UpdateAgentRoleDto,
|
||||||
} from './dto/agent-role.dto';
|
} 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). */
|
/** Path/body param for the per-role routes (update/delete). */
|
||||||
class AgentRoleIdDto {
|
class AgentRoleIdDto {
|
||||||
@@ -113,4 +119,54 @@ export class AiAgentRolesController {
|
|||||||
this.assertAdmin(user, workspace);
|
this.assertAdmin(user, workspace);
|
||||||
return this.rolesService.remove(workspace.id, idDto.id);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AiAgentRolesController } from './ai-agent-roles.controller';
|
import { AiAgentRolesController } from './ai-agent-roles.controller';
|
||||||
import { AiAgentRolesService } from './ai-agent-roles.service';
|
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
|
* Agent roles unit (v1). Admin CRUD + member-visible listing for the chat
|
||||||
* role picker. AiAgentRoleRepo (DatabaseModule, global) and
|
* role picker, plus the admin catalog (browse/import/update). AiAgentRoleRepo
|
||||||
* WorkspaceAbilityFactory (CaslModule, global) are resolved without explicit
|
* (DatabaseModule, global), WorkspaceAbilityFactory (CaslModule, global) and
|
||||||
* imports. The stream-time role resolution + model override live in
|
* EnvironmentService (EnvironmentModule, global — used by the catalog provider)
|
||||||
* AiChatService / AiService; this module only hosts the management API.
|
* are resolved without explicit imports. The stream-time role resolution +
|
||||||
|
* model override live in AiChatService / AiService; this module only hosts the
|
||||||
|
* management API.
|
||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [AiAgentRolesController],
|
controllers: [AiAgentRolesController],
|
||||||
providers: [AiAgentRolesService],
|
providers: [AiAgentRolesService, AiAgentRolesCatalogProvider],
|
||||||
})
|
})
|
||||||
export class AiAgentRolesModule {}
|
export class AiAgentRolesModule {}
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { BadRequestException, ConflictException } from '@nestjs/common';
|
import {
|
||||||
|
BadGatewayException,
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Logger,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { AiAgentRolesService } from './ai-agent-roles.service';
|
import { AiAgentRolesService } from './ai-agent-roles.service';
|
||||||
import type { AiAgentRole } from '@docmost/db/types/entity.types';
|
import type { AiAgentRole } from '@docmost/db/types/entity.types';
|
||||||
import type {
|
import type {
|
||||||
@@ -27,12 +32,22 @@ describe('AiAgentRolesService guards', () => {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
autoStart: true,
|
autoStart: true,
|
||||||
launchMessage: null,
|
launchMessage: null,
|
||||||
|
source: null,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
...over,
|
...over,
|
||||||
} as AiAgentRole;
|
} 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 } = {}) {
|
function makeService(opts: { existing?: AiAgentRole | undefined } = {}) {
|
||||||
const repo = {
|
const repo = {
|
||||||
findById: jest.fn().mockResolvedValue(opts.existing),
|
findById: jest.fn().mockResolvedValue(opts.existing),
|
||||||
@@ -41,8 +56,9 @@ describe('AiAgentRolesService guards', () => {
|
|||||||
softDelete: jest.fn().mockResolvedValue(undefined),
|
softDelete: jest.fn().mockResolvedValue(undefined),
|
||||||
listByWorkspace: jest.fn().mockResolvedValue([]),
|
listByWorkspace: jest.fn().mockResolvedValue([]),
|
||||||
};
|
};
|
||||||
const service = new AiAgentRolesService(repo as never);
|
const catalog = makeCatalog();
|
||||||
return { service, repo };
|
const service = new AiAgentRolesService(repo as never, catalog as never);
|
||||||
|
return { service, repo, catalog };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('update', () => {
|
describe('update', () => {
|
||||||
@@ -163,6 +179,7 @@ describe('AiAgentRolesService guards', () => {
|
|||||||
enabled: false,
|
enabled: false,
|
||||||
autoStart: true,
|
autoStart: true,
|
||||||
launchMessage: null,
|
launchMessage: null,
|
||||||
|
source: null,
|
||||||
createdAt,
|
createdAt,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
});
|
});
|
||||||
@@ -397,7 +414,7 @@ describe('AiAgentRolesService guards', () => {
|
|||||||
softDelete: jest.fn(),
|
softDelete: jest.fn(),
|
||||||
listByWorkspace: jest.fn().mockResolvedValue(rows),
|
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 };
|
return { service, repo };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,4 +478,630 @@ describe('AiAgentRolesService guards', () => {
|
|||||||
).rejects.toBeInstanceOf(ConflictException);
|
).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' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('source-uniqueness 23505 (concurrent import of same slug+language) => skipped, NOT an error, batch continues', async () => {
|
||||||
|
// Two parallel imports of the same bundle each build installedKeys from a
|
||||||
|
// stale snapshot, so both reach the insert for slug 'a'. The DB partial
|
||||||
|
// unique index on (workspace, source->>slug, source->>language) rejects the
|
||||||
|
// loser with a 23505 carrying the source-index constraint name. That must
|
||||||
|
// be treated as "already installed" (skip), not a per-role error, and the
|
||||||
|
// rest of the batch (slug 'b') must still import.
|
||||||
|
const { service, repo } = makeImportService({
|
||||||
|
bundleRoles: [
|
||||||
|
catalogRole({ slug: 'a', name: 'A' }),
|
||||||
|
catalogRole({ slug: 'b', name: 'B' }),
|
||||||
|
],
|
||||||
|
indexRoles: [
|
||||||
|
{ slug: 'a', version: 1 },
|
||||||
|
{ slug: 'b', version: 1 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// The kysely-postgres-js driver surfaces the violated constraint on
|
||||||
|
// `constraint_name` (not node-postgres' `.constraint`), matching prod.
|
||||||
|
const sourceRace = Object.assign(new Error('duplicate key'), {
|
||||||
|
code: '23505',
|
||||||
|
constraint_name: 'ai_agent_roles_workspace_source_unique',
|
||||||
|
});
|
||||||
|
repo.insert
|
||||||
|
.mockRejectedValueOnce(sourceRace)
|
||||||
|
.mockImplementationOnce((v) => Promise.resolve(makeRow(v)));
|
||||||
|
const res = await service.importFromCatalog('ws-1', 'u1', dto());
|
||||||
|
// 'a' converged on the concurrent install (skip); 'b' imported; no errors.
|
||||||
|
expect(res).toMatchObject({ created: 1, skipped: 1, renamed: 0 });
|
||||||
|
expect(res.errors).toEqual([]);
|
||||||
|
// Both inserts were attempted (the batch did not abort on the 23505).
|
||||||
|
expect(repo.insert).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('non-unique insert error => generic message, root cause logged, import continues', async () => {
|
||||||
|
const logSpy = jest
|
||||||
|
.spyOn(Logger.prototype, 'error')
|
||||||
|
.mockImplementation(() => undefined);
|
||||||
|
try {
|
||||||
|
const { service, repo } = makeImportService({
|
||||||
|
bundleRoles: [
|
||||||
|
catalogRole({ slug: 'a', name: 'A' }),
|
||||||
|
catalogRole({ slug: 'b', name: 'B' }),
|
||||||
|
],
|
||||||
|
indexRoles: [
|
||||||
|
{ slug: 'a', version: 1 },
|
||||||
|
{ slug: 'b', version: 1 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// A non-23505 failure (e.g. a not-null violation) on the first insert.
|
||||||
|
const boom = Object.assign(new Error('null value in column'), {
|
||||||
|
code: '23502',
|
||||||
|
});
|
||||||
|
repo.insert
|
||||||
|
.mockRejectedValueOnce(boom)
|
||||||
|
.mockImplementationOnce((v) => Promise.resolve(makeRow(v)));
|
||||||
|
const res = await service.importFromCatalog('ws-1', 'u1', dto());
|
||||||
|
// The generic (non-409) user-facing message; the second role still imports.
|
||||||
|
expect(res.created).toBe(1);
|
||||||
|
expect(res.errors).toEqual([
|
||||||
|
{ slug: 'a', message: 'Failed to import role' },
|
||||||
|
]);
|
||||||
|
// The root cause was logged with the slug for diagnosis.
|
||||||
|
expect(logSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(String(logSpy.mock.calls[0][0])).toContain('slug=a');
|
||||||
|
} finally {
|
||||||
|
logSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bundleId absent from the index => BadGateway (no insert)', async () => {
|
||||||
|
// The requested bundle is not listed in the fetched index (a stale client
|
||||||
|
// or an index/bundle drift); the import must surface a 502 rather than
|
||||||
|
// silently doing nothing or dereferencing a missing meta.
|
||||||
|
const { service, repo } = makeImportService({});
|
||||||
|
await expect(
|
||||||
|
service.importFromCatalog('ws-1', 'u1', dto({ bundleId: 'missing' })),
|
||||||
|
).rejects.toBeInstanceOf(BadGatewayException);
|
||||||
|
expect(repo.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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('slug listed in the index but missing from the bundle file => not-in-catalog', async () => {
|
||||||
|
// Index/bundle drift: the index still advertises a newer `researcher`
|
||||||
|
// (v5 > installed v1) in an offered language, but the fetched bundle file
|
||||||
|
// no longer contains that slug. The update must no-op as not-in-catalog,
|
||||||
|
// not throw or write a half-resolved role.
|
||||||
|
const { service, repo } = makeUpdateService({
|
||||||
|
role: imported(1),
|
||||||
|
bundleRoles: [
|
||||||
|
{ slug: 'someone-else', name: 'Other', instructions: 'x' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
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('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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Catalog browse (getCatalog / getCatalogBundle) against a MOCKED provider.
|
||||||
|
// Covers the localized() three-tier fallback (requested lang -> en -> first ->
|
||||||
|
// null), the sorted union of bundle languages, the missing-bundle BadGateway,
|
||||||
|
// and the role-version default.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('getCatalog', () => {
|
||||||
|
function makeBrowseService(index: unknown) {
|
||||||
|
const repo = {
|
||||||
|
findById: jest.fn(),
|
||||||
|
insert: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
softDelete: jest.fn(),
|
||||||
|
listByWorkspace: jest.fn(),
|
||||||
|
};
|
||||||
|
const catalog = {
|
||||||
|
fetchIndex: jest.fn().mockResolvedValue(index),
|
||||||
|
fetchBundle: jest.fn(),
|
||||||
|
};
|
||||||
|
const service = new AiAgentRolesService(repo as never, catalog as never);
|
||||||
|
return { service, catalog };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns the sorted union of every bundle language', async () => {
|
||||||
|
const { service } = makeBrowseService({
|
||||||
|
schemaVersion: 1,
|
||||||
|
bundles: [
|
||||||
|
{
|
||||||
|
id: 'a',
|
||||||
|
name: { en: 'A' },
|
||||||
|
languages: ['ru', 'en'],
|
||||||
|
roles: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'b',
|
||||||
|
name: { en: 'B' },
|
||||||
|
languages: ['en', 'de'],
|
||||||
|
roles: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const res = await service.getCatalog('en');
|
||||||
|
expect(res.languages).toEqual(['de', 'en', 'ru']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('localized name uses the requested language when present', async () => {
|
||||||
|
const { service } = makeBrowseService({
|
||||||
|
schemaVersion: 1,
|
||||||
|
bundles: [
|
||||||
|
{
|
||||||
|
id: 'a',
|
||||||
|
name: { en: 'General', ru: 'Общие' },
|
||||||
|
description: { en: 'desc-en', ru: 'desc-ru' },
|
||||||
|
languages: ['en', 'ru'],
|
||||||
|
roles: [{ slug: 'researcher', version: 2 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const res = await service.getCatalog('ru');
|
||||||
|
expect(res.bundles[0]).toMatchObject({
|
||||||
|
id: 'a',
|
||||||
|
name: 'Общие',
|
||||||
|
description: 'desc-ru',
|
||||||
|
languages: ['en', 'ru'],
|
||||||
|
roles: [{ slug: 'researcher', version: 2 }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('localized name falls back to en when the requested language is missing', async () => {
|
||||||
|
const { service } = makeBrowseService({
|
||||||
|
schemaVersion: 1,
|
||||||
|
bundles: [
|
||||||
|
{
|
||||||
|
id: 'a',
|
||||||
|
name: { en: 'General', ru: 'Общие' },
|
||||||
|
languages: ['en', 'ru'],
|
||||||
|
roles: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const res = await service.getCatalog('fr');
|
||||||
|
expect(res.bundles[0].name).toBe('General');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('localized name falls back to the first available locale when en is absent', async () => {
|
||||||
|
const { service } = makeBrowseService({
|
||||||
|
schemaVersion: 1,
|
||||||
|
bundles: [
|
||||||
|
{
|
||||||
|
id: 'a',
|
||||||
|
name: { ru: 'Общие', de: 'Allgemein' },
|
||||||
|
languages: ['ru', 'de'],
|
||||||
|
roles: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const res = await service.getCatalog('fr');
|
||||||
|
// Neither 'fr' nor 'en' is present -> first available value.
|
||||||
|
expect(res.bundles[0].name).toBe('Общие');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('empty name map => falls back to the bundle id; absent description => null', async () => {
|
||||||
|
const { service } = makeBrowseService({
|
||||||
|
schemaVersion: 1,
|
||||||
|
bundles: [
|
||||||
|
{
|
||||||
|
id: 'a',
|
||||||
|
name: {},
|
||||||
|
languages: ['en'],
|
||||||
|
roles: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const res = await service.getCatalog('en');
|
||||||
|
expect(res.bundles[0].name).toBe('a');
|
||||||
|
expect(res.bundles[0].description).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getCatalogBundle', () => {
|
||||||
|
function makeBundleService(opts: {
|
||||||
|
index: unknown;
|
||||||
|
bundle: unknown;
|
||||||
|
}) {
|
||||||
|
const repo = {
|
||||||
|
findById: jest.fn(),
|
||||||
|
insert: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
softDelete: jest.fn(),
|
||||||
|
listByWorkspace: jest.fn(),
|
||||||
|
};
|
||||||
|
const catalog = {
|
||||||
|
fetchIndex: jest.fn().mockResolvedValue(opts.index),
|
||||||
|
fetchBundle: jest.fn().mockResolvedValue(opts.bundle),
|
||||||
|
};
|
||||||
|
const service = new AiAgentRolesService(repo as never, catalog as never);
|
||||||
|
return { service, catalog };
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
bundles: [
|
||||||
|
{
|
||||||
|
id: 'general',
|
||||||
|
name: { en: 'General' },
|
||||||
|
languages: ['en'],
|
||||||
|
roles: [{ slug: 'researcher', version: 4 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
it('missing bundle in the index => BadGateway', async () => {
|
||||||
|
const { service, catalog } = makeBundleService({
|
||||||
|
index,
|
||||||
|
bundle: { schemaVersion: 1, language: 'en', roles: [] },
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
service.getCatalogBundle('ghost', 'en'),
|
||||||
|
).rejects.toBeInstanceOf(BadGatewayException);
|
||||||
|
expect(catalog.fetchBundle).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps role content with the version taken from the index', async () => {
|
||||||
|
const { service } = makeBundleService({
|
||||||
|
index,
|
||||||
|
bundle: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
language: 'en',
|
||||||
|
roles: [
|
||||||
|
{
|
||||||
|
slug: 'researcher',
|
||||||
|
name: 'Researcher',
|
||||||
|
instructions: 'be a researcher',
|
||||||
|
emoji: '🔬',
|
||||||
|
autoStart: false,
|
||||||
|
launchMessage: 'go',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const res = await service.getCatalogBundle('general', 'en');
|
||||||
|
expect(res).toMatchObject({ bundleId: 'general', language: 'en' });
|
||||||
|
expect(res.roles[0]).toEqual({
|
||||||
|
slug: 'researcher',
|
||||||
|
emoji: '🔬',
|
||||||
|
name: 'Researcher',
|
||||||
|
description: null,
|
||||||
|
instructions: 'be a researcher',
|
||||||
|
autoStart: false,
|
||||||
|
launchMessage: 'go',
|
||||||
|
version: 4,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('role absent from the index meta => version defaults to 1; autoStart defaults to true', async () => {
|
||||||
|
const { service } = makeBundleService({
|
||||||
|
index,
|
||||||
|
bundle: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
language: 'en',
|
||||||
|
roles: [
|
||||||
|
{ slug: 'newcomer', name: 'Newcomer', instructions: 'hi' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const res = await service.getCatalogBundle('general', 'en');
|
||||||
|
expect(res.roles[0]).toMatchObject({
|
||||||
|
slug: 'newcomer',
|
||||||
|
version: 1,
|
||||||
|
autoStart: true,
|
||||||
|
emoji: null,
|
||||||
|
launchMessage: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,24 @@
|
|||||||
import {
|
import {
|
||||||
|
BadGatewayException,
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
Injectable,
|
Injectable,
|
||||||
|
Logger,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { AiAgentRoleRepo } from '@docmost/db/repos/ai-agent-roles/ai-agent-roles.repo';
|
import {
|
||||||
import { AiAgentRole } from '@docmost/db/types/entity.types';
|
AiAgentRoleRepo,
|
||||||
|
parseSource,
|
||||||
|
} from '@docmost/db/repos/ai-agent-roles/ai-agent-roles.repo';
|
||||||
|
import { AiAgentRole, RoleSource } from '@docmost/db/types/entity.types';
|
||||||
import { CreateAgentRoleDto, UpdateAgentRoleDto } from './dto/agent-role.dto';
|
import { CreateAgentRoleDto, UpdateAgentRoleDto } from './dto/agent-role.dto';
|
||||||
|
import { ImportFromCatalogDto, UpdateFromCatalogDto } from './dto/agent-role-catalog.dto';
|
||||||
import { RoleModelConfig } from './role-model-config';
|
import { RoleModelConfig } from './role-model-config';
|
||||||
|
import { AiAgentRolesCatalogProvider } from './catalog/ai-agent-roles-catalog.provider';
|
||||||
|
import {
|
||||||
|
CatalogBundleFile,
|
||||||
|
CatalogBundleMeta,
|
||||||
|
CatalogRole,
|
||||||
|
} from './catalog/catalog-types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full (admin) view of an agent role. There are no secret columns on this table
|
* Full (admin) view of an agent role. There are no secret columns on this table
|
||||||
@@ -24,6 +36,10 @@ export interface AgentRoleView {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
autoStart: boolean;
|
autoStart: boolean;
|
||||||
launchMessage: string | null;
|
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: RoleSource | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
@@ -56,7 +72,12 @@ export interface AgentRolePickerView {
|
|||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AiAgentRolesService {
|
export class AiAgentRolesService {
|
||||||
constructor(private readonly repo: AiAgentRoleRepo) {}
|
private readonly logger = new Logger(AiAgentRolesService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly repo: AiAgentRoleRepo,
|
||||||
|
private readonly catalog: AiAgentRolesCatalogProvider,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List the workspace's roles. Admins get the full view (the settings page needs
|
* List the workspace's roles. Admins get the full view (the settings page needs
|
||||||
@@ -165,6 +186,316 @@ export class AiAgentRolesService {
|
|||||||
return { success: true };
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared read prefix for the two bundle-by-id catalog paths (getCatalogBundle /
|
||||||
|
* importFromCatalog): fetch the index, resolve the requested bundle's meta
|
||||||
|
* (502 if the index does not list it), fetch its per-language file, and build
|
||||||
|
* the slug->version map from the meta. The callers keep their own response /
|
||||||
|
* write logic; only this duplicated read is factored out here.
|
||||||
|
*/
|
||||||
|
private async loadBundleById(
|
||||||
|
bundleId: string,
|
||||||
|
language: string,
|
||||||
|
): Promise<{
|
||||||
|
meta: CatalogBundleMeta;
|
||||||
|
file: CatalogBundleFile;
|
||||||
|
versions: Map<string, 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);
|
||||||
|
return { meta, file, versions: versionMap(meta) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 { file, versions } = await this.loadBundleById(bundleId, language);
|
||||||
|
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. A role is "already installed"
|
||||||
|
* (and thus skipped — updates are a separate action) only when an existing
|
||||||
|
* role matches BOTH its `source.slug` AND `source.language`: this is a
|
||||||
|
* multilingual catalog, so a different language of the same slug (e.g. the
|
||||||
|
* `ru` variant of a slug already installed as `en`) is a SEPARATE install and
|
||||||
|
* still imports. 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 { file, versions } = await this.loadBundleById(
|
||||||
|
dto.bundleId,
|
||||||
|
dto.language,
|
||||||
|
);
|
||||||
|
|
||||||
|
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) => parseSource(r.source))
|
||||||
|
.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,
|
||||||
|
...catalogRoleContentFields(role),
|
||||||
|
enabled: true,
|
||||||
|
source: { slug: role.slug, language: dto.language, version },
|
||||||
|
});
|
||||||
|
created++;
|
||||||
|
if (didRename) renamed++;
|
||||||
|
takenNames.add(name.toLowerCase());
|
||||||
|
installedKeys.add(installKey);
|
||||||
|
} catch (err) {
|
||||||
|
// A 23505 from the source-uniqueness index means a CONCURRENT import
|
||||||
|
// already installed this exact slug+language between our snapshot
|
||||||
|
// (installedKeys) and this insert: the in-process snapshot cannot see a
|
||||||
|
// sibling request's writes, so the partial unique index is the backstop.
|
||||||
|
// Outcome is identical to the snapshot-based skip above — count it as
|
||||||
|
// skipped (already installed) and continue; do NOT abort or error.
|
||||||
|
if (isSourceUniqueViolation(err)) {
|
||||||
|
skipped++;
|
||||||
|
installedKeys.add(installKey);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Otherwise: a unique-NAME race (23505 on the name index) is expected and
|
||||||
|
// self-explanatory (it becomes a friendly per-role error). Any OTHER
|
||||||
|
// insert failure is unexpected, so log the root cause with enough context
|
||||||
|
// to diagnose it — the user-facing message is deliberately generic.
|
||||||
|
if (!isUniqueViolation(err)) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to import catalog role (workspaceId=${workspaceId} bundleId=${dto.bundleId} slug=${role.slug}): ${err instanceof Error ? err.stack ?? err.message : String(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 = parseSource(role.source);
|
||||||
|
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,
|
||||||
|
...catalogRoleContentFields(fresh),
|
||||||
|
// 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 {
|
private toView(row: AiAgentRole): AgentRoleView {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -176,6 +507,9 @@ export class AiAgentRolesService {
|
|||||||
enabled: row.enabled,
|
enabled: row.enabled,
|
||||||
autoStart: row.autoStart,
|
autoStart: row.autoStart,
|
||||||
launchMessage: row.launchMessage ?? null,
|
launchMessage: row.launchMessage ?? null,
|
||||||
|
// parseSource yields a fully-valid RoleSource | null (the row is already
|
||||||
|
// normalized; this also keeps the field type honest without a cast).
|
||||||
|
source: parseSource(row.source),
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
updatedAt: row.updatedAt,
|
updatedAt: row.updatedAt,
|
||||||
};
|
};
|
||||||
@@ -205,11 +539,7 @@ export class AiAgentRolesService {
|
|||||||
* failures keep surfacing as 500s.
|
* failures keep surfacing as 500s.
|
||||||
*/
|
*/
|
||||||
function rethrowDuplicateName(err: unknown, name: string): never {
|
function rethrowDuplicateName(err: unknown, name: string): never {
|
||||||
if (
|
if (isUniqueViolation(err)) {
|
||||||
err &&
|
|
||||||
typeof err === 'object' &&
|
|
||||||
(err as { code?: unknown }).code === '23505'
|
|
||||||
) {
|
|
||||||
throw new ConflictException(
|
throw new ConflictException(
|
||||||
`A role named "${name}" already exists in this workspace.`,
|
`A role named "${name}" already exists in this workspace.`,
|
||||||
);
|
);
|
||||||
@@ -217,13 +547,120 @@ function rethrowDuplicateName(err: unknown, name: string): never {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** '' / whitespace-only / undefined => null; otherwise the trimmed value. */
|
/** Whether `err` is a Postgres unique-violation (SQLSTATE 23505). */
|
||||||
function emptyToNull(value: string | undefined): string | null {
|
function isUniqueViolation(err: unknown): boolean {
|
||||||
if (value === undefined) return null;
|
return (
|
||||||
|
!!err &&
|
||||||
|
typeof err === 'object' &&
|
||||||
|
(err as { code?: unknown }).code === '23505'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The partial unique index name from the
|
||||||
|
* 20260626T160000-ai-agent-roles-catalog-source-unique migration: unique on
|
||||||
|
* (workspace_id, source->>'slug', source->>'language') for catalog-imported,
|
||||||
|
* non-deleted rows. A 23505 carrying this constraint name is a source-collision
|
||||||
|
* (concurrent import of the same slug+language), distinct from a name-collision.
|
||||||
|
*/
|
||||||
|
const SOURCE_UNIQUE_CONSTRAINT = 'ai_agent_roles_workspace_source_unique';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether `err` is the 23505 raised by the SOURCE-uniqueness index specifically
|
||||||
|
* (vs the name-uniqueness index). The active driver (`kysely-postgres-js` over
|
||||||
|
* `postgres@3.4.8`) exposes the violated constraint name on `constraint_name`,
|
||||||
|
* so we key off that (accepting the node-postgres-style `.constraint` as a
|
||||||
|
* fallback for other drivers) — that way a source race is skipped while a name
|
||||||
|
* race still surfaces as a friendly per-role error. A 23505 with no constraint
|
||||||
|
* name (e.g. a wrapped/test error) is NOT treated as a source collision,
|
||||||
|
* preserving the existing name-race behavior.
|
||||||
|
*/
|
||||||
|
function isSourceUniqueViolation(err: unknown): boolean {
|
||||||
|
if (!isUniqueViolation(err)) return false;
|
||||||
|
const e = err as { constraint_name?: unknown; constraint?: unknown };
|
||||||
|
return (
|
||||||
|
e.constraint_name === SOURCE_UNIQUE_CONSTRAINT ||
|
||||||
|
e.constraint === SOURCE_UNIQUE_CONSTRAINT
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The role-content fields shared by import (insert) and update (patch) of a
|
||||||
|
* catalog role: emoji/description/launchMessage normalized to null, model config
|
||||||
|
* normalized, autoStart defaulted. The caller adds the write-specific fields
|
||||||
|
* (`name`, `source`, and on insert `workspaceId`/`creatorId`/`enabled`).
|
||||||
|
*/
|
||||||
|
function catalogRoleContentFields(role: CatalogRole): {
|
||||||
|
emoji: string | null;
|
||||||
|
description: string | null;
|
||||||
|
instructions: string;
|
||||||
|
modelConfig: Record<string, unknown> | null;
|
||||||
|
autoStart: boolean;
|
||||||
|
launchMessage: string | null;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
emoji: emptyToNull(role.emoji),
|
||||||
|
description: emptyToNull(role.description),
|
||||||
|
instructions: role.instructions,
|
||||||
|
modelConfig: normalizeModelConfig(role.modelConfig) as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| null,
|
||||||
|
autoStart: role.autoStart ?? true,
|
||||||
|
launchMessage: emptyToNull(role.launchMessage ?? undefined),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** '' / 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();
|
const trimmed = value.trim();
|
||||||
return trimmed.length > 0 ? trimmed : null;
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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 {
|
||||||
|
// `taken` is finite, so within `taken.size + 2` iterations a candidate index
|
||||||
|
// is guaranteed free; the 1000 cap is a defensive upper bound far above any
|
||||||
|
// realistic per-name collision count. The throw below is therefore
|
||||||
|
// unreachable in practice and only satisfies the return-type checker.
|
||||||
|
for (let n = 2; n < 1000; n++) {
|
||||||
|
const candidate = `${base} (${n})`;
|
||||||
|
if (!taken.has(candidate.toLowerCase())) return candidate;
|
||||||
|
}
|
||||||
|
throw new BadRequestException(`Too many roles named "${base}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A short, safe message for an import insert failure (409 vs other). */
|
||||||
|
function importErrorMessage(err: unknown): string {
|
||||||
|
if (isUniqueViolation(err)) {
|
||||||
|
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
|
* 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
|
* there is no usable override (no driver and no chatModel). The DTO's @IsIn
|
||||||
|
|||||||
@@ -0,0 +1,357 @@
|
|||||||
|
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() {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A ReadableStream whose first read rejects (e.g. a mid-body AbortError). */
|
||||||
|
function errorStream(err: Error): ReadableStream<Uint8Array> {
|
||||||
|
return new ReadableStream<Uint8Array>({
|
||||||
|
pull() {
|
||||||
|
throw err;
|
||||||
|
},
|
||||||
|
cancel() {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockResponse(opts: {
|
||||||
|
ok?: boolean;
|
||||||
|
status?: number;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
body: ReadableStream<Uint8Array> | null;
|
||||||
|
text?: string;
|
||||||
|
}): Response {
|
||||||
|
return {
|
||||||
|
ok: opts.ok ?? true,
|
||||||
|
status: opts.status ?? 200,
|
||||||
|
headers: { get: (k: string) => opts.headers?.[k.toLowerCase()] ?? null },
|
||||||
|
body: opts.body,
|
||||||
|
text: async () => opts.text ?? '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('fetch rejects (network failure) => BadGateway (unavailable)', async () => {
|
||||||
|
global.fetch = jest
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValue(new Error('ECONNREFUSED')) as never;
|
||||||
|
const provider = makeProvider('https://catalog.example.com');
|
||||||
|
await expect(provider.fetchIndex()).rejects.toBeInstanceOf(
|
||||||
|
BadGatewayException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes redirect:"error" to fetch (redirect-SSRF hardening)', async () => {
|
||||||
|
const fetchMock = jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(
|
||||||
|
mockResponse({ body: streamOf([new Uint8Array(0)]) }),
|
||||||
|
);
|
||||||
|
global.fetch = fetchMock as never;
|
||||||
|
const provider = makeProvider('https://catalog.example.com');
|
||||||
|
// Body shape is irrelevant; an empty stream parses to invalid JSON and
|
||||||
|
// throws, but the fetch call (with its init) still happened.
|
||||||
|
await expect(provider.fetchIndex()).rejects.toBeDefined();
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
|
expect.objectContaining({ redirect: 'error' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirect response rejects (redirect:"error") => BadGateway', async () => {
|
||||||
|
// With redirect:"error", the platform fetch rejects on a 3xx instead of
|
||||||
|
// following it. Simulate that: the mock rejects when asked not to follow.
|
||||||
|
global.fetch = jest.fn().mockImplementation((_url, init) => {
|
||||||
|
if (init?.redirect === 'error') {
|
||||||
|
return Promise.reject(
|
||||||
|
new TypeError('fetch failed: unexpected redirect'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Promise.resolve(
|
||||||
|
mockResponse({ status: 302, body: null }),
|
||||||
|
);
|
||||||
|
}) as never;
|
||||||
|
const provider = makeProvider('https://catalog.example.com');
|
||||||
|
await expect(provider.fetchIndex()).rejects.toBeInstanceOf(
|
||||||
|
BadGatewayException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('non-ok response (503) => BadGateway carrying the status', async () => {
|
||||||
|
global.fetch = jest.fn().mockResolvedValue(
|
||||||
|
mockResponse({ ok: false, status: 503, body: null }),
|
||||||
|
) as never;
|
||||||
|
const provider = makeProvider('https://catalog.example.com');
|
||||||
|
await expect(provider.fetchIndex()).rejects.toThrow(/503/);
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('body read aborts mid-stream (AbortError) => BadGateway (not a generic 500)', async () => {
|
||||||
|
// The 10s timer aborts the whole request; on a slow/dripping source the
|
||||||
|
// body read (reader.read()) rejects with an AbortError AFTER fetch()
|
||||||
|
// resolved. The provider must map that to BadGateway, not let it escape.
|
||||||
|
const abortErr = Object.assign(new Error('The operation was aborted'), {
|
||||||
|
name: 'AbortError',
|
||||||
|
});
|
||||||
|
global.fetch = jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(mockResponse({ body: errorStream(abortErr) })) as never;
|
||||||
|
const provider = makeProvider('https://catalog.example.com');
|
||||||
|
await expect(provider.fetchIndex()).rejects.toBeInstanceOf(
|
||||||
|
BadGatewayException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('null body (no readable stream) => response.text() fallback parses', async () => {
|
||||||
|
const json = JSON.stringify({
|
||||||
|
schemaVersion: 1,
|
||||||
|
bundles: [
|
||||||
|
{
|
||||||
|
id: 'general',
|
||||||
|
name: { en: 'General' },
|
||||||
|
languages: ['en'],
|
||||||
|
roles: [{ slug: 'researcher', version: 2 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
global.fetch = jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(mockResponse({ body: null, text: json })) as never;
|
||||||
|
const provider = makeProvider('https://catalog.example.com');
|
||||||
|
const index = await provider.fetchIndex();
|
||||||
|
expect(index.bundles[0].id).toBe('general');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('null body + text() over the cap => BadGateway (too large)', async () => {
|
||||||
|
const oversized = 'a'.repeat(1_000_001);
|
||||||
|
global.fetch = jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(
|
||||||
|
mockResponse({ body: null, text: oversized }),
|
||||||
|
) as never;
|
||||||
|
const provider = makeProvider('https://catalog.example.com');
|
||||||
|
await expect(provider.fetchIndex()).rejects.toBeInstanceOf(
|
||||||
|
BadGatewayException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invalid JSON body => BadGateway (parse failure)', async () => {
|
||||||
|
const body = streamOf([new TextEncoder().encode('{not valid json')]);
|
||||||
|
global.fetch = jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(mockResponse({ body })) as never;
|
||||||
|
const provider = makeProvider('https://catalog.example.com');
|
||||||
|
await expect(provider.fetchIndex()).rejects.toBeInstanceOf(
|
||||||
|
BadGatewayException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('malformed index.json (valid JSON, wrong shape) => BadGateway', async () => {
|
||||||
|
// Parses as JSON but fails isCatalogIndex (schemaVersion not a number).
|
||||||
|
const body = streamOf([
|
||||||
|
new TextEncoder().encode(
|
||||||
|
JSON.stringify({ schemaVersion: 'x', bundles: [] }),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
global.fetch = jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(mockResponse({ body })) as never;
|
||||||
|
const provider = makeProvider('https://catalog.example.com');
|
||||||
|
await expect(provider.fetchIndex()).rejects.toThrow(/malformed/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
import { promises as fs } from 'node:fs';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import {
|
||||||
|
BadGatewayException,
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
} 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 {
|
||||||
|
private readonly logger = new Logger(AiAgentRolesCatalogProvider.name);
|
||||||
|
|
||||||
|
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 (err) {
|
||||||
|
const reason = shortError(err);
|
||||||
|
this.logger.error(`Agent roles catalog JSON parse failed (${rel}): ${reason}`);
|
||||||
|
throw new BadGatewayException(
|
||||||
|
`Agent roles catalog file is not valid JSON (${rel}): ${reason}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 (err) {
|
||||||
|
const reason = shortError(err);
|
||||||
|
this.logger.error(
|
||||||
|
`Agent roles catalog local read failed (${path.join(dir, rel)}): ${reason}`,
|
||||||
|
);
|
||||||
|
throw new BadGatewayException(
|
||||||
|
`Agent roles catalog is unavailable: ${reason}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
// `redirect: 'error'` hardens against redirect-SSRF: a
|
||||||
|
// compromised-but-trusted upstream cannot 3xx the fetch into the
|
||||||
|
// internal network (e.g. http://169.254.169.254/...). A redirect
|
||||||
|
// response rejects here and is mapped to BadGateway below.
|
||||||
|
response = await fetch(url, {
|
||||||
|
signal: controller.signal,
|
||||||
|
redirect: 'error',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const reason = shortError(err);
|
||||||
|
this.logger.error(
|
||||||
|
`Agent roles catalog remote fetch failed (${rel}): ${reason}`,
|
||||||
|
);
|
||||||
|
throw new BadGatewayException(
|
||||||
|
`Agent roles catalog is unavailable: ${reason}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
this.logger.error(
|
||||||
|
`Agent roles catalog remote returned ${response.status} (${rel})`,
|
||||||
|
);
|
||||||
|
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.
|
||||||
|
// The 10s timer aborts the WHOLE request, so a slow/dripping hostile
|
||||||
|
// source rejects reader.read() (or response.text()) with an AbortError
|
||||||
|
// mid-body. Map that — and any other read failure — to a logged
|
||||||
|
// BadGateway so the admin endpoint returns 502 (not a generic 500). The
|
||||||
|
// cap's own BadGateway is rethrown as-is (no double-wrap).
|
||||||
|
try {
|
||||||
|
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;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof BadGatewayException) throw err;
|
||||||
|
const reason = shortError(err);
|
||||||
|
this.logger.error(
|
||||||
|
`Agent roles catalog body read failed (${rel}): ${reason}`,
|
||||||
|
);
|
||||||
|
throw new BadGatewayException(
|
||||||
|
`Agent roles catalog is unavailable: ${reason}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
return Buffer.concat(chunks).toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A short, non-sensitive error string for logging/propagation: only the first
|
||||||
|
* line of the message head is kept (upstream bodies / URLs are discarded).
|
||||||
|
*/
|
||||||
|
function shortError(err: unknown): string {
|
||||||
|
let message = '';
|
||||||
|
if (typeof err === 'string') {
|
||||||
|
message = err;
|
||||||
|
} else if (
|
||||||
|
err &&
|
||||||
|
typeof err === 'object' &&
|
||||||
|
typeof (err as { message?: unknown }).message === 'string'
|
||||||
|
) {
|
||||||
|
// Read `.message` directly (works for Error instances and the realm-shifted
|
||||||
|
// Error-likes jest can hand back, where `instanceof Error` is false).
|
||||||
|
message = (err as { message: string }).message;
|
||||||
|
}
|
||||||
|
const head = (message || 'unknown error').split('\n')[0];
|
||||||
|
return head.length > 200 ? `${head.slice(0, 200)}…` : head;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
47
apps/server/src/core/ai-chat/roles/catalog/catalog-types.ts
Normal file
47
apps/server/src/core/ai-chat/roles/catalog/catalog-types.ts
Normal 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[];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -21,6 +21,41 @@ export interface TreeNodeSnapshot {
|
|||||||
position: string;
|
position: string;
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
parentPageId: string | null;
|
parentPageId: string | null;
|
||||||
|
// Death-timer deadline carried so the `addTreeNode` broadcast shows the
|
||||||
|
// temporary-note clock marker immediately on every client (incl. the author,
|
||||||
|
// whose optimistic insert can lose the race to this broadcast). null/absent =>
|
||||||
|
// permanent.
|
||||||
|
temporaryExpiresAt?: Date | string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single canonical builder for a `TreeNodeSnapshot` from a page-like row. Both
|
||||||
|
* the `PAGE_CREATED` event enrichment (`page.repo.insertPage`) and the
|
||||||
|
* `addTreeNode` broadcast (`WsTreeService.broadcastPageCreated`) build this same
|
||||||
|
* snapshot; routing both through here keeps the optional `temporaryExpiresAt`
|
||||||
|
* (and the `?? null` normalisation that pins a permanent note to an explicit
|
||||||
|
* null) from silently drifting between the two literals.
|
||||||
|
*/
|
||||||
|
export function toTreeNodeSnapshot(page: {
|
||||||
|
id: string;
|
||||||
|
slugId: string;
|
||||||
|
title: string | null;
|
||||||
|
icon: string | null;
|
||||||
|
position: string;
|
||||||
|
spaceId: string;
|
||||||
|
parentPageId: string | null;
|
||||||
|
temporaryExpiresAt?: Date | string | null;
|
||||||
|
}): TreeNodeSnapshot {
|
||||||
|
return {
|
||||||
|
id: page.id,
|
||||||
|
slugId: page.slugId,
|
||||||
|
title: page.title,
|
||||||
|
icon: page.icon,
|
||||||
|
position: page.position,
|
||||||
|
spaceId: page.spaceId,
|
||||||
|
parentPageId: page.parentPageId,
|
||||||
|
temporaryExpiresAt: page.temporaryExpiresAt ?? null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PageEvent {
|
export class PageEvent {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { type Kysely, sql } from 'kysely';
|
||||||
|
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
// A catalog-imported role is uniquely identified within a workspace by its
|
||||||
|
// `source.slug` + `source.language` (a multilingual catalog: the `ru` variant
|
||||||
|
// of a slug installed as `en` is a SEPARATE install — hence both keys). The
|
||||||
|
// import path skips a slug+language already installed using an in-memory
|
||||||
|
// snapshot (installedKeys), but two CONCURRENT imports of the same bundle each
|
||||||
|
// read a stale snapshot and would both insert the same slug+language,
|
||||||
|
// duplicating the role. This partial unique index is the database-level
|
||||||
|
// backstop: the second insert gets a 23505 the service treats as
|
||||||
|
// "already installed" (skip), so the two imports converge on ONE role.
|
||||||
|
//
|
||||||
|
// Partial on `source IS NOT NULL` so MANUALLY-created roles (source NULL) are
|
||||||
|
// unconstrained — there can be many of those. Also partial on
|
||||||
|
// `deleted_at IS NULL` (like the existing name-unique index) so a soft-deleted
|
||||||
|
// role does not block re-importing the same slug+language later, matching the
|
||||||
|
// app's snapshot (listByWorkspace filters out soft-deleted rows).
|
||||||
|
await sql`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS ai_agent_roles_workspace_source_unique
|
||||||
|
ON ai_agent_roles (workspace_id, (source ->> 'slug'), (source ->> 'language'))
|
||||||
|
WHERE source IS NOT NULL AND deleted_at IS NULL
|
||||||
|
`.execute(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema
|
||||||
|
.dropIndex('ai_agent_roles_workspace_source_unique')
|
||||||
|
.ifExists()
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
@@ -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';
|
import type { KyselyDB } from '../../types/kysely.types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -132,4 +132,77 @@ describe('AiAgentRoleRepo insert/update auto-start columns', () => {
|
|||||||
expect(set2.mock.calls[0][0].launchMessage).toBeNull();
|
expect(set2.mock.calls[0][0].launchMessage).toBeNull();
|
||||||
expect('autoStart' in set2.mock.calls[0][0]).toBe(false);
|
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 is THE single form validator for the `source` jsonb column: a
|
||||||
|
* JSON-string (legacy double-encoded) is parsed; a FULLY-VALID object
|
||||||
|
* ({ slug, language, version }) passes through as a typed RoleSource; anything
|
||||||
|
* partial or wrong-shaped degrades to null (= manual role). This is the
|
||||||
|
* stricter-than-before guard that closes the drift where a weak `{}`/`{slug:123}`
|
||||||
|
* value used to be stamped as a valid source by the read path.
|
||||||
|
*/
|
||||||
|
describe('parseSource', () => {
|
||||||
|
it('parses a legacy double-encoded JSON string into the typed source', () => {
|
||||||
|
expect(
|
||||||
|
parseSource('{"slug":"researcher","language":"en","version":1}'),
|
||||||
|
).toEqual({ slug: 'researcher', language: 'en', version: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes a fully-valid already-parsed object through', () => {
|
||||||
|
const obj = { slug: 's', language: 'en', version: 2 };
|
||||||
|
expect(parseSource(obj)).toEqual(obj);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the typed RoleSource (extra keys tolerated) for a valid shape', () => {
|
||||||
|
const src = parseSource({ slug: 's', language: 'ru', version: 3 });
|
||||||
|
expect(src).not.toBeNull();
|
||||||
|
// Narrowed to RoleSource: the fields are present and correctly typed.
|
||||||
|
expect(src?.slug).toBe('s');
|
||||||
|
expect(src?.language).toBe('ru');
|
||||||
|
expect(src?.version).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('partial / wrong-typed shapes => null (no weak-but-typed-as-valid drift)', () => {
|
||||||
|
// Empty object: no slug/language/version.
|
||||||
|
expect(parseSource({})).toBeNull();
|
||||||
|
// slug present but not a string.
|
||||||
|
expect(parseSource({ slug: 123, language: 'en', version: 1 })).toBeNull();
|
||||||
|
// slug only, missing language + version.
|
||||||
|
expect(parseSource({ slug: 'a' })).toBeNull();
|
||||||
|
// empty-string slug / language are not valid catalog keys.
|
||||||
|
expect(parseSource({ slug: '', language: 'en', version: 1 })).toBeNull();
|
||||||
|
expect(parseSource({ slug: 'a', language: '', version: 1 })).toBeNull();
|
||||||
|
// version must be a number, not a numeric string.
|
||||||
|
expect(parseSource({ slug: 'a', language: 'en', version: '1' })).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
|
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
|
||||||
import { dbOrTx, jsonbBind, parseJsonbValue } from '../../utils';
|
import { dbOrTx, jsonbBind, parseJsonbValue } from '../../utils';
|
||||||
import { AiAgentRole } from '@docmost/db/types/entity.types';
|
import { AiAgentRole, RoleSource } from '@docmost/db/types/entity.types';
|
||||||
|
|
||||||
/** The jsonb shape persisted in `model_config` (loosely typed for the column). */
|
/** The jsonb shape persisted in `model_config` (loosely typed for the column). */
|
||||||
type ModelConfigValue = Record<string, unknown> | null;
|
type ModelConfigValue = Record<string, unknown> | null;
|
||||||
@@ -81,6 +81,8 @@ export class AiAgentRoleRepo {
|
|||||||
autoStart?: boolean;
|
autoStart?: boolean;
|
||||||
// null/'' => stored as null (client default launch message).
|
// null/'' => stored as null (client default launch message).
|
||||||
launchMessage?: string | null;
|
launchMessage?: string | null;
|
||||||
|
// Catalog origin { slug, language, version } | null. null => manual role.
|
||||||
|
source?: Record<string, unknown> | null;
|
||||||
},
|
},
|
||||||
trx?: KyselyTransaction,
|
trx?: KyselyTransaction,
|
||||||
): Promise<AiAgentRole> {
|
): Promise<AiAgentRole> {
|
||||||
@@ -103,6 +105,9 @@ export class AiAgentRoleRepo {
|
|||||||
autoStart: values.autoStart ?? true,
|
autoStart: values.autoStart ?? true,
|
||||||
// Empty string is treated as "no custom text" => null.
|
// Empty string is treated as "no custom text" => null.
|
||||||
launchMessage: values.launchMessage || 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()
|
.returningAll()
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
@@ -124,6 +129,8 @@ export class AiAgentRoleRepo {
|
|||||||
autoStart?: boolean;
|
autoStart?: boolean;
|
||||||
// undefined => unchanged; null/'' => clear to null; string => set.
|
// undefined => unchanged; null/'' => clear to null; string => set.
|
||||||
launchMessage?: string | null;
|
launchMessage?: string | null;
|
||||||
|
// undefined => unchanged; null => clear; object => set.
|
||||||
|
source?: Record<string, unknown> | null;
|
||||||
},
|
},
|
||||||
trx?: KyselyTransaction,
|
trx?: KyselyTransaction,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -142,6 +149,9 @@ export class AiAgentRoleRepo {
|
|||||||
// Empty string clears to null (client default launch message).
|
// Empty string clears to null (client default launch message).
|
||||||
set.launchMessage = patch.launchMessage || null;
|
set.launchMessage = patch.launchMessage || null;
|
||||||
}
|
}
|
||||||
|
if (patch.source !== undefined) {
|
||||||
|
set.source = jsonbBind(patch.source);
|
||||||
|
}
|
||||||
await db
|
await db
|
||||||
.updateTable('aiAgentRoles')
|
.updateTable('aiAgentRoles')
|
||||||
.set(set)
|
.set(set)
|
||||||
@@ -192,14 +202,46 @@ 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
|
* THE single form validator for the `source` jsonb column: parse the value read
|
||||||
* generated `JsonValue` type (an object is a valid JsonValue at runtime). */
|
* from the DB into a fully-valid {@link RoleSource} or null. Same legacy
|
||||||
|
* double-encoding self-heal as {@link parseModelConfig} (a JSON string is parsed
|
||||||
|
* once), then validates the FULL shape — `slug` and `language` non-empty
|
||||||
|
* strings, `version` a number. A null / corrupt / partially-shaped value (e.g.
|
||||||
|
* `{}`, `{ slug: 123 }`, `{ slug: 'a' }` missing language/version) degrades to
|
||||||
|
* null (= manually created, no catalog provenance), so a bad row never breaks
|
||||||
|
* the read path AND never stamps a half-built object as a valid `RoleSource`.
|
||||||
|
* Both the repo read-path and the service share this so the contract cannot
|
||||||
|
* drift between layers.
|
||||||
|
*/
|
||||||
|
export function parseSource(value: unknown): RoleSource | null {
|
||||||
|
return parseJsonbValue(value, isRoleSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full-shape guard for a persisted `source` jsonb value (see parseSource). */
|
||||||
|
function isRoleSource(v: unknown): v is RoleSource {
|
||||||
|
if (v === null || typeof v !== 'object' || Array.isArray(v)) return false;
|
||||||
|
const obj = v as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
typeof obj.slug === 'string' &&
|
||||||
|
obj.slug.length > 0 &&
|
||||||
|
typeof obj.language === 'string' &&
|
||||||
|
obj.language.length > 0 &&
|
||||||
|
typeof obj.version === 'number'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalize a DB row so `modelConfig` and `source` are always a valid object or
|
||||||
|
* null. The casts bridge the concrete parsed types (`Record | null`,
|
||||||
|
* `RoleSource | null`) to the column's broad generated `JsonValue` type — both
|
||||||
|
* are valid JsonValues at runtime; RoleSource lacks the JsonObject index
|
||||||
|
* signature so it routes through `unknown`. */
|
||||||
function normalizeRow(row: AiAgentRole): AiAgentRole {
|
function normalizeRow(row: AiAgentRole): AiAgentRole {
|
||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
modelConfig: parseModelConfig(
|
modelConfig: parseModelConfig(
|
||||||
row.modelConfig,
|
row.modelConfig,
|
||||||
) as AiAgentRole['modelConfig'],
|
) as AiAgentRole['modelConfig'],
|
||||||
|
source: parseSource(row.source) as unknown as AiAgentRole['source'],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
|
|||||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
import { EventName } from '../../../common/events/event.contants';
|
import { EventName } from '../../../common/events/event.contants';
|
||||||
import { TreeUpdateSnapshot } from '../../listeners/page.listener';
|
import {
|
||||||
|
TreeUpdateSnapshot,
|
||||||
|
toTreeNodeSnapshot,
|
||||||
|
} from '../../listeners/page.listener';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional extras for the PAGE_UPDATED event emitted by updatePage(s). Lets the
|
* Optional extras for the PAGE_UPDATED event emitted by updatePage(s). Lets the
|
||||||
@@ -200,17 +203,10 @@ export class PageRepo {
|
|||||||
this.eventEmitter.emit(EventName.PAGE_CREATED, {
|
this.eventEmitter.emit(EventName.PAGE_CREATED, {
|
||||||
pageIds: [result.id],
|
pageIds: [result.id],
|
||||||
workspaceId: result.workspaceId,
|
workspaceId: result.workspaceId,
|
||||||
pages: [
|
// Built via the shared snapshot helper so the field copy (and the
|
||||||
{
|
// death-timer deadline that shows the sidebar clock marker without a
|
||||||
id: result.id,
|
// reload) can't drift from the `addTreeNode` broadcast literal.
|
||||||
slugId: result.slugId,
|
pages: [toTreeNodeSnapshot(result)],
|
||||||
title: result.title,
|
|
||||||
icon: result.icon,
|
|
||||||
position: result.position,
|
|
||||||
spaceId: result.spaceId,
|
|
||||||
parentPageId: result.parentPageId,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
2
apps/server/src/database/types/db.d.ts
vendored
2
apps/server/src/database/types/db.d.ts
vendored
@@ -618,6 +618,8 @@ export interface AiAgentRoles {
|
|||||||
autoStart: Generated<boolean>;
|
autoStart: Generated<boolean>;
|
||||||
// Optional custom auto-start text. null/empty => client default launch message.
|
// Optional custom auto-start text. null/empty => client default launch message.
|
||||||
launchMessage: string | null;
|
launchMessage: string | null;
|
||||||
|
// Catalog origin of an imported role: { slug, language, version } | null. null => manually created.
|
||||||
|
source: Json | null;
|
||||||
createdAt: Generated<Timestamp>;
|
createdAt: Generated<Timestamp>;
|
||||||
updatedAt: Generated<Timestamp>;
|
updatedAt: Generated<Timestamp>;
|
||||||
deletedAt: Timestamp | null;
|
deletedAt: Timestamp | null;
|
||||||
|
|||||||
@@ -81,6 +81,24 @@ export type UpdatableAiMcpServer = Updateable<Omit<AiMcpServersTable, 'id'>>;
|
|||||||
// A role replaces the persona layer of the system prompt (instructions) and may
|
// A role replaces the persona layer of the system prompt (instructions) and may
|
||||||
// optionally override the chat model (`modelConfig`). Soft-deletable.
|
// optionally override the chat model (`modelConfig`). Soft-deletable.
|
||||||
export type AiAgentRole = Selectable<AiAgentRoles>;
|
export type AiAgentRole = Selectable<AiAgentRoles>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The validated shape of the `source` jsonb column on ai_agent_roles: the
|
||||||
|
* catalog origin of an imported role. `version` lets the admin UI offer an
|
||||||
|
* UPDATE when the catalog ships a newer revision of the same slug; null `source`
|
||||||
|
* (not this type) means a manually-created role with no catalog provenance.
|
||||||
|
*
|
||||||
|
* THE single contract for that column, shared by the repo read-path
|
||||||
|
* (`parseSource`, the only form validator) and the service, so the persisted
|
||||||
|
* shape can never be validated weakly in one layer and strongly in another.
|
||||||
|
* Defined here (a leaf db-types module both already import `AiAgentRole` from) to
|
||||||
|
* avoid an import cycle between the repo and the service.
|
||||||
|
*/
|
||||||
|
export interface RoleSource {
|
||||||
|
slug: string;
|
||||||
|
language: string;
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
export type InsertableAiAgentRole = Insertable<AiAgentRoles>;
|
export type InsertableAiAgentRole = Insertable<AiAgentRoles>;
|
||||||
export type UpdatableAiAgentRole = Updateable<Omit<AiAgentRoles, 'id'>>;
|
export type UpdatableAiAgentRole = Updateable<Omit<AiAgentRoles, 'id'>>;
|
||||||
|
|
||||||
|
|||||||
@@ -289,6 +289,15 @@ export class EnvironmentService {
|
|||||||
// provider/model/key config now lives solely in workspace settings +
|
// provider/model/key config now lives solely in workspace settings +
|
||||||
// ai_provider_credentials, with no env fallback. APP_SECRET stays (getAppSecret).
|
// 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 {
|
getEventStoreDriver(): string {
|
||||||
return this.configService
|
return this.configService
|
||||||
.get<string>('EVENT_STORE_DRIVER', 'postgres')
|
.get<string>('EVENT_STORE_DRIVER', 'postgres')
|
||||||
|
|||||||
@@ -83,6 +83,27 @@ describe('WsTreeService', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('broadcastPageCreated carries temporaryExpiresAt when the page is a temporary note', async () => {
|
||||||
|
const expiresAt = new Date('2026-07-01T00:00:00.000Z');
|
||||||
|
await service.broadcastPageCreated({ ...snapshot, temporaryExpiresAt: expiresAt });
|
||||||
|
|
||||||
|
const data =
|
||||||
|
wsService.emitTreeEvent.mock.calls[0][2].payload.data;
|
||||||
|
// The death-timer deadline reaches receivers so the clock marker renders
|
||||||
|
// immediately (incl. the author if this broadcast wins the optimistic race).
|
||||||
|
expect(data.temporaryExpiresAt).toBe(expiresAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('broadcastPageCreated pins temporaryExpiresAt to null for a permanent page', async () => {
|
||||||
|
// Fixture omits temporaryExpiresAt; the `?? null` must send an explicit null
|
||||||
|
// (permanent) rather than undefined, so receivers clear any stale marker.
|
||||||
|
await service.broadcastPageCreated(snapshot);
|
||||||
|
|
||||||
|
const data =
|
||||||
|
wsService.emitTreeEvent.mock.calls[0][2].payload.data;
|
||||||
|
expect(data.temporaryExpiresAt).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('broadcastPageDeleted emits deleteTreeNode with the root node only', async () => {
|
it('broadcastPageDeleted emits deleteTreeNode with the root node only', async () => {
|
||||||
await service.broadcastPageDeleted({
|
await service.broadcastPageDeleted({
|
||||||
...snapshot,
|
...snapshot,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
PageMovedEvent,
|
PageMovedEvent,
|
||||||
TreeNodeSnapshot,
|
TreeNodeSnapshot,
|
||||||
TreeUpdateSnapshot,
|
TreeUpdateSnapshot,
|
||||||
|
toTreeNodeSnapshot,
|
||||||
} from '../database/listeners/page.listener';
|
} from '../database/listeners/page.listener';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -28,15 +29,16 @@ export class WsTreeService {
|
|||||||
// Receivers place by `position` among already-loaded siblings, not by
|
// Receivers place by `position` among already-loaded siblings, not by
|
||||||
// this absolute index (sender's loaded set differs from receivers').
|
// this absolute index (sender's loaded set differs from receivers').
|
||||||
index: 0,
|
index: 0,
|
||||||
|
// Built via the shared snapshot helper (same one page.repo uses to fill
|
||||||
|
// the event), then extended with the tree-only fields the client
|
||||||
|
// receiver consumes. The helper carries the death-timer deadline
|
||||||
|
// (normalised to null => permanent) so receivers — and the author, if
|
||||||
|
// this broadcast wins the race against the optimistic insert — render
|
||||||
|
// the temporary-note clock marker immediately, without it drifting from
|
||||||
|
// the event literal.
|
||||||
data: {
|
data: {
|
||||||
id: page.id,
|
...toTreeNodeSnapshot(page),
|
||||||
slugId: page.slugId,
|
|
||||||
name: page.title ?? '',
|
name: page.title ?? '',
|
||||||
title: page.title,
|
|
||||||
icon: page.icon,
|
|
||||||
position: page.position,
|
|
||||||
spaceId: page.spaceId,
|
|
||||||
parentPageId: page.parentPageId,
|
|
||||||
hasChildren: false,
|
hasChildren: false,
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user