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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude_code
2026-06-26 19:26:30 +03:00
committed by claude code agent 227
parent 0fc9c4a998
commit 19f84ca0e7
29 changed files with 2643 additions and 29 deletions
@@ -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();
}
@@ -1,4 +1,4 @@
import { AiAgentRoleRepo } from './ai-agent-roles.repo';
import { AiAgentRoleRepo, parseSource } from './ai-agent-roles.repo';
import type { KyselyDB } from '../../types/kysely.types';
/**
@@ -132,4 +132,50 @@ describe('AiAgentRoleRepo insert/update auto-start columns', () => {
expect(set2.mock.calls[0][0].launchMessage).toBeNull();
expect('autoStart' in set2.mock.calls[0][0]).toBe(false);
});
it('insert binds `source` (jsonb); update sets it only when present', async () => {
const { repo, values } = makeInsertRepo();
await repo.insert({
workspaceId: 'ws-1',
name: 'R',
instructions: 'do',
source: { slug: 'researcher', language: 'en', version: 1 },
});
// jsonbBind returns a RawBuilder for a non-empty object (not null).
expect(values.mock.calls[0][0].source).not.toBeNull();
const { repo: repo2, set } = makeUpdateRepo();
await repo2.update('r-1', 'ws-1', { name: 'X' });
expect('source' in set.mock.calls[0][0]).toBe(false);
const { repo: repo3, set: set3 } = makeUpdateRepo();
await repo3.update('r-1', 'ws-1', {
source: { slug: 's', language: 'en', version: 2 },
});
expect('source' in set3.mock.calls[0][0]).toBe(true);
});
});
/**
* parseSource: a JSON-string (legacy double-encoded) is parsed; a real object
* passes through; null / a non-object / an array degrade to null (= manual role).
*/
describe('parseSource', () => {
it('parses a legacy double-encoded JSON string into the object', () => {
expect(
parseSource('{"slug":"researcher","language":"en","version":1}'),
).toEqual({ slug: 'researcher', language: 'en', version: 1 });
});
it('passes an already-parsed object through', () => {
const obj = { slug: 's', language: 'en', version: 2 };
expect(parseSource(obj)).toEqual(obj);
});
it('null / array / non-object / unparseable string => null', () => {
expect(parseSource(null)).toBeNull();
expect(parseSource([1, 2])).toBeNull();
expect(parseSource(42)).toBeNull();
expect(parseSource('not json')).toBeNull();
});
});
@@ -81,6 +81,8 @@ export class AiAgentRoleRepo {
autoStart?: boolean;
// null/'' => stored as null (client default launch message).
launchMessage?: string | null;
// Catalog origin { slug, language, version } | null. null => manual role.
source?: Record<string, unknown> | null;
},
trx?: KyselyTransaction,
): Promise<AiAgentRole> {
@@ -103,6 +105,9 @@ export class AiAgentRoleRepo {
autoStart: values.autoStart ?? true,
// Empty string is treated as "no custom text" => null.
launchMessage: values.launchMessage || null,
// Same cast reason as modelConfig (see above).
// eslint-disable-next-line @typescript-eslint/no-explicit-any
source: jsonbBind(values.source) as any,
})
.returningAll()
.executeTakeFirst();
@@ -124,6 +129,8 @@ export class AiAgentRoleRepo {
autoStart?: boolean;
// undefined => unchanged; null/'' => clear to null; string => set.
launchMessage?: string | null;
// undefined => unchanged; null => clear; object => set.
source?: Record<string, unknown> | null;
},
trx?: KyselyTransaction,
): Promise<void> {
@@ -142,6 +149,9 @@ export class AiAgentRoleRepo {
// Empty string clears to null (client default launch message).
set.launchMessage = patch.launchMessage || null;
}
if (patch.source !== undefined) {
set.source = jsonbBind(patch.source);
}
await db
.updateTable('aiAgentRoles')
.set(set)
@@ -192,8 +202,23 @@ export function parseModelConfig(
);
}
/** Normalize a DB row so `modelConfig` is always an object or null. The cast
* bridges parseModelConfig's concrete `Record | null` to the column's broad
/**
* Parse the `source` jsonb value read from the DB into an object or null,
* analogous to {@link parseModelConfig}. Same legacy double-encoding self-heal
* (a JSON string is parsed once) and the same shape guard: not null, an object,
* and not an array. A corrupt / wrong-shaped value degrades to null (= manually
* created), so a bad row never breaks the read path.
*/
export function parseSource(value: unknown): Record<string, unknown> | null {
return parseJsonbValue(
value,
(v): v is Record<string, unknown> =>
v !== null && typeof v === 'object' && !Array.isArray(v),
);
}
/** Normalize a DB row so `modelConfig` and `source` are always an object or
* null. The casts bridge the concrete `Record | null` to the column's broad
* generated `JsonValue` type (an object is a valid JsonValue at runtime). */
function normalizeRow(row: AiAgentRole): AiAgentRole {
return {
@@ -201,5 +226,6 @@ function normalizeRow(row: AiAgentRole): AiAgentRole {
modelConfig: parseModelConfig(
row.modelConfig,
) as AiAgentRole['modelConfig'],
source: parseSource(row.source) as AiAgentRole['source'],
};
}
+2
View File
@@ -618,6 +618,8 @@ export interface AiAgentRoles {
autoStart: Generated<boolean>;
// Optional custom auto-start text. null/empty => client default launch message.
launchMessage: string | null;
// Catalog origin of an imported role: { slug, language, version } | null. null => manually created.
source: Json | null;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
deletedAt: Timestamp | null;