Merge remote-tracking branch 'gitea/develop' into feat/html-embed-admin

# Conflicts:
#	apps/server/src/core/workspace/services/workspace.service.ts
This commit is contained in:
claude_code
2026-06-20 20:18:44 +03:00
130 changed files with 9955 additions and 3096 deletions

View File

@@ -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;
}

View File

@@ -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),

View File

@@ -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([]);
});
});

View File

@@ -173,9 +173,23 @@ export class PageRepo {
.returning(this.baseFields)
.executeTakeFirst();
// Enrich the event with a thin node snapshot (variant A) so the WS tree
// listener can broadcast `addTreeNode` without re-reading the DB. `result`
// already comes from `returning(this.baseFields)`, so no extra query.
this.eventEmitter.emit(EventName.PAGE_CREATED, {
pageIds: [result.id],
workspaceId: result.workspaceId,
pages: [
{
id: result.id,
slugId: result.slugId,
title: result.title,
icon: result.icon,
position: result.position,
spaceId: result.spaceId,
parentPageId: result.parentPageId,
},
],
});
return result;
@@ -266,6 +280,25 @@ export class PageRepo {
): Promise<void> {
const currentDate = new Date();
// Read the root snapshot up front so PAGE_SOFT_DELETED can carry it without
// a post-commit DB read (variant A). Only the root of the deleted subtree is
// needed for the tree broadcast — the client `treeModel.remove` drops all
// descendants, so we don't snapshot/broadcast every descendant.
const rootSnapshot = await this.db
.selectFrom('pages')
.select([
'id',
'slugId',
'title',
'icon',
'position',
'spaceId',
'parentPageId',
])
.where('id', '=', pageId)
.where('deletedAt', 'is', null)
.executeTakeFirst();
const descendants = await this.db
.withRecursive('page_descendants', (db) =>
db
@@ -305,6 +338,21 @@ export class PageRepo {
this.eventEmitter.emit(EventName.PAGE_SOFT_DELETED, {
pageIds: pageIds,
workspaceId,
// Root-only snapshot: one `deleteTreeNode` is enough, the client removes
// the whole subtree. Skip if the root vanished between the two reads.
pages: rootSnapshot
? [
{
id: rootSnapshot.id,
slugId: rootSnapshot.slugId,
title: rootSnapshot.title,
icon: rootSnapshot.icon,
position: rootSnapshot.position,
spaceId: rootSnapshot.spaceId,
parentPageId: rootSnapshot.parentPageId,
},
]
: [],
});
}
}
@@ -313,7 +361,7 @@ export class PageRepo {
// First, check if the page being restored has a deleted parent
const pageToRestore = await this.db
.selectFrom('pages')
.select(['id', 'parentPageId'])
.select(['id', 'parentPageId', 'spaceId'])
.where('id', '=', pageId)
.executeTakeFirst();
@@ -372,6 +420,10 @@ export class PageRepo {
this.eventEmitter.emit(EventName.PAGE_RESTORED, {
pageIds: pageIds,
workspaceId: workspaceId,
// spaceId lets the WS listener send a space-scoped refetchRootTreeNodeEvent.
// Restore can re-attach a whole subtree, so a root refetch is simpler and
// more robust than N pointwise addTreeNode events.
spaceId: pageToRestore.spaceId,
});
}
@@ -672,4 +724,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();
}
}

View File

@@ -239,7 +239,7 @@ export class WorkspaceRepo {
// is a real jsonb object, never a double-encoded string. The CASE self-heals
// workspaces whose settings.ai.provider was previously corrupted into an
// array/string.
const ALLOWED = ['driver', 'chatModel', 'embeddingModel', 'baseUrl', 'embeddingBaseUrl', 'sttModel', 'sttBaseUrl', 'sttApiStyle', 'systemPrompt'];
const ALLOWED = ['driver', 'chatModel', 'embeddingModel', 'baseUrl', 'embeddingBaseUrl', 'sttModel', 'sttBaseUrl', 'sttApiStyle', 'systemPrompt', 'publicShareChatModel', 'publicShareAssistantRoleId'];
const entries = Object.entries(provider).filter(
([k, v]) => v !== undefined && ALLOWED.includes(k),
);