feat(ai-chat): add reusable agent roles (persona + optional model)
Roles are workspace-admin presets that customize the AI agent's system- prompt persona and, optionally, the model, attached to a chat at creation time. Examples: a 'Proofreader' that only touches grammar, a 'Fact- checker' that cites web sources. A role changes ONLY instructions and ( optional ) the model; the toolset stays full, so the security boundary (CASL via the per-user loopback token) is unchanged. Backend: - Migration 20260620T150000-ai-agent-roles: ai_agent_roles table (workspace-scoped, soft-delete, model_config jsonb) + ai_chats.role_id (ON DELETE SET NULL). - AiAgentRoleRepo / AiAgentRolesService / AiAgentRolesController at /workspace/ai-agent-roles. LIST (picker view) is open to all workspace members; create/update/delete are admin-only. The picker view omits instructions and model_config so they never leak to non-admins. - buildSystemPrompt: optional roleInstructions REPLACES the admin persona (priority order: role > admin > default). The non-removable SAFETY_FRAMEWORK is always appended - a role cannot strip it. - AiChatService.stream: persists roleId on first turn; subsequent turns read role_id from the chat row, never from the request body. The role's instructions are applied even if it was later disabled or soft-deleted (existing chats keep their persona). - AiService.getChatModel accepts an optional override. Same-driver overrides reuse the workspace key; cross-driver (openai/gemini) loads alternate creds from ai_provider_credentials and throws a clean 503 if they are missing (no silent fallback). Cross-driver ollama is rejected with a clear message (no per-driver ollama base URL exists yet). - Controller resolves the role model BEFORE res.hijack so misconfigured overrides return JSON 503, not a broken stream. Client: - New chat picker (Mantine Select) lists enabled roles, default 'Universal assistant' (roleId null). The roleId is sent only when starting a new chat; existing chats show the role as a fixed badge. - Role badge in the chat window header and conversation list. - Settings -> AI: new 'Agent roles' management section mirrors the external MCP servers UI (add/edit/delete + enable toggle + optional model override). Form fields: name, emoji, description, instructions, model override (driver + chatModel), with a reminder that the safety framework is always appended. Hardening after review: - Empty-string roleId coerced to null on both client and server (picker 'Universal assistant' option used to crash the uuid INSERT). - New-chat insert validates picker-eligibility (enabled + not soft-deleted + workspace-scoped); ineligible ids silently fall back to null. - findByCreator's role JOIN is workspace-scoped and every column ref is table-qualified (avoids Postgres ambiguous-column errors). - getChatModelForRole applies the same picker-eligibility gate as stream on the new-chat path, so model and persona resolve from one source.
This commit is contained in:
@@ -144,8 +144,14 @@ export class AiChatController {
|
||||
|
||||
// Resolve the model BEFORE hijack so an unconfigured provider returns a
|
||||
// clean JSON 503 (AiNotConfiguredException is a 503 HttpException; letting
|
||||
// it propagate here yields a normal response, not a broken stream).
|
||||
const model = await this.aiChatService.getChatModel(workspace.id);
|
||||
// it propagate here yields a normal response, not a broken stream). This
|
||||
// also resolves a role's optional model override: a misconfigured alternate
|
||||
// driver throws a clear 503 here (never a silent fallback mid-stream).
|
||||
const model = await this.aiChatService.getChatModelForRole(
|
||||
workspace.id,
|
||||
body.roleId,
|
||||
body.chatId,
|
||||
);
|
||||
|
||||
// Abort the agent loop when the client disconnects. `close` also fires on
|
||||
// normal completion, so only abort when the response has not finished
|
||||
|
||||
@@ -7,6 +7,7 @@ import { AiTranscriptionService } from './ai-transcription.service';
|
||||
import { AiChatToolsService } from './tools/ai-chat-tools.service';
|
||||
import { EmbeddingModule } from './embedding/embedding.module';
|
||||
import { ExternalMcpModule } from './external-mcp/external-mcp.module';
|
||||
import { AiAgentRolesModule } from './roles/ai-agent-roles.module';
|
||||
|
||||
/**
|
||||
* Per-user AI chat module (§6.1).
|
||||
@@ -18,9 +19,17 @@ import { ExternalMcpModule } from './external-mcp/external-mcp.module';
|
||||
* + AI_CHAT throttler come from the global ThrottleModule registered in
|
||||
* AppModule. EmbeddingModule hosts the vector-RAG indexer + AI_QUEUE consumer
|
||||
* (§6.7 stage D); importing it here boots the processor with the app.
|
||||
* AiAgentRolesModule supplies the role resolve path used during streaming
|
||||
* (persona + optional model override).
|
||||
*/
|
||||
@Module({
|
||||
imports: [AiModule, TokenModule, EmbeddingModule, ExternalMcpModule],
|
||||
imports: [
|
||||
AiModule,
|
||||
TokenModule,
|
||||
EmbeddingModule,
|
||||
ExternalMcpModule,
|
||||
AiAgentRolesModule,
|
||||
],
|
||||
controllers: [AiChatController],
|
||||
providers: [AiChatService, AiTranscriptionService, AiChatToolsService],
|
||||
})
|
||||
|
||||
@@ -61,6 +61,14 @@ export interface BuildSystemPromptInput {
|
||||
* used instead.
|
||||
*/
|
||||
adminPrompt?: string | null;
|
||||
/**
|
||||
* Role-specific persona fragment (from `ai_agent_roles.instructions` for the
|
||||
* chat's bound role, if any). When this is a non-blank string it REPLACES the
|
||||
* admin persona base (a role like "Proofreader" must dominate, not compete
|
||||
* with, the workspace prompt). When blank/absent the admin prompt (or default)
|
||||
* is used. The non-removable SAFETY_FRAMEWORK is appended AFTER in all cases.
|
||||
*/
|
||||
roleInstructions?: string | null;
|
||||
/**
|
||||
* The page the user is currently viewing (client-supplied), if any. When it
|
||||
* has an id, a CONTEXT line is added so the agent can resolve "this page" /
|
||||
@@ -71,19 +79,26 @@ export interface BuildSystemPromptInput {
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the agent's system prompt: the admin's configured text (or a default
|
||||
* when empty), then ALWAYS the non-removable safety framework. The admin text
|
||||
* can shape the persona but cannot strip the safety rules.
|
||||
* Compose the agent's system prompt: the persona (role instructions, else the
|
||||
* admin's configured text, else a default when empty), then ALWAYS the
|
||||
* non-removable safety framework. Neither the role nor the admin can strip the
|
||||
* safety rules — SAFETY_FRAMEWORK is appended unconditionally.
|
||||
*/
|
||||
export function buildSystemPrompt({
|
||||
workspace,
|
||||
adminPrompt,
|
||||
roleInstructions,
|
||||
openedPage,
|
||||
}: BuildSystemPromptInput): string {
|
||||
const base =
|
||||
typeof adminPrompt === 'string' && adminPrompt.trim().length > 0
|
||||
? adminPrompt.trim()
|
||||
: DEFAULT_PROMPT;
|
||||
// Persona priority: role instructions (when non-blank) REPLACE the admin
|
||||
// persona base; otherwise the admin prompt (or the default) is used. A role
|
||||
// is a narrow persona like "Proofreader" — its instructions must dominate.
|
||||
const persona =
|
||||
typeof roleInstructions === 'string' && roleInstructions.trim().length > 0
|
||||
? roleInstructions.trim()
|
||||
: typeof adminPrompt === 'string' && adminPrompt.trim().length > 0
|
||||
? adminPrompt.trim()
|
||||
: DEFAULT_PROMPT;
|
||||
|
||||
let context = workspace?.name ? `\n\nWorkspace: ${workspace.name}.` : '';
|
||||
|
||||
@@ -100,5 +115,5 @@ export function buildSystemPrompt({
|
||||
context += `\nThe user is currently viewing the page "${title}" (pageId: ${pageId.trim()}). When they refer to "this page", "the current page", or similar, operate on that pageId — use the read/write page tools with it.`;
|
||||
}
|
||||
|
||||
return `${base}${context}\n${SAFETY_FRAMEWORK}`;
|
||||
return `${persona}${context}\n${SAFETY_FRAMEWORK}`;
|
||||
}
|
||||
|
||||
@@ -15,15 +15,23 @@ import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.rep
|
||||
import { User, Workspace, AiChatMessage } from '@docmost/db/types/entity.types';
|
||||
import { AiChatToolsService } from './tools/ai-chat-tools.service';
|
||||
import { McpClientsService } from './external-mcp/mcp-clients.service';
|
||||
import { AiAgentRolesService } from './roles/ai-agent-roles.service';
|
||||
import { buildSystemPrompt } from './ai-chat.prompt';
|
||||
|
||||
/**
|
||||
* Payload accepted from the client `useChat` POST body. We do NOT bind a strict
|
||||
* DTO (the global ValidationPipe whitelist would strip the useChat-specific
|
||||
* fields), so this is a loose shape parsed straight off `req.body`.
|
||||
*
|
||||
* `roleId` is consumed ONLY on the first turn of a brand-new chat (chatId is
|
||||
* null) — the server persists it onto the chat row. Subsequent turns read the
|
||||
* role from the chat row, never from the body, so the role cannot be swapped
|
||||
* per-turn.
|
||||
*/
|
||||
export interface AiChatStreamBody {
|
||||
chatId?: string;
|
||||
// Bound ONCE at chat creation; ignored on later turns.
|
||||
roleId?: string | null;
|
||||
// The page the user is currently viewing (client-supplied), or null on a
|
||||
// non-page route. Used ONLY as prompt context so the agent knows what "this
|
||||
// page" refers to; the page itself is never fetched server-side here. The id
|
||||
@@ -70,6 +78,7 @@ export class AiChatService {
|
||||
private readonly aiSettings: AiSettingsService,
|
||||
private readonly tools: AiChatToolsService,
|
||||
private readonly mcpClients: McpClientsService,
|
||||
private readonly rolesService: AiAgentRolesService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -81,6 +90,73 @@ export class AiChatService {
|
||||
return this.ai.getChatModel(workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the chat language model for a turn, honouring a role's optional
|
||||
* model override. Resolves the role for THIS turn from the chat row's roleId
|
||||
* (existing chat) or the request body's roleId (new chat). The driver-switch
|
||||
* path throws AiNotConfiguredException (→ 503) with a clear, role-specific
|
||||
* message when the alternate driver is unconfigured — never a silent fallback.
|
||||
*
|
||||
* Exposed so the controller can resolve the model BEFORE res.hijack(), so a
|
||||
* misconfigured role surfaces as clean JSON 503 rather than a broken stream.
|
||||
*/
|
||||
async getChatModelForRole(
|
||||
workspaceId: string,
|
||||
roleIdFromBody: string | null | undefined,
|
||||
chatId: string | null | undefined,
|
||||
): Promise<LanguageModel> {
|
||||
let effectiveRoleId: string | null = null;
|
||||
if (chatId) {
|
||||
const chat = await this.aiChatRepo.findById(chatId, workspaceId);
|
||||
if (chat) {
|
||||
// For an existing chat, the role is fixed on the chat row — read it
|
||||
// there, never from the request body (the role cannot be swapped
|
||||
// per-turn). findByIdForResolve returns the role even if disabled or
|
||||
// soft-deleted, so existing chats keep their persona/model.
|
||||
effectiveRoleId = chat.roleId ?? null;
|
||||
if (effectiveRoleId) {
|
||||
const role = await this.rolesService.findByIdForResolve(
|
||||
effectiveRoleId,
|
||||
workspaceId,
|
||||
);
|
||||
if (!role?.modelConfig) return this.ai.getChatModel(workspaceId);
|
||||
return this.ai.getChatModel(workspaceId, role.modelConfig);
|
||||
}
|
||||
return this.ai.getChatModel(workspaceId);
|
||||
}
|
||||
// Stale chatId: stream() will create a new chat, so fall through to the
|
||||
// new-chat resolution path using body.roleId.
|
||||
}
|
||||
|
||||
// New chat (or stale chatId): mirror stream()'s picker-eligibility gate so
|
||||
// a stale disabled/deleted role id is silently ignored here too — otherwise
|
||||
// the controller would throw 503 on an alt-driver override while stream()
|
||||
// proceeds without the role, diverging model and persona.
|
||||
effectiveRoleId = roleIdFromBody || null;
|
||||
if (effectiveRoleId) {
|
||||
const eligible = await this.rolesService.findByIdForPicker(
|
||||
effectiveRoleId,
|
||||
workspaceId,
|
||||
);
|
||||
if (!eligible) effectiveRoleId = null;
|
||||
}
|
||||
if (!effectiveRoleId) {
|
||||
return this.ai.getChatModel(workspaceId);
|
||||
}
|
||||
|
||||
// Once gated as picker-eligible, use the full resolve view: the role is
|
||||
// enabled and live, so findByIdForResolve === findByIdForPicker here, but
|
||||
// findByIdForResolve returns the model_config-bearing row.
|
||||
const role = await this.rolesService.findByIdForResolve(
|
||||
effectiveRoleId,
|
||||
workspaceId,
|
||||
);
|
||||
if (!role?.modelConfig) {
|
||||
return this.ai.getChatModel(workspaceId);
|
||||
}
|
||||
return this.ai.getChatModel(workspaceId, role.modelConfig);
|
||||
}
|
||||
|
||||
async stream({
|
||||
user,
|
||||
workspace,
|
||||
@@ -101,9 +177,27 @@ export class AiChatService {
|
||||
}
|
||||
}
|
||||
if (!chatId) {
|
||||
// roleId is bound ONCE at chat creation (client sends it on the first
|
||||
// turn of a new chat). Subsequent turns read it from the chat row below.
|
||||
// Normalize with || (not ??) so a stray empty string also maps to null
|
||||
// (the uuid column rejects ""; belt-and-braces with the client coercion).
|
||||
let effectiveRoleId: string | null = body.roleId || null;
|
||||
// Verify the role is picker-eligible (enabled, not soft-deleted, in this
|
||||
// workspace). A stray id is either a stale client or a malicious one; the
|
||||
// picker would never have offered a disabled/deleted role, so silently
|
||||
// fall back to the universal assistant rather than throwing (avoids
|
||||
// erroring an otherwise-legitimate new chat).
|
||||
if (effectiveRoleId) {
|
||||
const eligible = await this.rolesService.findByIdForPicker(
|
||||
effectiveRoleId,
|
||||
workspace.id,
|
||||
);
|
||||
if (!eligible) effectiveRoleId = null;
|
||||
}
|
||||
const chat = await this.aiChatRepo.insert({
|
||||
creatorId: user.id,
|
||||
workspaceId: workspace.id,
|
||||
roleId: effectiveRoleId,
|
||||
});
|
||||
chatId = chat.id;
|
||||
isNewChat = true;
|
||||
@@ -143,9 +237,29 @@ export class AiChatService {
|
||||
// The model is resolved by the controller before hijack (clean 503 path).
|
||||
// Here we only need the admin-configured system prompt.
|
||||
const resolved = await this.aiSettings.resolve(workspace.id);
|
||||
|
||||
// Resolve the role's persona for THIS turn. For an existing chat the role is
|
||||
// fixed on the chat row (read there, never from the request body, so it
|
||||
// cannot be swapped per-turn); for a brand-new chat the role was just bound
|
||||
// above from body.roleId. findByIdForResolve returns the role even if
|
||||
// disabled or soft-deleted, so existing chats keep their persona.
|
||||
let roleInstructions: string | null = null;
|
||||
{
|
||||
const chat = await this.aiChatRepo.findById(chatId, workspace.id);
|
||||
const roleId = chat?.roleId ?? null;
|
||||
if (roleId) {
|
||||
const role = await this.rolesService.findByIdForResolve(
|
||||
roleId,
|
||||
workspace.id,
|
||||
);
|
||||
if (role) roleInstructions = role.instructions;
|
||||
}
|
||||
}
|
||||
|
||||
const system = buildSystemPrompt({
|
||||
workspace,
|
||||
adminPrompt: resolved?.systemPrompt,
|
||||
roleInstructions,
|
||||
openedPage: body.openPage,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
|
||||
import { AuthUser } from '../../../common/decorators/auth-user.decorator';
|
||||
import { AuthWorkspace } from '../../../common/decorators/auth-workspace.decorator';
|
||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import WorkspaceAbilityFactory from '../../casl/abilities/workspace-ability.factory';
|
||||
import {
|
||||
WorkspaceCaslAction,
|
||||
WorkspaceCaslSubject,
|
||||
} from '../../casl/interfaces/workspace-ability.type';
|
||||
import { AiAgentRolesService } from './ai-agent-roles.service';
|
||||
import { CreateAgentRoleDto } from './dto/create-agent-role.dto';
|
||||
import { UpdateAgentRoleDto } from './dto/update-agent-role.dto';
|
||||
import { AgentRoleIdDto } from './dto/agent-role-id.dto';
|
||||
|
||||
/**
|
||||
* Agent roles management (admin CRUD + member-readable picker list). Mounted at
|
||||
* `/workspace/ai-agent-roles` alongside `ai-mcp-servers` (both are admin AI
|
||||
* settings). Routes are POST to match this codebase's convention.
|
||||
*
|
||||
* CRITICAL access asymmetry:
|
||||
* - `list` (the base POST) returns the PICKER view and is open to ANY workspace
|
||||
* member — otherwise a non-admin could not pick a role when starting a chat.
|
||||
* The picker view deliberately omits `instructions` (admin-authored trusted
|
||||
* content) so it is safe to expose to non-admins.
|
||||
* - `admin-list` / `create` / `update` / `delete` are admin-only (the same gate
|
||||
* as `/workspace/update` and the AI provider settings).
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('workspace/ai-agent-roles')
|
||||
export class AiAgentRolesController {
|
||||
constructor(
|
||||
private readonly rolesService: AiAgentRolesService,
|
||||
private readonly workspaceAbility: WorkspaceAbilityFactory,
|
||||
) {}
|
||||
|
||||
private assertAdmin(user: User, workspace: Workspace): void {
|
||||
const ability = this.workspaceAbility.createForUser(user, workspace);
|
||||
if (
|
||||
ability.cannot(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Settings)
|
||||
) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Picker list — open to ANY workspace member (no assertAdmin). Returns only
|
||||
* id/name/emoji/description; never `instructions` (so it is safe for
|
||||
* non-admins to read).
|
||||
*/
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post()
|
||||
async list(@AuthWorkspace() workspace: Workspace) {
|
||||
return this.rolesService.listForPicker(workspace.id);
|
||||
}
|
||||
|
||||
/** Admin list — full view including `instructions` + `modelConfig`. */
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('admin-list')
|
||||
async adminList(
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
this.assertAdmin(user, workspace);
|
||||
return this.rolesService.listForAdmin(workspace.id);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('create')
|
||||
async create(
|
||||
@Body() dto: CreateAgentRoleDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
this.assertAdmin(user, workspace);
|
||||
return this.rolesService.create(workspace.id, user.id, dto);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('update')
|
||||
async update(
|
||||
@Body() idDto: AgentRoleIdDto,
|
||||
@Body() dto: UpdateAgentRoleDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
this.assertAdmin(user, workspace);
|
||||
return this.rolesService.update(workspace.id, idDto.id, dto);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('delete')
|
||||
async remove(
|
||||
@Body() idDto: AgentRoleIdDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
this.assertAdmin(user, workspace);
|
||||
return this.rolesService.remove(workspace.id, idDto.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AiAgentRolesController } from './ai-agent-roles.controller';
|
||||
import { AiAgentRolesService } from './ai-agent-roles.service';
|
||||
|
||||
/**
|
||||
* Agent roles unit: reusable personas that customize the agent's system-prompt
|
||||
* persona (and optionally the model) for chats bound to a role.
|
||||
*
|
||||
* AiAgentRoleRepo (DatabaseModule, global) and WorkspaceAbilityFactory
|
||||
* (CaslModule, global) are resolved without explicit imports. The service is
|
||||
* exported so AiChatService can resolve a role's instructions/model during
|
||||
* streaming.
|
||||
*/
|
||||
@Module({
|
||||
controllers: [AiAgentRolesController],
|
||||
providers: [AiAgentRolesService],
|
||||
exports: [AiAgentRolesService],
|
||||
})
|
||||
export class AiAgentRolesModule {}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { AiAgentRoleRepo } from '@docmost/db/repos/ai-chat/ai-agent-role.repo';
|
||||
import { AiAgentRole } from '@docmost/db/types/entity.types';
|
||||
import { RoleModelConfig } from '@docmost/db/types/ai-agent-roles.types';
|
||||
import { CreateAgentRoleDto } from './dto/create-agent-role.dto';
|
||||
import { UpdateAgentRoleDto } from './dto/update-agent-role.dto';
|
||||
|
||||
/**
|
||||
* Public (picker) view of a role. SECURITY: this shape intentionally excludes
|
||||
* `instructions` and `modelConfig` — it is the only shape reachable by non-admin
|
||||
* workspace members (the chat-start picker). A non-admin must be able to list
|
||||
* roles to pick one when starting a chat, but must not read the instructions.
|
||||
*/
|
||||
export interface AgentRolePickerView {
|
||||
id: string;
|
||||
name: string;
|
||||
emoji: string | null;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-facing view of a role. Includes `instructions` and `modelConfig` (the
|
||||
* fields only an admin should read/edit). Admin-only.
|
||||
*/
|
||||
export interface AgentRoleAdminView {
|
||||
id: string;
|
||||
name: string;
|
||||
emoji: string | null;
|
||||
description: string | null;
|
||||
instructions: string;
|
||||
modelConfig: RoleModelConfig | null;
|
||||
enabled: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin business logic for agent roles: CRUD plus the resolve path used by the
|
||||
* stream. Roles are workspace-scoped; soft-deleted roles stay readable for the
|
||||
* resolve path so existing chats keep their persona.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AiAgentRolesService {
|
||||
constructor(private readonly repo: AiAgentRoleRepo) {}
|
||||
|
||||
/**
|
||||
* Picker list (all workspace members). Returns only enabled, non-deleted
|
||||
* roles, projected to the picker view (NO instructions).
|
||||
*/
|
||||
async listForPicker(workspaceId: string): Promise<AgentRolePickerView[]> {
|
||||
const rows = await this.repo.listForPicker(workspaceId);
|
||||
return rows.map((r) => this.toPickerView(r));
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin list. Returns all non-deleted roles (including disabled), projected to
|
||||
* the full admin view (with instructions + modelConfig).
|
||||
*/
|
||||
async listForAdmin(workspaceId: string): Promise<AgentRoleAdminView[]> {
|
||||
const rows = await this.repo.listForAdmin(workspaceId);
|
||||
return rows.map((r) => this.toAdminView(r));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a role for the stream path: returns the role INCLUDING soft-deleted
|
||||
* and disabled rows, so existing chats keep their persona. Returns undefined
|
||||
* only when the row is gone (hard delete already nulled the chat's roleId via
|
||||
* ON DELETE SET NULL).
|
||||
*/
|
||||
async findByIdForResolve(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
): Promise<AiAgentRole | undefined> {
|
||||
return this.repo.findByIdForResolve(id, workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Picker-eligibility check: returns the role ONLY when enabled, not
|
||||
* soft-deleted, and in this workspace. Used to validate a client-supplied
|
||||
* roleId at new-chat creation so a disabled/soft-deleted role cannot be bound
|
||||
* to a fresh chat. Returns undefined otherwise.
|
||||
*/
|
||||
async findByIdForPicker(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
): Promise<AiAgentRole | undefined> {
|
||||
return this.repo.findByIdEnabled(id, workspaceId);
|
||||
}
|
||||
|
||||
async create(
|
||||
workspaceId: string,
|
||||
creatorId: string,
|
||||
dto: CreateAgentRoleDto,
|
||||
): Promise<AgentRoleAdminView> {
|
||||
const row = await this.repo.insert({
|
||||
workspaceId,
|
||||
creatorId,
|
||||
name: dto.name.trim(),
|
||||
emoji: dto.emoji?.trim() || null,
|
||||
description: dto.description?.trim() || null,
|
||||
instructions: dto.instructions.trim(),
|
||||
modelConfig: normalizeModelConfig(dto.modelConfig),
|
||||
enabled: dto.enabled ?? true,
|
||||
});
|
||||
return this.toAdminView(row);
|
||||
}
|
||||
|
||||
async update(
|
||||
workspaceId: string,
|
||||
id: string,
|
||||
dto: UpdateAgentRoleDto,
|
||||
): Promise<AgentRoleAdminView> {
|
||||
const existing = await this.repo.findById(id, workspaceId);
|
||||
if (!existing) {
|
||||
throw new BadRequestException('Agent role not found');
|
||||
}
|
||||
|
||||
await this.repo.update(id, workspaceId, {
|
||||
name: dto.name !== undefined ? dto.name.trim() : undefined,
|
||||
emoji: dto.emoji !== undefined ? dto.emoji.trim() || null : undefined,
|
||||
description:
|
||||
dto.description !== undefined
|
||||
? dto.description.trim() || null
|
||||
: undefined,
|
||||
instructions:
|
||||
dto.instructions !== undefined ? dto.instructions.trim() : undefined,
|
||||
// `modelConfig` is a nested object on the DTO; normalize it only when the
|
||||
// key was present in the patch. The DTO loader turns a raw null/undefined
|
||||
// into undefined here, so we distinguish "leave unchanged" (key absent)
|
||||
// from "clear" (null). ValidationPipe delivers the validated DTO value
|
||||
// as-is for present keys.
|
||||
modelConfig:
|
||||
dto.modelConfig === undefined
|
||||
? undefined
|
||||
: normalizeModelConfig(dto.modelConfig),
|
||||
enabled: dto.enabled,
|
||||
});
|
||||
|
||||
const updated = await this.repo.findById(id, workspaceId);
|
||||
return this.toAdminView(updated as AiAgentRole);
|
||||
}
|
||||
|
||||
async remove(workspaceId: string, id: string): Promise<{ success: true }> {
|
||||
await this.repo.softDelete(id, workspaceId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// --- internals ---
|
||||
|
||||
private toPickerView(row: AiAgentRole): AgentRolePickerView {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
emoji: row.emoji,
|
||||
description: row.description,
|
||||
};
|
||||
}
|
||||
|
||||
private toAdminView(row: AiAgentRole): AgentRoleAdminView {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
emoji: row.emoji,
|
||||
description: row.description,
|
||||
instructions: row.instructions,
|
||||
modelConfig: row.modelConfig,
|
||||
enabled: row.enabled,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the model config for storage. Returns null when there is no override
|
||||
* (so the column reads as the workspace default). When a config object is
|
||||
* present, `chatModel` is required (enforced by the DTO) and `driver` is kept
|
||||
* only when set.
|
||||
*/
|
||||
function normalizeModelConfig(
|
||||
config:
|
||||
| { driver?: string; chatModel?: string }
|
||||
| null
|
||||
| undefined,
|
||||
): RoleModelConfig | null {
|
||||
if (!config) return null;
|
||||
const chatModel = typeof config.chatModel === 'string' ? config.chatModel.trim() : '';
|
||||
if (!chatModel) return null;
|
||||
const out: RoleModelConfig = { chatModel };
|
||||
const driver =
|
||||
typeof config.driver === 'string' ? config.driver.trim() : '';
|
||||
if (driver === 'openai' || driver === 'gemini' || driver === 'ollama') {
|
||||
out.driver = driver;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
/** Path param for the per-role routes (update/delete). */
|
||||
export class AgentRoleIdDto {
|
||||
@IsString()
|
||||
id: string;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
/** Allowed model override drivers (mirrors the workspace provider drivers). */
|
||||
export const ROLE_DRIVERS = ['openai', 'gemini', 'ollama'] as const;
|
||||
export type RoleDriver = (typeof ROLE_DRIVERS)[number];
|
||||
|
||||
/**
|
||||
* Optional model override on a role. When `driver` differs from the workspace's
|
||||
* configured driver, the role's chats use that driver's credentials (loaded
|
||||
* from ai_provider_credentials). `chatModel` is always required when
|
||||
* `modelConfig` is provided.
|
||||
*/
|
||||
export class RoleModelConfigDto {
|
||||
@IsOptional()
|
||||
@IsIn(ROLE_DRIVERS)
|
||||
driver?: RoleDriver;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
chatModel: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin create payload for an agent role. The global ValidationPipe runs with
|
||||
* `whitelist: true`, so unknown fields are stripped.
|
||||
*
|
||||
* SECURITY: `instructions` is admin-authored trusted content that only enters
|
||||
* the system prompt of chats in this workspace. It is never returned to
|
||||
* non-admin (picker) clients.
|
||||
*/
|
||||
export class CreateAgentRoleDto {
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
emoji?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
description?: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(8000)
|
||||
instructions: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
@ValidateNested()
|
||||
@Type(() => RoleModelConfigDto)
|
||||
modelConfig?: RoleModelConfigDto;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enabled?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ROLE_DRIVERS, RoleDriver } from './create-agent-role.dto';
|
||||
|
||||
/**
|
||||
* Optional model override update payload. Every field is optional (partial
|
||||
* update). Passing `modelConfig: null` clears the override; omitting it leaves
|
||||
* the stored override unchanged.
|
||||
*/
|
||||
export class UpdateRoleModelConfigDto {
|
||||
@IsOptional()
|
||||
@IsIn(ROLE_DRIVERS)
|
||||
driver?: RoleDriver;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
chatModel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin update payload for an agent role. Every field is optional (partial
|
||||
* update). The global ValidationPipe runs with `whitelist: true`.
|
||||
*/
|
||||
export class UpdateAgentRoleDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
emoji?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(8000)
|
||||
instructions?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
@ValidateNested()
|
||||
@Type(() => UpdateRoleModelConfigDto)
|
||||
modelConfig?: UpdateRoleModelConfigDto;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enabled?: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user