feat(ai-chat): per-user AI agent backend — LLM config, read-only agent, provenance schema

WIP checkpoint of the gitmost AI-chat backend (plan stages A + B1 + B3a).
The agent acts under the requesting user's JWT (Docmost CASL enforces page
access); the external service-account /mcp endpoint is untouched.

LLM provider config (A2-A4):
- integrations/crypto: AES-256-GCM SecretBoxService (key derived from APP_SECRET,
  per-record salt/iv; clear error on rotation instead of crashing).
- ai_provider_credentials table/repo/types: encrypted API key stored outside
  workspace settings/baseFields, write-only (never returned by any endpoint).
- integrations/ai: per-workspace AI SDK v6 provider driver (openai/gemini/ollama),
  admin-gated GET(masked)/PATCH(write-only key)/Test endpoints; settings.ai.provider
  holds non-secret config incl. systemPrompt. Removed unused AI_* env getters (DB is
  the single source of truth).

Chat module (A1, A5-A8):
- ai_chats/ai_chat_messages repos (workspace-scoped, soft-delete, tsv never selected).
- core/ai-chat: CRUD + POST /ai-chat/stream (Fastify hijack + AI SDK v6
  pipeUIMessageStreamToResponse, abort on disconnect, persist user/assistant msgs).
- Agent loop: streamText + stepCountIs(8); read tools searchPages/getPage via a
  per-request DocmostClient over loopback REST under the user's minted access token.
- Gate settings.ai.chat (+ 503 when provider unconfigured); buildSystemPrompt with a
  non-removable safety/anti-prompt-injection framework. Per-user rate limit.

Per-user auth (B1):
- @docmost/mcp DocmostClient gains an additive getToken variant (carry a user JWT,
  re-fetch on 401) and exports DocmostClient; the email/password service-account path
  (external /mcp, stdio) is unchanged.

Agent-edit provenance backbone (B3a):
- Migration: pages/page_history (last_updated_source, last_updated_ai_chat_id) and
  comments (created_source, ai_chat_id, resolved_source).
- Signed actor/aiChatId claim in the collab token; onAuthenticate propagates it,
  onStoreDocument writes it with a sticky agent marker, saveHistory copies it.

Migrations auto-run on boot (additive). Write tools, frontend, RAG and external MCP
servers are not in this checkpoint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
vvzvlad
2026-06-17 01:36:41 +03:00
parent 6914774ca8
commit 683da7a4c5
40 changed files with 2063 additions and 86 deletions

View File

@@ -0,0 +1,99 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
import { dbOrTx } from '../../utils';
import {
AiChatMessage,
InsertableAiChatMessage,
} from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
@Injectable()
export class AiChatMessageRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
// The `tsv` column is a trigger-maintained tsvector used only for
// full-text search. It must never be selected so it cannot leak into
// HTTP responses or the chat history fed to the language model.
private baseFields: Array<keyof AiChatMessage> = [
'id',
'chatId',
'workspaceId',
'userId',
'role',
'content',
'toolCalls',
'metadata',
'createdAt',
'updatedAt',
'deletedAt',
];
async findByChat(
chatId: string,
workspaceId: string,
pagination?: PaginationOptions,
) {
const query = this.db
.selectFrom('aiChatMessages')
.select(this.baseFields)
.where('chatId', '=', chatId)
.where('workspaceId', '=', workspaceId)
.where('deletedAt', 'is', null);
// Default page size when no pagination options are supplied.
const perPage = pagination?.limit ?? 50;
return executeWithCursorPagination(query, {
perPage,
cursor: pagination?.cursor,
beforeCursor: pagination?.beforeCursor,
fields: [
{ expression: 'createdAt', direction: 'asc' },
{ expression: 'id', direction: 'asc' },
],
parseCursor: (cursor) => ({
createdAt: new Date(cursor.createdAt),
id: cursor.id,
}),
});
}
// Load the most RECENT `limit` messages for a chat and return them in
// ascending chronological order (oldest -> newest), as the model expects.
// `findByChat` returns the FIRST page ASC (the OLDEST messages), which loses
// recent turns once a chat grows beyond a page; this rebuilds the model
// history from the tail instead. Plain query (no cursor pagination).
async findRecent(
chatId: string,
workspaceId: string,
limit: number,
): Promise<AiChatMessage[]> {
const rows = await this.db
.selectFrom('aiChatMessages')
.select(this.baseFields)
.where('chatId', '=', chatId)
.where('workspaceId', '=', workspaceId)
.where('deletedAt', 'is', null)
.orderBy('createdAt', 'desc')
.orderBy('id', 'desc')
.limit(limit)
.execute();
// Selected newest-first for the limit; reverse to oldest-first for the model.
return rows.reverse();
}
async insert(
insertable: InsertableAiChatMessage,
trx?: KyselyTransaction,
): Promise<AiChatMessage> {
const db = dbOrTx(this.db, trx);
return db
.insertInto('aiChatMessages')
.values(insertable)
.returning(this.baseFields)
.executeTakeFirst();
}
}

View File

@@ -0,0 +1,94 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
import { dbOrTx } from '../../utils';
import {
AiChat,
InsertableAiChat,
UpdatableAiChat,
} from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
@Injectable()
export class AiChatRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
async findById(id: string, workspaceId: string): Promise<AiChat | undefined> {
return this.db
.selectFrom('aiChats')
.selectAll('aiChats')
.where('id', '=', id)
.where('workspaceId', '=', workspaceId)
.where('deletedAt', 'is', null)
.executeTakeFirst();
}
async findByCreator(
creatorId: string,
workspaceId: string,
pagination: PaginationOptions,
) {
const query = this.db
.selectFrom('aiChats')
.selectAll('aiChats')
.where('creatorId', '=', creatorId)
.where('workspaceId', '=', workspaceId)
.where('deletedAt', 'is', null);
return executeWithCursorPagination(query, {
perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [
{ expression: 'createdAt', direction: 'desc' },
{ expression: 'id', direction: 'desc' },
],
parseCursor: (cursor) => ({
createdAt: new Date(cursor.createdAt),
id: cursor.id,
}),
});
}
async insert(
insertable: InsertableAiChat,
trx?: KyselyTransaction,
): Promise<AiChat> {
const db = dbOrTx(this.db, trx);
return db
.insertInto('aiChats')
.values(insertable)
.returningAll()
.executeTakeFirst();
}
async update(
id: string,
updatable: UpdatableAiChat,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.updateTable('aiChats')
.set({ ...updatable, updatedAt: new Date() })
.where('id', '=', id)
.where('workspaceId', '=', workspaceId)
.execute();
}
async softDelete(
id: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.updateTable('aiChats')
.set({ deletedAt: new Date() })
.where('id', '=', id)
.where('workspaceId', '=', workspaceId)
.execute();
}
}

View File

@@ -0,0 +1,63 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
import { dbOrTx } from '../../utils';
import { AiProviderCredentials } from '@docmost/db/types/entity.types';
/**
* Repository for per-workspace AI provider credentials.
*
* SECURITY (D9/§8.1): rows hold encrypted provider API keys. This table must
* NEVER be added to workspace `baseFields` or returned by any workspace
* endpoint. `api_key_enc` should only be read by the AI driver layer.
*/
@Injectable()
export class AiProviderCredentialsRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
async find(
workspaceId: string,
driver: string,
): Promise<AiProviderCredentials | undefined> {
return this.db
.selectFrom('aiProviderCredentials')
.selectAll('aiProviderCredentials')
.where('workspaceId', '=', workspaceId)
.where('driver', '=', driver)
.executeTakeFirst();
}
async upsert(
workspaceId: string,
driver: string,
apiKeyEnc: string,
trx?: KyselyTransaction,
): Promise<AiProviderCredentials> {
const db = dbOrTx(this.db, trx);
return db
.insertInto('aiProviderCredentials')
.values({ workspaceId, driver, apiKeyEnc })
.onConflict((oc) =>
oc.columns(['workspaceId', 'driver']).doUpdateSet({
apiKeyEnc,
updatedAt: new Date(),
}),
)
.returningAll()
.executeTakeFirst();
}
async clearKey(
workspaceId: string,
driver: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.updateTable('aiProviderCredentials')
.set({ apiKeyEnc: null, updatedAt: new Date() })
.where('workspaceId', '=', workspaceId)
.where('driver', '=', driver)
.execute();
}
}

View File

@@ -25,6 +25,8 @@ export class PageHistoryRepo {
'icon',
'coverPhoto',
'lastUpdatedById',
'lastUpdatedSource',
'lastUpdatedAiChatId',
'contributorIds',
'spaceId',
'workspaceId',
@@ -75,6 +77,9 @@ export class PageHistoryRepo {
icon: page.icon,
coverPhoto: page.coverPhoto,
lastUpdatedById: page.lastUpdatedById ?? page.creatorId,
// Copy the provenance marker off the page row, as for lastUpdatedById.
lastUpdatedSource: page.lastUpdatedSource,
lastUpdatedAiChatId: page.lastUpdatedAiChatId,
contributorIds: opts?.contributorIds,
spaceId: page.spaceId,
workspaceId: page.workspaceId,

View File

@@ -35,6 +35,8 @@ export class PageRepo {
'parentPageId',
'creatorId',
'lastUpdatedById',
'lastUpdatedSource',
'lastUpdatedAiChatId',
'spaceId',
'workspaceId',
'isLocked',

View File

@@ -211,6 +211,36 @@ export class WorkspaceRepo {
.executeTakeFirst();
}
/**
* Deep-merge a partial provider config into the fixed path
* `settings.ai.provider`. Unlike `updateAiSettings` (single scalar key under
* `settings.ai`), this stores a nested object. The path is constant — only the
* provider value is parameterized (bound, not `sql.raw`) — so it cannot store
* a secret and is safe from injection. Sibling `settings.ai.*` keys (search /
* generative / chat / mcp / systemPrompt) and provider fields absent from the
* partial are preserved via jsonb `||` merge.
*/
async updateAiProviderSettings(
workspaceId: string,
provider: Record<string, unknown>,
trx?: KyselyTransaction,
): Promise<Workspace> {
const db = dbOrTx(this.db, trx);
const providerJson = JSON.stringify(provider);
return db
.updateTable('workspaces')
.set({
settings: sql`COALESCE(settings, '{}'::jsonb)
|| jsonb_build_object('ai', COALESCE(settings->'ai', '{}'::jsonb)
|| jsonb_build_object('provider', COALESCE(settings->'ai'->'provider', '{}'::jsonb)
|| ${providerJson}::jsonb))`,
updatedAt: new Date(),
})
.where('id', '=', workspaceId)
.returning(this.baseFields)
.executeTakeFirst();
}
async updateSharingSettings(
workspaceId: string,
prefKey: string,