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:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user