feat(ai-chat): agent roles (admin-defined persona + optional model)
Reusable, workspace-shared agent roles for the built-in AI chat. A role is a named persona (system-prompt instructions) + optional model override; a chat is bound to a role at creation and applies it every turn. Backend: - migration 20260620T120000: ai_agent_roles table + ai_chats.role_id (FK ON DELETE SET NULL); hand-merged types into db.d.ts/entity.types.ts (db.d.ts is hand-curated here, full codegen would clobber it). - core/ai-chat/roles: CRUD module. list = any workspace member; create/ update/delete = admin (Manage Settings ability, like ai-settings/mcp). All repo queries scoped by workspace_id; soft-delete (deleted_at). - buildSystemPrompt gains roleInstructions: role REPLACES the persona base (admin prompt / DEFAULT_PROMPT) but SAFETY_FRAMEWORK + context are always still appended. - stream(): role resolved from ai_chats.role_id for existing chats (never the request body -> no per-turn role swap); body.roleId only on creation. Disabled (enabled=false) and soft-deleted roles fall back to universal. - getChatModel(workspaceId, override): role model_config can swap model id / driver; a driver without configured creds throws 503 with a clear message naming the driver+role, resolved BEFORE response hijack. Client: - new-chat role picker (enabled roles only, default Universal assistant), roleId sent only on the first message; role badge (emoji+name) in the chat header and conversation list; admin Agent-roles management section in Settings -> AI (add/edit/delete, MCP-form pattern). Tests: ai-chat.prompt.spec (role layering + safety always present, incl. jailbreak); ai.service.spec (override on unconfigured driver -> 503). Implements docs/ai-agent-roles-plan.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,7 @@ import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
|
||||
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
|
||||
import { AiProviderCredentialsRepo } from '@docmost/db/repos/ai-chat/ai-provider-credentials.repo';
|
||||
import { AiMcpServerRepo } from '@docmost/db/repos/ai-chat/ai-mcp-server.repo';
|
||||
import { AiAgentRoleRepo } from '@docmost/db/repos/ai-agent-roles/ai-agent-roles.repo';
|
||||
import { PageEmbeddingRepo } from '@docmost/db/repos/ai-chat/page-embedding.repo';
|
||||
import { PageListener } from '@docmost/db/listeners/page.listener';
|
||||
import { PostgresJSDialect } from 'kysely-postgres-js';
|
||||
@@ -101,6 +102,7 @@ import { normalizePostgresUrl } from '../common/helpers';
|
||||
AiChatMessageRepo,
|
||||
AiProviderCredentialsRepo,
|
||||
AiMcpServerRepo,
|
||||
AiAgentRoleRepo,
|
||||
PageEmbeddingRepo,
|
||||
PageListener,
|
||||
],
|
||||
@@ -131,6 +133,7 @@ import { normalizePostgresUrl } from '../common/helpers';
|
||||
AiChatMessageRepo,
|
||||
AiProviderCredentialsRepo,
|
||||
AiMcpServerRepo,
|
||||
AiAgentRoleRepo,
|
||||
PageEmbeddingRepo,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { type Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
// Reusable, workspace-scoped agent roles (admin-owned). A role REPLACES the
|
||||
// persona layer of the system prompt (instructions) and may optionally
|
||||
// override the chat model. The non-removable SAFETY_FRAMEWORK is always still
|
||||
// appended downstream — a role only shapes the persona, never the safety rules.
|
||||
await db.schema
|
||||
.createTable('ai_agent_roles')
|
||||
.ifNotExists()
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
.addColumn('workspace_id', 'uuid', (col) =>
|
||||
col.references('workspaces.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
// Who created the role (audit). The role is shared and outlives its author,
|
||||
// so SET NULL on user deletion (unlike ai_chats.creator_id which is NOT NULL).
|
||||
.addColumn('creator_id', 'uuid', (col) =>
|
||||
col.references('users.id').onDelete('set null'),
|
||||
)
|
||||
// Display name, e.g. 'Proofreader'.
|
||||
.addColumn('name', 'varchar', (col) => col.notNull())
|
||||
// Optional presentation emoji for the role badge.
|
||||
.addColumn('emoji', 'varchar', (col) => col)
|
||||
// Optional short description shown in the management UI.
|
||||
.addColumn('description', 'text', (col) => col)
|
||||
// The persona fragment injected into the system prompt (replaces the admin
|
||||
// persona / DEFAULT_PROMPT). Required.
|
||||
.addColumn('instructions', 'text', (col) => col.notNull())
|
||||
// Optional model override: { chatModel } or { driver, chatModel }. NULL =>
|
||||
// use the workspace default model. Driver creds come from the matching
|
||||
// provider in ai_provider_credentials (no per-role creds).
|
||||
.addColumn('model_config', 'jsonb', (col) => col)
|
||||
.addColumn('enabled', 'boolean', (col) => col.notNull().defaultTo(true))
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('updated_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
// Soft delete (consistent with ai_chats): the role disappears from the
|
||||
// picker but lookups can still resolve it for already-bound chats.
|
||||
.addColumn('deleted_at', 'timestamptz', (col) => col)
|
||||
.execute();
|
||||
|
||||
// Scoped lookups (listByWorkspace) hit workspace_id first.
|
||||
await db.schema
|
||||
.createIndex('idx_ai_agent_roles_workspace_id')
|
||||
.ifNotExists()
|
||||
.on('ai_agent_roles')
|
||||
.column('workspace_id')
|
||||
.execute();
|
||||
|
||||
// Bind a chat to a role. ON DELETE SET NULL: a hard-deleted role degrades the
|
||||
// chat to the universal assistant instead of breaking it. The role is read
|
||||
// from this column on every turn — the client only sends roleId on chat
|
||||
// creation (first message).
|
||||
await db.schema
|
||||
.alterTable('ai_chats')
|
||||
.addColumn('role_id', 'uuid', (col) =>
|
||||
col.references('ai_agent_roles.id').onDelete('set null'),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.alterTable('ai_chats').dropColumn('role_id').execute();
|
||||
await db.schema.dropTable('ai_agent_roles').execute();
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { sql } from 'kysely';
|
||||
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
|
||||
import { dbOrTx } from '../../utils';
|
||||
import { AiAgentRole } from '@docmost/db/types/entity.types';
|
||||
|
||||
/** The jsonb shape persisted in `model_config` (loosely typed for the column). */
|
||||
type ModelConfigValue = Record<string, unknown> | null;
|
||||
|
||||
/**
|
||||
* Repository for per-workspace agent roles (admin-owned presets). All lookups
|
||||
* are workspace-scoped and soft-delete aware (`deleted_at IS NULL`). A role
|
||||
* shapes only the system-prompt persona + optional model override; it never
|
||||
* widens or narrows the toolset or CASL boundary.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AiAgentRoleRepo {
|
||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||
|
||||
/** Single live (not soft-deleted) role scoped to the workspace. */
|
||||
async findById(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
): Promise<AiAgentRole | undefined> {
|
||||
return this.db
|
||||
.selectFrom('aiAgentRoles')
|
||||
.selectAll('aiAgentRoles')
|
||||
.where('id', '=', id)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
/** All live roles for the workspace (management list + chat picker). */
|
||||
async listByWorkspace(workspaceId: string): Promise<AiAgentRole[]> {
|
||||
return this.db
|
||||
.selectFrom('aiAgentRoles')
|
||||
.selectAll('aiAgentRoles')
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.orderBy('createdAt', 'asc')
|
||||
.execute();
|
||||
}
|
||||
|
||||
async insert(
|
||||
values: {
|
||||
workspaceId: string;
|
||||
creatorId?: string | null;
|
||||
name: string;
|
||||
emoji?: string | null;
|
||||
description?: string | null;
|
||||
instructions: string;
|
||||
modelConfig?: ModelConfigValue;
|
||||
enabled?: boolean;
|
||||
},
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<AiAgentRole> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.insertInto('aiAgentRoles')
|
||||
.values({
|
||||
workspaceId: values.workspaceId,
|
||||
creatorId: values.creatorId ?? null,
|
||||
name: values.name,
|
||||
emoji: values.emoji ?? null,
|
||||
description: values.description ?? null,
|
||||
instructions: values.instructions,
|
||||
modelConfig: jsonbObject(values.modelConfig),
|
||||
enabled: values.enabled ?? true,
|
||||
})
|
||||
.returningAll()
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
patch: {
|
||||
name?: string;
|
||||
// undefined => unchanged; null => clear; string => set.
|
||||
emoji?: string | null;
|
||||
description?: string | null;
|
||||
instructions?: string;
|
||||
// undefined => unchanged; null => clear; object => set.
|
||||
modelConfig?: ModelConfigValue;
|
||||
enabled?: boolean;
|
||||
},
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
const set: Record<string, unknown> = { updatedAt: new Date() };
|
||||
if (patch.name !== undefined) set.name = patch.name;
|
||||
if (patch.emoji !== undefined) set.emoji = patch.emoji;
|
||||
if (patch.description !== undefined) set.description = patch.description;
|
||||
if (patch.instructions !== undefined) set.instructions = patch.instructions;
|
||||
if (patch.modelConfig !== undefined) {
|
||||
set.modelConfig = jsonbObject(patch.modelConfig);
|
||||
}
|
||||
if (patch.enabled !== undefined) set.enabled = patch.enabled;
|
||||
await db
|
||||
.updateTable('aiAgentRoles')
|
||||
.set(set)
|
||||
.where('id', '=', id)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** Soft delete (consistent with ai_chats). Bound chats keep their role_id; the
|
||||
* stream resolves only live roles, so the chat degrades to universal. */
|
||||
async softDelete(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.updateTable('aiAgentRoles')
|
||||
.set({ deletedAt: new Date() })
|
||||
.where('id', '=', id)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode an object as a jsonb bind for the `model_config` column. The postgres
|
||||
* driver would otherwise need an explicit cast; bind the JSON text and cast it.
|
||||
* Returns null for null/undefined/empty objects. Cast to `any` because the
|
||||
* generated column type is the broad `JsonValue` union, which a concrete object
|
||||
* type is not structurally assignable to.
|
||||
*/
|
||||
function jsonbObject(value: ModelConfigValue | undefined) {
|
||||
if (value === null || value === undefined || Object.keys(value).length === 0) {
|
||||
return null;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return sql`${JSON.stringify(value)}::jsonb` as any;
|
||||
}
|
||||
@@ -29,20 +29,34 @@ export class AiChatRepo {
|
||||
workspaceId: string,
|
||||
pagination: PaginationOptions,
|
||||
) {
|
||||
// Left-join the bound role for the badge (emoji + name). Joined, not
|
||||
// denormalized — the chat list is not a hot path. A soft-deleted role
|
||||
// resolves to NULL so the badge disappears, matching the stream's behavior.
|
||||
const query = this.db
|
||||
.selectFrom('aiChats')
|
||||
.leftJoin('aiAgentRoles', (join) =>
|
||||
join
|
||||
.onRef('aiAgentRoles.id', '=', 'aiChats.roleId')
|
||||
.on('aiAgentRoles.deletedAt', 'is', null),
|
||||
)
|
||||
.selectAll('aiChats')
|
||||
.where('creatorId', '=', creatorId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('deletedAt', 'is', null);
|
||||
.select([
|
||||
'aiAgentRoles.name as roleName',
|
||||
'aiAgentRoles.emoji as roleEmoji',
|
||||
])
|
||||
.where('aiChats.creatorId', '=', creatorId)
|
||||
.where('aiChats.workspaceId', '=', workspaceId)
|
||||
.where('aiChats.deletedAt', 'is', null);
|
||||
|
||||
return executeWithCursorPagination(query, {
|
||||
perPage: pagination.limit,
|
||||
cursor: pagination.cursor,
|
||||
beforeCursor: pagination.beforeCursor,
|
||||
fields: [
|
||||
{ expression: 'createdAt', direction: 'desc' },
|
||||
{ expression: 'id', direction: 'desc' },
|
||||
// Qualify to aiChats — the join introduces an aiAgentRoles.createdAt/id
|
||||
// that would otherwise make the ORDER BY / cursor comparison ambiguous.
|
||||
{ expression: 'aiChats.createdAt', direction: 'desc' },
|
||||
{ expression: 'aiChats.id', direction: 'desc' },
|
||||
],
|
||||
parseCursor: (cursor) => ({
|
||||
createdAt: new Date(cursor.createdAt),
|
||||
|
||||
28
apps/server/src/database/types/db.d.ts
vendored
28
apps/server/src/database/types/db.d.ts
vendored
@@ -561,6 +561,33 @@ export interface AiChats {
|
||||
workspaceId: string;
|
||||
creatorId: string;
|
||||
title: string | null;
|
||||
// The agent role this chat is bound to (set on creation, immutable). NULL =>
|
||||
// universal assistant. ON DELETE SET NULL: a hard-deleted role degrades the
|
||||
// chat to universal instead of breaking it. Resolved from this column on every
|
||||
// turn — NOT from the request body.
|
||||
roleId: string | null;
|
||||
createdAt: Generated<Timestamp>;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
deletedAt: Timestamp | null;
|
||||
}
|
||||
|
||||
// Reusable, workspace-scoped agent roles (admin-owned). Mirrors migration
|
||||
// 20260620T120000-ai-agent-roles.ts. A role REPLACES the persona layer of the
|
||||
// system prompt (`instructions`) and may optionally override the chat model
|
||||
// (`modelConfig`). The non-removable SAFETY_FRAMEWORK is always still appended
|
||||
// downstream. Soft-deletable via `deletedAt`.
|
||||
export interface AiAgentRoles {
|
||||
id: Generated<string>;
|
||||
workspaceId: string;
|
||||
// Audit only; SET NULL on user deletion (the role outlives its author).
|
||||
creatorId: string | null;
|
||||
name: string;
|
||||
emoji: string | null;
|
||||
description: string | null;
|
||||
instructions: string;
|
||||
// { chatModel } | { driver, chatModel } | null. null => workspace default.
|
||||
modelConfig: Json | null;
|
||||
enabled: Generated<boolean>;
|
||||
createdAt: Generated<Timestamp>;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
deletedAt: Timestamp | null;
|
||||
@@ -597,6 +624,7 @@ export interface UserSessions {
|
||||
}
|
||||
|
||||
export interface DB {
|
||||
aiAgentRoles: AiAgentRoles;
|
||||
aiChats: AiChats;
|
||||
aiChatMessages: AiChatMessages;
|
||||
apiKeys: ApiKeys;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Insertable, Selectable, Updateable } from 'kysely';
|
||||
import {
|
||||
AiAgentRoles,
|
||||
AiChats,
|
||||
AiChatMessages,
|
||||
Attachments,
|
||||
@@ -74,6 +75,13 @@ export type AiMcpServer = Selectable<AiMcpServersTable>;
|
||||
export type InsertableAiMcpServer = Insertable<AiMcpServersTable>;
|
||||
export type UpdatableAiMcpServer = Updateable<Omit<AiMcpServersTable, 'id'>>;
|
||||
|
||||
// AI Agent Roles (reusable, workspace-scoped, admin-owned agent presets).
|
||||
// A role replaces the persona layer of the system prompt (instructions) and may
|
||||
// optionally override the chat model (`modelConfig`). Soft-deletable.
|
||||
export type AiAgentRole = Selectable<AiAgentRoles>;
|
||||
export type InsertableAiAgentRole = Insertable<AiAgentRoles>;
|
||||
export type UpdatableAiAgentRole = Updateable<Omit<AiAgentRoles, 'id'>>;
|
||||
|
||||
// Workspace
|
||||
export type Workspace = Selectable<Workspaces>;
|
||||
export type InsertableWorkspace = Insertable<Workspaces>;
|
||||
|
||||
Reference in New Issue
Block a user