Merge gitea/develop into feat/public-share-assistant
Resolve conflicts with the independently-merged ai-agent-roles feature: - ai-chat.module.ts: keep BOTH AiAgentRolesModule and the public-share wiring (Share/Search modules, PublicShareChatController, services). - ai.service.ts: take develop's getChatModel ChatModelOverride superset, which already covers the public-share model-id-only override. 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,85 @@
|
||||
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();
|
||||
|
||||
// A role name is unique per workspace. Partial (WHERE deleted_at IS NULL) so a
|
||||
// soft-deleted role does not block re-creating a role with the same name.
|
||||
await db.schema
|
||||
.createIndex('ai_agent_roles_workspace_id_name_unique')
|
||||
.ifNotExists()
|
||||
.on('ai_agent_roles')
|
||||
.columns(['workspace_id', 'name'])
|
||||
.unique()
|
||||
.where(sql.ref('deleted_at'), 'is', null)
|
||||
.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
|
||||
.dropIndex('ai_agent_roles_workspace_id_name_unique')
|
||||
.ifExists()
|
||||
.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,38 @@ 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.
|
||||
// A DISABLED role (enabled=false) is likewise excluded: resolveRoleForRequest
|
||||
// downgrades such a chat to the universal assistant, so the badge must not
|
||||
// advertise a role that is not actually applied.
|
||||
const query = this.db
|
||||
.selectFrom('aiChats')
|
||||
.leftJoin('aiAgentRoles', (join) =>
|
||||
join
|
||||
.onRef('aiAgentRoles.id', '=', 'aiChats.roleId')
|
||||
.on('aiAgentRoles.deletedAt', 'is', null)
|
||||
.on('aiAgentRoles.enabled', '=', true),
|
||||
)
|
||||
.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),
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { PageEmbeddingRepo } from './page-embedding.repo';
|
||||
import type { KyselyDB } from '../../types/kysely.types';
|
||||
|
||||
/**
|
||||
* Unit test for the pure access-scoping branch of searchByEmbedding: when the
|
||||
* caller has NO accessible spaces (`spaceIds` empty), the method must early-
|
||||
* return [] WITHOUT touching the database. We inject a db whose query builder
|
||||
* throws if invoked, so any DB access fails the test.
|
||||
*
|
||||
* NOTE: the dimension-mixing case (filter by model_dimensions) needs a live
|
||||
* pgvector-enabled Postgres and is intentionally NOT covered here — it requires
|
||||
* a real DB and is out of scope for this pure unit test.
|
||||
*/
|
||||
describe('PageEmbeddingRepo.searchByEmbedding', () => {
|
||||
it('early-returns [] for empty spaceIds without any DB call', async () => {
|
||||
const throwingDb = {
|
||||
selectFrom: () => {
|
||||
throw new Error('DB should not be queried for empty spaceIds');
|
||||
},
|
||||
} as unknown as KyselyDB;
|
||||
|
||||
const repo = new PageEmbeddingRepo(throwingDb);
|
||||
const result = await repo.searchByEmbedding('ws-1', [0.1, 0.2, 0.3], [], 10);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -672,4 +672,58 @@ export class PageRepo {
|
||||
.execute()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole space tree (all root pages and their descendants) in a single
|
||||
* recursive query. Mirrors getPageAndDescendants but seeded by every root
|
||||
* page of the space (parentPageId IS NULL) instead of a single parent.
|
||||
*/
|
||||
async getSpaceDescendants(
|
||||
spaceId: string,
|
||||
opts: { includeContent: boolean },
|
||||
) {
|
||||
return this.db
|
||||
.withRecursive('page_hierarchy', (db) =>
|
||||
db
|
||||
.selectFrom('pages')
|
||||
.select([
|
||||
'id',
|
||||
'slugId',
|
||||
'title',
|
||||
'icon',
|
||||
'position',
|
||||
'parentPageId',
|
||||
'spaceId',
|
||||
'workspaceId',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
])
|
||||
.$if(opts?.includeContent, (qb) => qb.select('content'))
|
||||
.where('spaceId', '=', spaceId)
|
||||
.where('parentPageId', 'is', null)
|
||||
.where('deletedAt', 'is', null)
|
||||
.unionAll((exp) =>
|
||||
exp
|
||||
.selectFrom('pages as p')
|
||||
.select([
|
||||
'p.id',
|
||||
'p.slugId',
|
||||
'p.title',
|
||||
'p.icon',
|
||||
'p.position',
|
||||
'p.parentPageId',
|
||||
'p.spaceId',
|
||||
'p.workspaceId',
|
||||
'p.createdAt',
|
||||
'p.updatedAt',
|
||||
])
|
||||
.$if(opts?.includeContent, (qb) => qb.select('p.content'))
|
||||
.innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id')
|
||||
.where('p.deletedAt', 'is', null),
|
||||
),
|
||||
)
|
||||
.selectFrom('page_hierarchy')
|
||||
.selectAll()
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
|
||||
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