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>
189 lines
6.5 KiB
TypeScript
189 lines
6.5 KiB
TypeScript
import { Global, Logger, Module, OnApplicationBootstrap } from '@nestjs/common';
|
|
import { InjectKysely, KyselyModule } from 'nestjs-kysely';
|
|
import { EnvironmentService } from '../integrations/environment/environment.service';
|
|
import { CamelCasePlugin, LogEvent, sql } from 'kysely';
|
|
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
|
|
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
|
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
|
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
|
|
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
|
|
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
|
import { PageRepo } from './repos/page/page.repo';
|
|
import { PagePermissionRepo } from './repos/page/page-permission.repo';
|
|
import { CommentRepo } from './repos/comment/comment.repo';
|
|
import { PageTransclusionsRepo } from './repos/page-transclusions/page-transclusions.repo';
|
|
import { PageTransclusionReferencesRepo } from './repos/page-transclusions/page-transclusion-references.repo';
|
|
import { PageHistoryRepo } from './repos/page/page-history.repo';
|
|
import { AttachmentRepo } from './repos/attachment/attachment.repo';
|
|
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
|
import * as process from 'node:process';
|
|
import { MigrationService } from '@docmost/db/services/migration.service';
|
|
import { UserTokenRepo } from './repos/user-token/user-token.repo';
|
|
import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo';
|
|
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
|
|
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
|
import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
|
|
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
|
|
import { LabelRepo } from '@docmost/db/repos/label/label.repo';
|
|
import { FavoriteRepo } from '@docmost/db/repos/favorite/favorite.repo';
|
|
import { TemplateRepo } from '@docmost/db/repos/template/template.repo';
|
|
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';
|
|
import * as postgres from 'postgres';
|
|
import { normalizePostgresUrl } from '../common/helpers';
|
|
|
|
@Global()
|
|
@Module({
|
|
imports: [
|
|
KyselyModule.forRootAsync({
|
|
imports: [],
|
|
inject: [EnvironmentService],
|
|
useFactory: (environmentService: EnvironmentService) => ({
|
|
dialect: new PostgresJSDialect({
|
|
postgres: postgres(
|
|
normalizePostgresUrl(environmentService.getDatabaseURL()),
|
|
{
|
|
max: environmentService.getDatabaseMaxPool(),
|
|
onnotice: () => {},
|
|
types: {
|
|
bigint: {
|
|
to: 20,
|
|
from: [20, 1700],
|
|
serialize: (value: number) => value.toString(),
|
|
parse: (value: string) => Number.parseInt(value),
|
|
},
|
|
},
|
|
},
|
|
),
|
|
}),
|
|
plugins: [new CamelCasePlugin()],
|
|
log: (event: LogEvent) => {
|
|
if (environmentService.getNodeEnv() !== 'development') return;
|
|
const logger = new Logger(DatabaseModule.name);
|
|
if (process.env.DEBUG_DB?.toLowerCase() === 'true') {
|
|
logger.debug(event.query.sql);
|
|
logger.debug('query time: ' + event.queryDurationMillis + ' ms');
|
|
}
|
|
},
|
|
}),
|
|
}),
|
|
],
|
|
providers: [
|
|
MigrationService,
|
|
WorkspaceRepo,
|
|
UserRepo,
|
|
GroupRepo,
|
|
GroupUserRepo,
|
|
SpaceRepo,
|
|
SpaceMemberRepo,
|
|
PageRepo,
|
|
PagePermissionRepo,
|
|
PageTransclusionsRepo,
|
|
PageTransclusionReferencesRepo,
|
|
PageHistoryRepo,
|
|
CommentRepo,
|
|
FavoriteRepo,
|
|
AttachmentRepo,
|
|
UserTokenRepo,
|
|
UserSessionRepo,
|
|
BacklinkRepo,
|
|
ShareRepo,
|
|
NotificationRepo,
|
|
WatcherRepo,
|
|
LabelRepo,
|
|
TemplateRepo,
|
|
AiChatRepo,
|
|
AiChatMessageRepo,
|
|
AiProviderCredentialsRepo,
|
|
AiMcpServerRepo,
|
|
AiAgentRoleRepo,
|
|
PageEmbeddingRepo,
|
|
PageListener,
|
|
],
|
|
exports: [
|
|
WorkspaceRepo,
|
|
UserRepo,
|
|
GroupRepo,
|
|
GroupUserRepo,
|
|
SpaceRepo,
|
|
SpaceMemberRepo,
|
|
PageRepo,
|
|
PagePermissionRepo,
|
|
PageTransclusionsRepo,
|
|
PageTransclusionReferencesRepo,
|
|
PageHistoryRepo,
|
|
CommentRepo,
|
|
FavoriteRepo,
|
|
AttachmentRepo,
|
|
UserTokenRepo,
|
|
UserSessionRepo,
|
|
BacklinkRepo,
|
|
ShareRepo,
|
|
NotificationRepo,
|
|
WatcherRepo,
|
|
LabelRepo,
|
|
TemplateRepo,
|
|
AiChatRepo,
|
|
AiChatMessageRepo,
|
|
AiProviderCredentialsRepo,
|
|
AiMcpServerRepo,
|
|
AiAgentRoleRepo,
|
|
PageEmbeddingRepo,
|
|
],
|
|
})
|
|
export class DatabaseModule implements OnApplicationBootstrap {
|
|
private readonly logger = new Logger(DatabaseModule.name);
|
|
|
|
constructor(
|
|
@InjectKysely() private readonly db: KyselyDB,
|
|
private readonly migrationService: MigrationService,
|
|
private readonly environmentService: EnvironmentService,
|
|
) {}
|
|
|
|
async onApplicationBootstrap() {
|
|
await this.establishConnection();
|
|
|
|
if (this.environmentService.getNodeEnv() === 'production') {
|
|
await this.migrationService.migrateToLatest();
|
|
}
|
|
}
|
|
|
|
async establishConnection() {
|
|
const retryAttempts = 15;
|
|
const retryDelay = 3000;
|
|
|
|
this.logger.log('Establishing database connection');
|
|
for (let i = 0; i < retryAttempts; i++) {
|
|
try {
|
|
await sql`SELECT 1=1`.execute(this.db);
|
|
this.logger.log('Database connection successful');
|
|
break;
|
|
} catch (err) {
|
|
if (err['errors']) {
|
|
this.logger.error(err['errors'][0]);
|
|
} else {
|
|
this.logger.error(err);
|
|
}
|
|
|
|
if (i < retryAttempts - 1) {
|
|
this.logger.log(
|
|
`Retrying [${i + 1}/${retryAttempts}] in ${retryDelay / 1000} seconds`,
|
|
);
|
|
await new Promise((resolve) => setTimeout(resolve, retryDelay));
|
|
} else {
|
|
this.logger.error(
|
|
`Failed to connect to database after ${retryAttempts} attempts. Exiting...`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|