Compare commits

...

4 Commits

Author SHA1 Message Date
glm5.2 agent 180
24bf0ab18f feat(ai-chat): add reusable agent roles (persona + optional model)
Roles are workspace-admin presets that customize the AI agent's system-
prompt persona and, optionally, the model, attached to a chat at creation
time. Examples: a 'Proofreader' that only touches grammar, a 'Fact-
checker' that cites web sources. A role changes ONLY instructions and
( optional ) the model; the toolset stays full, so the security boundary
(CASL via the per-user loopback token) is unchanged.

Backend:
- Migration 20260620T150000-ai-agent-roles: ai_agent_roles table
  (workspace-scoped, soft-delete, model_config jsonb) + ai_chats.role_id
  (ON DELETE SET NULL).
- AiAgentRoleRepo / AiAgentRolesService / AiAgentRolesController at
  /workspace/ai-agent-roles. LIST (picker view) is open to all workspace
  members; create/update/delete are admin-only. The picker view omits
  instructions and model_config so they never leak to non-admins.
- buildSystemPrompt: optional roleInstructions REPLACES the admin persona
  (priority order: role > admin > default). The non-removable
  SAFETY_FRAMEWORK is always appended - a role cannot strip it.
- AiChatService.stream: persists roleId on first turn; subsequent turns
  read role_id from the chat row, never from the request body. The role's
  instructions are applied even if it was later disabled or soft-deleted
  (existing chats keep their persona).
- AiService.getChatModel accepts an optional override. Same-driver
  overrides reuse the workspace key; cross-driver (openai/gemini) loads
  alternate creds from ai_provider_credentials and throws a clean 503 if
  they are missing (no silent fallback). Cross-driver ollama is rejected
  with a clear message (no per-driver ollama base URL exists yet).
- Controller resolves the role model BEFORE res.hijack so misconfigured
  overrides return JSON 503, not a broken stream.

Client:
- New chat picker (Mantine Select) lists enabled roles, default
  'Universal assistant' (roleId null). The roleId is sent only when
  starting a new chat; existing chats show the role as a fixed badge.
- Role badge in the chat window header and conversation list.
- Settings -> AI: new 'Agent roles' management section mirrors the
  external MCP servers UI (add/edit/delete + enable toggle + optional
  model override). Form fields: name, emoji, description, instructions,
  model override (driver + chatModel), with a reminder that the safety
  framework is always appended.

Hardening after review:
- Empty-string roleId coerced to null on both client and server (picker
  'Universal assistant' option used to crash the uuid INSERT).
- New-chat insert validates picker-eligibility (enabled + not soft-deleted
  + workspace-scoped); ineligible ids silently fall back to null.
- findByCreator's role JOIN is workspace-scoped and every column ref is
  table-qualified (avoids Postgres ambiguous-column errors).
- getChatModelForRole applies the same picker-eligibility gate as stream
  on the new-chat path, so model and persona resolve from one source.
2026-06-20 15:54:23 +03:00
glm5.2 agent 180
2936d16a43 docs: add history-diff performance redesign plan
Add docs/history-diff-perf-plan.md: deep-dive into the page-history
inline diff performance problem and a phased redesign.

- Root causes: O(K·D) recreateTransform (rfc6902 full-doc rebuild per op),
  full recompute on the "Highlight changes" toggle, a second full TipTap
  instance, all synchronous on the main thread.
- Fix: drop recreateTransform; diff directly via prosemirror-changeset
  (getReplaceStep + ChangeSet.addSteps/computeDiff), keeping the existing
  decoration contract for visual parity.
- Split the diff useEffect so the toggle no longer re-diffs.
- Phased plan (P0 core, P1 large-doc guard + error handling, P2 worker),
  testing/parity strategy, risks and rollback.
2026-06-20 15:43:44 +03:00
glm5.2 agent 180
ddfccb30f3 docs(backlog): add dependency update & security audit snapshot
Record outdated-deps and security-audit findings for the fork as of
2026-06-20 (pnpm outdated -r + pnpm audit --prod): 162 outdated entries,
50 major-behind, 51 vulnerabilities (16 high).

Key finding: pnpm.overrides pin several packages to versions flagged by
the audit (ws, undici, tmp, hono, protobufjs, dompurify) — cheapest fix
is bumping the pins. Also flags direct-dep highs (@nestjs/platform-fastify
auth middleware bypass, nodemailer, form-data, react-router-dom),
risky majors to schedule separately (Mantine9/React19, Hocuspocus 4,
CASL 7, TypeScript 6, zod 4, stripe), the deprecated @types/form-data,
and @types/node drift across the workspace.
2026-06-20 15:43:23 +03:00
glm5.2 agent 180
19e083596d docs: add search morphology + hybrid-search-exposure plans
Two feature plans grounded in the current develop:
- search-language-morphology-plan.md: make the FTS text-search config
  selectable (env SEARCH_TS_CONFIG, whitelist english|russian|simple|ru_en;
  recommend ru_en for the RU/EN wiki). Documents every hardcoded 'english'
  touchpoint (pages.tsv trigger, page_embeddings.fts generated column,
  attachments.tsv, search.service.ts, hybrid lexical CTE), the DDL-baked
  config constraint, reindex strategy, and the regconfig SQL-injection guard.
- hybrid-search-general-plan.md: expose the existing pgvector/RRF hybrid
  search (today agent-only via searchPages) on the user-facing /search, the
  UI, and the MCP search tool, reusing page-embedding.repo.hybridSearch with
  identical CASL/permission post-filtering and lexical fallback.
2026-06-20 15:41:54 +03:00
35 changed files with 2550 additions and 37 deletions

View File

@@ -709,6 +709,31 @@
"No tools available": "No tools available",
"Created successfully": "Created successfully",
"Deleted successfully": "Deleted successfully",
"Agent roles": "Agent roles",
"Personas": "Personas",
"Add role": "Add role",
"Edit role": "Edit role",
"Delete role": "Delete role",
"Role name": "Role name",
"Emoji": "Emoji",
"Optional. Shown next to the role name in the picker.": "Optional. Shown next to the role name in the picker.",
"Description": "Description",
"Optional. Shown in the role picker to help users choose.": "Optional. Shown in the role picker to help users choose.",
"Instructions": "Instructions",
"The agent persona. Replaces the workspace system message for chats bound to this role. A built-in safety framework is always appended.": "The agent persona. Replaces the workspace system message for chats bound to this role. A built-in safety framework is always appended.",
"Model override": "Model override",
"Optional. Use a different model for chats bound to this role.": "Optional. Use a different model for chats bound to this role.",
"Use workspace default": "Use workspace default",
"Custom model": "Custom model",
"Driver": "Driver",
"Defaults to the workspace driver. Credentials for an alternate driver must be set in the provider settings.": "Defaults to the workspace driver. Credentials for an alternate driver must be set in the provider settings.",
"No agent roles configured": "No agent roles configured",
"Reusable agent personas for specialized chats.": "Reusable agent personas for specialized chats.",
"Are you sure you want to delete this role? Existing chats keep their persona.": "Are you sure you want to delete this role? Existing chats keep their persona.",
"Disabled roles are hidden from the picker but existing chats keep using them.": "Disabled roles are hidden from the picker but existing chats keep using them.",
"Universal assistant": "Universal assistant",
"Agent role": "Agent role",
"Default workspace persona": "Default workspace persona",
"Clear": "Clear",
"Provider": "Provider",
"•••• set": "•••• set",

View File

@@ -19,3 +19,12 @@ export const aiChatWindowOpenAtom = atom<boolean>(false);
// in ChatInput's local state, that remount would wipe text the user typed while
// the agent was still streaming. Reset on deliberate chat switches.
export const aiChatDraftAtom = atom<string>("");
/**
* The role selected for the NEXT new chat (picker). Bound to the chat only on
* the first turn (sent as `roleId` in the `/ai-chat/stream` body when chatId is
* null); after that the role is fixed on the chat row and this atom is not read
* again for that chat. Reset to null (universal assistant) on "New chat". null
* means the universal assistant (no bound role).
*/
export const selectedRoleForNewChatAtom = atom(null as string | null);

View File

@@ -6,7 +6,12 @@ import {
useRef,
useState,
} from "react";
import { Group, Loader, Tooltip } from "@mantine/core";
import {
Group,
Loader,
Select,
Tooltip,
} from "@mantine/core";
import {
IconArrowsDiagonal,
IconCheck,
@@ -25,6 +30,7 @@ import {
activeAiChatIdAtom,
aiChatWindowOpenAtom,
aiChatDraftAtom,
selectedRoleForNewChatAtom,
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { extractPageSlugId } from "@/lib";
@@ -33,6 +39,7 @@ import {
useAiChatMessagesQuery,
useAiChatsQuery,
} from "@/features/ai-chat/queries/ai-chat-query.ts";
import { useAiRolesQuery } from "@/features/workspace/queries/ai-agent-roles-query.ts";
import ConversationList from "@/features/ai-chat/components/conversation-list.tsx";
import ChatThread from "@/features/ai-chat/components/chat-thread.tsx";
import { buildChatMarkdown } from "@/features/ai-chat/utils/chat-markdown.ts";
@@ -102,6 +109,13 @@ export default function AiChatWindow() {
const [windowOpen, setWindowOpen] = useAtom(aiChatWindowOpenAtom);
const [activeChatId, setActiveChatId] = useAtom(activeAiChatIdAtom);
const setDraft = useSetAtom(aiChatDraftAtom);
const [selectedRoleId, setSelectedRoleId] = useAtom(
selectedRoleForNewChatAtom,
);
// Roles for the chat-start picker (any workspace member can list). Loaded
// unconditionally so the picker is ready when the user starts a new chat.
const { data: roles } = useAiRolesQuery();
// History section starts collapsed (matches the former panel's behavior).
const [historyOpen, setHistoryOpen] = useState(false);
@@ -144,7 +158,8 @@ export default function AiChatWindow() {
setActiveChatId(null);
setHistoryOpen(false);
setDraft("");
}, [setActiveChatId, setDraft]);
setSelectedRoleId(null);
}, [setActiveChatId, setDraft, setSelectedRoleId]);
const selectChat = useCallback(
(chatId: string): void => {
@@ -342,6 +357,17 @@ export default function AiChatWindow() {
style={{ flex: "none" }}
/>
<span className={classes.title}>{t("AI chat")}</span>
{activeChat?.roleName && (
<Tooltip
label={`${activeChat.roleEmoji ?? ""} ${activeChat.roleName}`.trim()}
withArrow
>
<span className={classes.badge} title={activeChat.roleName}>
{activeChat.roleEmoji ? `${activeChat.roleEmoji} ` : ""}
{activeChat.roleName}
</span>
</Tooltip>
)}
<div style={{ flex: 1, display: "flex", justifyContent: "center" }}>
{contextTokens > 0 && (
@@ -432,6 +458,33 @@ export default function AiChatWindow() {
)}
</div>
{/* Role picker for a NEW chat only. The role is bound once at chat
creation; for an existing chat the role is fixed on the chat row and
shown as a badge in the drag bar above. Hidden when no roles exist. */}
{activeChatId === null && roles && roles.length > 0 && (
<div style={{ padding: "0 8px" }}>
<Select
size="xs"
label={t("Agent role")}
value={selectedRoleId ?? ""}
onChange={(value) => setSelectedRoleId(value || null)}
data={[
{ value: "", label: t("Universal assistant") },
...roles.map((r) => ({
value: r.id,
label: `${r.emoji ? `${r.emoji} ` : ""}${r.name}`,
})),
]}
allowDeselect={false}
searchable={false}
description={
roles.find((r) => r.id === selectedRoleId)?.description ??
t("Default workspace persona")
}
/>
</div>
)}
{/* body: active chat thread */}
<div className={classes.body}>
{waitingForHistory ? (

View File

@@ -5,10 +5,12 @@ import { IconAlertTriangle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useChat, type UIMessage } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { useAtomValue } from "jotai";
import MessageList from "@/features/ai-chat/components/message-list.tsx";
import ChatInput from "@/features/ai-chat/components/chat-input.tsx";
import { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
import { selectedRoleForNewChatAtom } from "@/features/ai-chat/atoms/ai-chat-atom.ts";
import classes from "@/features/ai-chat/components/ai-chat.module.css";
/** The page the user is currently viewing, sent as chat context. */
@@ -64,6 +66,7 @@ export default function ChatThread({
onTurnFinished,
}: ChatThreadProps) {
const { t } = useTranslation();
const selectedRoleId = useAtomValue(selectedRoleForNewChatAtom);
const initialMessages = useMemo<UIMessage[]>(
() => (initialRows ?? []).map(rowToUiMessage),
@@ -84,6 +87,13 @@ export default function ChatThread({
const openPageRef = useRef<OpenPageContext | null>(openPage ?? null);
openPageRef.current = openPage ?? null;
// The role for a NEW chat is read live from the jotai atom via a ref, so a
// change of selection does NOT recreate the transport mid-stream. Only sent on
// the first turn of a new chat (chatId null); for an existing chat the server
// reads the role from the chat row, never from the body.
const roleIdRef = useRef<string | null>(selectedRoleId);
roleIdRef.current = selectedRoleId;
// Stable `useChat` store key for the lifetime of THIS mount.
//
// CRITICAL: `useChat` (@ai-sdk/react) re-creates its internal `Chat` store
@@ -109,15 +119,16 @@ export default function ChatThread({
new DefaultChatTransport<UIMessage>({
api: "/api/ai-chat/stream",
credentials: "include",
// Inject the chat id and the currently-open page alongside the useChat
// messages so the server can resolve an existing chat (or create one
// when null) and tell the agent which page "this page" refers to. Both
// are read live from refs so changing chats/pages does NOT recreate the
// transport. `openPage` is null on a non-page route.
// Inject the chat id, role (new chats only), and the currently-open
// page alongside the useChat messages so the server can resolve an
// existing chat (or create one when null) and tell the agent which page
// "this page" refers to. roleId is bound ONCE at chat creation; for an
// existing chat the server ignores it and reads the role from the row.
prepareSendMessagesRequest: ({ messages, body }) => ({
body: {
...body,
chatId: chatIdRef.current,
roleId: chatIdRef.current ? null : roleIdRef.current,
openPage: openPageRef.current,
messages,
},

View File

@@ -117,9 +117,12 @@ export default function ConversationList({
)}
onClick={() => onSelect(chat.id)}
>
<Text size="sm" lineClamp={1} style={{ flex: 1 }}>
{chat.title || t("Untitled chat")}
</Text>
<Group gap="xs" wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
{chat.roleEmoji && <span aria-hidden>{chat.roleEmoji}</span>}
<Text size="sm" lineClamp={1} style={{ flex: 1 }}>
{chat.title || t("Untitled chat")}
</Text>
</Group>
<Menu shadow="md" width={180} position="bottom-end">
<Menu.Target>
<ActionIcon

View File

@@ -4,17 +4,54 @@ import type { UIMessage } from "@ai-sdk/react";
/**
* A persisted chat row (mirrors the server `ai_chats` selectAll shape returned
* by `POST /ai-chat/chats`). Only the fields the UI reads are typed.
*
* `roleId`/`roleName`/`roleEmoji` come from the list endpoint (the server JOINs
* `ai_agent_roles` for the badge — list is not a hot path, so no denormalization).
* All three are null/absent for a universal-assistant chat (no bound role).
*/
export interface IAiChat {
id: string;
title: string | null;
creatorId: string;
workspaceId: string;
roleId?: string | null;
roleName?: string | null;
roleEmoji?: string | null;
createdAt: string;
updatedAt: string;
deletedAt?: string | null;
}
/**
* A reusable agent role (persona). The picker view (id/name/emoji/description)
* is safe for any workspace member; the admin view adds `instructions`,
* `modelConfig`, `enabled`, and timestamps. Mirrors the server's
* AgentRolePickerView / AgentRoleAdminView.
*/
export type IAiRoleDriver = "openai" | "gemini" | "ollama";
export interface IAiRoleModelConfig {
driver?: IAiRoleDriver;
chatModel: string;
}
/** Picker view — no `instructions` (admin-only field). */
export interface IAiRolePicker {
id: string;
name: string;
emoji: string | null;
description: string | null;
}
/** Admin view — includes instructions, modelConfig, enabled, timestamps. */
export interface IAiRole extends IAiRolePicker {
instructions: string;
modelConfig: IAiRoleModelConfig | null;
enabled: boolean;
createdAt: string;
updatedAt: string;
}
/**
* A persisted message row (mirrors the server `ai_chat_messages` baseFields
* returned by `POST /ai-chat/messages`, oldest first). `metadata.parts` holds

View File

@@ -0,0 +1,236 @@
import { useEffect, useState } from "react";
import { z } from "zod/v4";
import {
Button,
Group,
Select,
Stack,
Switch,
Text,
Textarea,
TextInput,
} from "@mantine/core";
import { useForm } from "@mantine/form";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { useTranslation } from "react-i18next";
import {
useCreateAiRoleMutation,
useUpdateAiRoleMutation,
} from "@/features/workspace/queries/ai-agent-roles-query.ts";
import {
type IAiRole,
type IAiRoleDriver,
type IAiRoleModelConfig,
} from "@/features/ai-chat/types/ai-chat.types.ts";
// Whether the role overrides the workspace model. When "default", no model
// override is stored (null). When "custom", chatModel + optional driver apply.
type OverrideMode = "default" | "custom";
const formSchema = z.object({
name: z.string().min(1),
emoji: z.string(),
description: z.string(),
instructions: z.string().min(1),
// Override mode controls whether model fields are emitted at all.
modelOverride: z.enum(["default", "custom"]),
modelDriver: z.enum(["openai", "gemini", "ollama"]).or(z.literal("")),
chatModel: z.string(),
enabled: z.boolean(),
});
type FormValues = z.infer<typeof formSchema>;
interface AiAgentRoleFormProps {
// When provided, the form edits an existing role; otherwise it creates one.
role?: IAiRole;
onClose: () => void;
}
const DRIVER_OPTIONS: { value: IAiRoleDriver | ""; label: string }[] = [
{ value: "", label: "(workspace default driver)" },
{ value: "openai", label: "OpenAI" },
{ value: "gemini", label: "Gemini" },
{ value: "ollama", label: "Ollama" },
];
export default function AiAgentRoleForm({
role,
onClose,
}: AiAgentRoleFormProps) {
const { t } = useTranslation();
const isEdit = Boolean(role);
const createMutation = useCreateAiRoleMutation();
const updateMutation = useUpdateAiRoleMutation();
const form = useForm<FormValues>({
validate: zod4Resolver(formSchema),
initialValues: buildInitialValues(role),
});
// Re-hydrate when the target role changes (e.g. reusing the modal).
useEffect(() => {
form.setValues(buildInitialValues(role));
form.resetDirty();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [role?.id]);
const isCustomModel = form.values.modelOverride === "custom";
// Build the modelConfig payload honouring the override mode. For "default"
// we pass null on edit (clear any stored override) and omit on create.
function resolveModelConfig(
isEdit: boolean,
): IAiRoleModelConfig | null | undefined {
if (!isCustomModel) {
// Clear only when editing a role that previously had an override.
return isEdit && role?.modelConfig ? null : undefined;
}
const chatModel = form.values.chatModel.trim();
if (!chatModel) return isEdit && role?.modelConfig ? null : undefined;
const cfg: IAiRoleModelConfig = { chatModel };
const driver = form.values.modelDriver;
if (driver === "openai" || driver === "gemini" || driver === "ollama") {
cfg.driver = driver;
}
return cfg;
}
async function handleSubmit(values: FormValues) {
const modelConfig = resolveModelConfig(isEdit);
if (isEdit && role) {
await updateMutation.mutateAsync({
id: role.id,
name: values.name,
emoji: values.emoji.trim() || null,
description: values.description.trim() || null,
instructions: values.instructions,
modelConfig,
enabled: values.enabled,
});
} else {
await createMutation.mutateAsync({
name: values.name,
emoji: values.emoji.trim() || undefined,
description: values.description.trim() || undefined,
instructions: values.instructions,
modelConfig: modelConfig ?? undefined,
enabled: values.enabled,
});
}
onClose();
}
const isSaving = createMutation.isPending || updateMutation.isPending;
return (
<Stack>
<TextInput
label={t("Role name")}
{...form.getInputProps("name")}
/>
<TextInput
label={t("Emoji")}
description={t("Optional. Shown next to the role name in the picker.")}
maxLength={4}
{...form.getInputProps("emoji")}
/>
<Textarea
label={t("Description")}
description={t("Optional. Shown in the role picker to help users choose.")}
autosize
minRows={1}
maxRows={3}
{...form.getInputProps("description")}
/>
<Textarea
label={t("Instructions")}
description={t(
"The agent persona. Replaces the workspace system message for chats bound to this role. A built-in safety framework is always appended.",
)}
autosize
minRows={4}
maxRows={16}
{...form.getInputProps("instructions")}
/>
<Select
label={t("Model override")}
description={t(
"Optional. Use a different model for chats bound to this role.",
)}
data={[
{ value: "default", label: t("Use workspace default") },
{ value: "custom", label: t("Custom model") },
]}
allowDeselect={false}
{...form.getInputProps("modelOverride")}
/>
{isCustomModel && (
<Group align="flex-end" gap="sm">
<Select
label={t("Driver")}
description={t(
"Defaults to the workspace driver. Credentials for an alternate driver must be set in the provider settings.",
)}
data={DRIVER_OPTIONS}
w={180}
{...form.getInputProps("modelDriver")}
/>
<TextInput
label={t("Chat model")}
style={{ flex: 1 }}
{...form.getInputProps("chatModel")}
/>
</Group>
)}
<Switch
label={t("Enabled")}
description={t(
"Disabled roles are hidden from the picker but existing chats keep using them.",
)}
checked={form.values.enabled}
onChange={(event) =>
form.setFieldValue("enabled", event.currentTarget.checked)
}
/>
<Group justify="flex-end" mt="sm">
<Button type="button" variant="default" onClick={onClose}>
{t("Cancel")}
</Button>
<Button
type="button"
onClick={() => handleSubmit(form.values)}
disabled={isSaving || !form.isValid()}
loading={isSaving}
>
{t("Save")}
</Button>
</Group>
</Stack>
);
}
/** Build the form initial values from an existing role (or defaults for create). */
function buildInitialValues(role: IAiRole | undefined): FormValues {
const cfg = role?.modelConfig;
return {
name: role?.name ?? "",
emoji: role?.emoji ?? "",
description: role?.description ?? "",
instructions: role?.instructions ?? "",
modelOverride: cfg ? "custom" : "default",
modelDriver: cfg?.driver ?? "",
chatModel: cfg?.chatModel ?? "",
enabled: role?.enabled ?? true,
};
}

View File

@@ -0,0 +1,184 @@
import { useState } from "react";
import {
ActionIcon,
Badge,
Box,
Button,
Group,
Modal,
Paper,
Stack,
Switch,
Text,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { modals } from "@mantine/modals";
import { IconPencil, IconPlus, IconTrash } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import useUserRole from "@/hooks/use-user-role.tsx";
import {
useAiRolesAdminQuery,
useDeleteAiRoleMutation,
useUpdateAiRoleMutation,
} from "@/features/workspace/queries/ai-agent-roles-query.ts";
import { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
import AiAgentRoleForm from "./ai-agent-role-form.tsx";
/**
* Admin section: list / add / edit / delete agent roles (reusable personas).
* Mirrors `AiMcpServers`. The add/edit form lives in `AiAgentRoleForm`, opened
* in a modal. Roles customize the agent's persona (and optionally the model);
* the non-removable safety framework is always appended after `instructions`.
*/
export default function AiAgentRoles() {
const { t } = useTranslation();
const { isAdmin } = useUserRole();
const { data: roles, isLoading } = useAiRolesAdminQuery(isAdmin);
const updateMutation = useUpdateAiRoleMutation();
const deleteMutation = useDeleteAiRoleMutation();
const [opened, { open, close }] = useDisclosure(false);
// The role being edited; undefined means the modal is in "create" mode.
const [editing, setEditing] = useState<IAiRole | undefined>(undefined);
if (!isAdmin) {
return (
<Text size="sm" c="dimmed">
{t("Only workspace admins can manage AI provider settings.")}
</Text>
);
}
function openCreate() {
setEditing(undefined);
open();
}
function openEdit(role: IAiRole) {
setEditing(role);
open();
}
function confirmDelete(role: IAiRole) {
modals.openConfirmModal({
title: t("Delete role"),
children: (
<Text size="sm">
{t(
"Are you sure you want to delete this role? Existing chats keep their persona.",
)}
</Text>
),
labels: { confirm: t("Delete"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => deleteMutation.mutate(role.id),
});
}
return (
<Paper withBorder radius="md" p="lg">
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap="xs" align="center" wrap="nowrap">
<Box
w={9}
h={9}
bg="grape.6"
style={{ borderRadius: "50%", flex: "none" }}
/>
<Text fw={600}>{t("Agent roles")}</Text>
<Badge size="sm" variant="light" color="gray">
{t("Personas")}
</Badge>
</Group>
<Button
leftSection={<IconPlus size={16} />}
variant="default"
size="xs"
onClick={openCreate}
>
{t("Add role")}
</Button>
</Group>
<Text size="xs" c="dimmed" mt={4}>
{t("Reusable agent personas for specialized chats.")}
</Text>
{!isLoading && (!roles || roles.length === 0) && (
<Text size="sm" c="dimmed" mt="sm">
{t("No agent roles configured")}
</Text>
)}
<Stack gap="xs" mt="sm">
{roles?.map((role) => (
<Group key={role.id} justify="space-between" wrap="nowrap">
<Stack gap={2} style={{ minWidth: 0 }}>
<Group gap="xs">
{role.emoji && <span aria-hidden>{role.emoji}</span>}
<Text fw={500} truncate>
{role.name}
</Text>
{role.modelConfig && (
<Badge size="xs" variant="light" color="indigo">
{role.modelConfig.driver
? `${role.modelConfig.driver}/${role.modelConfig.chatModel}`
: role.modelConfig.chatModel}
</Badge>
)}
</Group>
{role.description && (
<Text size="xs" c="dimmed" truncate>
{role.description}
</Text>
)}
</Stack>
<Group gap="xs" wrap="nowrap">
<Switch
size="sm"
checked={role.enabled}
aria-label={t("Enabled")}
onChange={(event) =>
updateMutation.mutate({
id: role.id,
enabled: event.currentTarget.checked,
})
}
/>
<ActionIcon
variant="subtle"
aria-label={t("Edit role")}
onClick={() => openEdit(role)}
>
<IconPencil size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
aria-label={t("Delete role")}
onClick={() => confirmDelete(role)}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
</Group>
))}
</Stack>
<Modal
opened={opened}
onClose={close}
title={editing ? t("Edit role") : t("Add role")}
size="lg"
>
{/* Remount the form per target so its internal state re-hydrates. */}
<AiAgentRoleForm
key={editing?.id ?? "new"}
role={editing}
onClose={close}
/>
</Modal>
</Paper>
);
}

View File

@@ -38,6 +38,7 @@ import {
SttApiStyle,
} from "@/features/workspace/services/ai-settings-service.ts";
import AiMcpServers from "./ai-mcp-servers.tsx";
import AiAgentRoles from "./ai-agent-roles.tsx";
// No driver field: every endpoint is OpenAI-compatible, so the form carries only
// the user-editable fields. `apiKey` / `embeddingApiKey` are write-only buffers
@@ -731,6 +732,9 @@ export default function AiProviderSettings() {
{/* Nested: external MCP tools the agent calls out to */}
<AiMcpServers />
{/* Nested: reusable agent roles (personas) */}
<AiAgentRoles />
{/* Save all endpoint settings */}
<Group>
<Button

View File

@@ -0,0 +1,107 @@
import {
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import {
getAiRoles,
getAiRolesAdmin,
createAiRole,
updateAiRole,
deleteAiRole,
type IAiRoleCreate,
type IAiRoleUpdate,
} from "@/features/workspace/services/ai-agent-roles-service.ts";
import {
type IAiRole,
type IAiRolePicker,
} from "@/features/ai-chat/types/ai-chat.types.ts";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
// Picker list (all members) and admin list (admins only) share an invalidate
// key so any role mutation refreshes both the picker and the admin table.
const aiRolesKey = ["ai-roles"];
/** Picker list — any workspace member. Returns id/name/emoji/description only. */
export function useAiRolesQuery(
enabled: boolean = true,
): UseQueryResult<IAiRolePicker[], Error> {
return useQuery({
queryKey: aiRolesKey,
queryFn: () => getAiRoles(),
enabled,
});
}
/** Admin list — full view including instructions/modelConfig. */
export function useAiRolesAdminQuery(
enabled: boolean = true,
): UseQueryResult<IAiRole[], Error> {
return useQuery({
queryKey: [...aiRolesKey, "admin"],
queryFn: () => getAiRolesAdmin(),
enabled,
});
}
export function useCreateAiRoleMutation() {
const { t } = useTranslation();
const queryClient = useQueryClient();
return useMutation<IAiRole, Error, IAiRoleCreate>({
mutationFn: (data) => createAiRole(data),
onSuccess: () => {
notifications.show({ message: t("Created successfully") });
queryClient.invalidateQueries({ queryKey: aiRolesKey });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage ?? t("Failed to update data"),
color: "red",
});
},
});
}
export function useUpdateAiRoleMutation() {
const { t } = useTranslation();
const queryClient = useQueryClient();
return useMutation<IAiRole, Error, IAiRoleUpdate>({
mutationFn: (data) => updateAiRole(data),
onSuccess: () => {
notifications.show({ message: t("Updated successfully") });
queryClient.invalidateQueries({ queryKey: aiRolesKey });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage ?? t("Failed to update data"),
color: "red",
});
},
});
}
export function useDeleteAiRoleMutation() {
const { t } = useTranslation();
const queryClient = useQueryClient();
return useMutation<{ success: true }, Error, string>({
mutationFn: (id) => deleteAiRole(id),
onSuccess: () => {
notifications.show({ message: t("Deleted successfully") });
queryClient.invalidateQueries({ queryKey: aiRolesKey });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage ?? t("Failed to update data"),
color: "red",
});
},
});
}

View File

@@ -0,0 +1,75 @@
import api from "@/lib/api-client";
import {
type IAiRole,
type IAiRoleModelConfig,
type IAiRoleDriver,
type IAiRolePicker,
} from "@/features/ai-chat/types/ai-chat.types.ts";
/**
* Agent roles API. The picker list (`getAiRoles`) is reachable by any workspace
* member (returns id/name/emoji/description only — never `instructions`); the
* admin endpoints (`getAiRolesAdmin`/create/update/delete) are admin-only and
* enforced server-side. The server wraps non-stream responses in `{ data }`
* (global transform interceptor), unwrapped here via `.data`.
*/
export type { IAiRoleDriver, IAiRoleModelConfig };
/** Create payload. `modelConfig` omitted/null => use the workspace default model. */
export interface IAiRoleCreate {
name: string;
emoji?: string;
description?: string;
instructions: string;
modelConfig?: IAiRoleModelConfig | null;
enabled?: boolean;
}
/** Update payload. Every field is optional (partial update). */
export interface IAiRoleUpdate {
id: string;
name?: string;
emoji?: string | null;
description?: string | null;
instructions?: string;
// undefined => leave unchanged; null => clear the override.
modelConfig?: IAiRoleModelConfig | null;
enabled?: boolean;
}
/** Picker list — any workspace member. */
export async function getAiRoles(): Promise<IAiRolePicker[]> {
const req = await api.post<IAiRolePicker[]>("/workspace/ai-agent-roles");
return req.data;
}
/** Admin list — includes instructions, modelConfig, enabled, timestamps. */
export async function getAiRolesAdmin(): Promise<IAiRole[]> {
const req = await api.post<IAiRole[]>("/workspace/ai-agent-roles/admin-list");
return req.data;
}
export async function createAiRole(data: IAiRoleCreate): Promise<IAiRole> {
const req = await api.post<IAiRole>(
"/workspace/ai-agent-roles/create",
data,
);
return req.data;
}
export async function updateAiRole(data: IAiRoleUpdate): Promise<IAiRole> {
const req = await api.post<IAiRole>(
"/workspace/ai-agent-roles/update",
data,
);
return req.data;
}
export async function deleteAiRole(id: string): Promise<{ success: true }> {
const req = await api.post<{ success: true }>(
"/workspace/ai-agent-roles/delete",
{ id },
);
return req.data;
}

View File

@@ -144,8 +144,14 @@ export class AiChatController {
// Resolve the model BEFORE hijack so an unconfigured provider returns a
// clean JSON 503 (AiNotConfiguredException is a 503 HttpException; letting
// it propagate here yields a normal response, not a broken stream).
const model = await this.aiChatService.getChatModel(workspace.id);
// it propagate here yields a normal response, not a broken stream). This
// also resolves a role's optional model override: a misconfigured alternate
// driver throws a clear 503 here (never a silent fallback mid-stream).
const model = await this.aiChatService.getChatModelForRole(
workspace.id,
body.roleId,
body.chatId,
);
// Abort the agent loop when the client disconnects. `close` also fires on
// normal completion, so only abort when the response has not finished

View File

@@ -7,6 +7,7 @@ import { AiTranscriptionService } from './ai-transcription.service';
import { AiChatToolsService } from './tools/ai-chat-tools.service';
import { EmbeddingModule } from './embedding/embedding.module';
import { ExternalMcpModule } from './external-mcp/external-mcp.module';
import { AiAgentRolesModule } from './roles/ai-agent-roles.module';
/**
* Per-user AI chat module (§6.1).
@@ -18,9 +19,17 @@ import { ExternalMcpModule } from './external-mcp/external-mcp.module';
* + AI_CHAT throttler come from the global ThrottleModule registered in
* AppModule. EmbeddingModule hosts the vector-RAG indexer + AI_QUEUE consumer
* (§6.7 stage D); importing it here boots the processor with the app.
* AiAgentRolesModule supplies the role resolve path used during streaming
* (persona + optional model override).
*/
@Module({
imports: [AiModule, TokenModule, EmbeddingModule, ExternalMcpModule],
imports: [
AiModule,
TokenModule,
EmbeddingModule,
ExternalMcpModule,
AiAgentRolesModule,
],
controllers: [AiChatController],
providers: [AiChatService, AiTranscriptionService, AiChatToolsService],
})

View File

@@ -61,6 +61,14 @@ export interface BuildSystemPromptInput {
* used instead.
*/
adminPrompt?: string | null;
/**
* Role-specific persona fragment (from `ai_agent_roles.instructions` for the
* chat's bound role, if any). When this is a non-blank string it REPLACES the
* admin persona base (a role like "Proofreader" must dominate, not compete
* with, the workspace prompt). When blank/absent the admin prompt (or default)
* is used. The non-removable SAFETY_FRAMEWORK is appended AFTER in all cases.
*/
roleInstructions?: string | null;
/**
* The page the user is currently viewing (client-supplied), if any. When it
* has an id, a CONTEXT line is added so the agent can resolve "this page" /
@@ -71,19 +79,26 @@ export interface BuildSystemPromptInput {
}
/**
* Compose the agent's system prompt: the admin's configured text (or a default
* when empty), then ALWAYS the non-removable safety framework. The admin text
* can shape the persona but cannot strip the safety rules.
* Compose the agent's system prompt: the persona (role instructions, else the
* admin's configured text, else a default when empty), then ALWAYS the
* non-removable safety framework. Neither the role nor the admin can strip the
* safety rules — SAFETY_FRAMEWORK is appended unconditionally.
*/
export function buildSystemPrompt({
workspace,
adminPrompt,
roleInstructions,
openedPage,
}: BuildSystemPromptInput): string {
const base =
typeof adminPrompt === 'string' && adminPrompt.trim().length > 0
? adminPrompt.trim()
: DEFAULT_PROMPT;
// Persona priority: role instructions (when non-blank) REPLACE the admin
// persona base; otherwise the admin prompt (or the default) is used. A role
// is a narrow persona like "Proofreader" — its instructions must dominate.
const persona =
typeof roleInstructions === 'string' && roleInstructions.trim().length > 0
? roleInstructions.trim()
: typeof adminPrompt === 'string' && adminPrompt.trim().length > 0
? adminPrompt.trim()
: DEFAULT_PROMPT;
let context = workspace?.name ? `\n\nWorkspace: ${workspace.name}.` : '';
@@ -100,5 +115,5 @@ export function buildSystemPrompt({
context += `\nThe user is currently viewing the page "${title}" (pageId: ${pageId.trim()}). When they refer to "this page", "the current page", or similar, operate on that pageId — use the read/write page tools with it.`;
}
return `${base}${context}\n${SAFETY_FRAMEWORK}`;
return `${persona}${context}\n${SAFETY_FRAMEWORK}`;
}

View File

@@ -15,15 +15,23 @@ import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.rep
import { User, Workspace, AiChatMessage } from '@docmost/db/types/entity.types';
import { AiChatToolsService } from './tools/ai-chat-tools.service';
import { McpClientsService } from './external-mcp/mcp-clients.service';
import { AiAgentRolesService } from './roles/ai-agent-roles.service';
import { buildSystemPrompt } from './ai-chat.prompt';
/**
* Payload accepted from the client `useChat` POST body. We do NOT bind a strict
* DTO (the global ValidationPipe whitelist would strip the useChat-specific
* fields), so this is a loose shape parsed straight off `req.body`.
*
* `roleId` is consumed ONLY on the first turn of a brand-new chat (chatId is
* null) — the server persists it onto the chat row. Subsequent turns read the
* role from the chat row, never from the body, so the role cannot be swapped
* per-turn.
*/
export interface AiChatStreamBody {
chatId?: string;
// Bound ONCE at chat creation; ignored on later turns.
roleId?: string | null;
// The page the user is currently viewing (client-supplied), or null on a
// non-page route. Used ONLY as prompt context so the agent knows what "this
// page" refers to; the page itself is never fetched server-side here. The id
@@ -70,6 +78,7 @@ export class AiChatService {
private readonly aiSettings: AiSettingsService,
private readonly tools: AiChatToolsService,
private readonly mcpClients: McpClientsService,
private readonly rolesService: AiAgentRolesService,
) {}
/**
@@ -81,6 +90,73 @@ export class AiChatService {
return this.ai.getChatModel(workspaceId);
}
/**
* Resolve the chat language model for a turn, honouring a role's optional
* model override. Resolves the role for THIS turn from the chat row's roleId
* (existing chat) or the request body's roleId (new chat). The driver-switch
* path throws AiNotConfiguredException (→ 503) with a clear, role-specific
* message when the alternate driver is unconfigured — never a silent fallback.
*
* Exposed so the controller can resolve the model BEFORE res.hijack(), so a
* misconfigured role surfaces as clean JSON 503 rather than a broken stream.
*/
async getChatModelForRole(
workspaceId: string,
roleIdFromBody: string | null | undefined,
chatId: string | null | undefined,
): Promise<LanguageModel> {
let effectiveRoleId: string | null = null;
if (chatId) {
const chat = await this.aiChatRepo.findById(chatId, workspaceId);
if (chat) {
// For an existing chat, the role is fixed on the chat row — read it
// there, never from the request body (the role cannot be swapped
// per-turn). findByIdForResolve returns the role even if disabled or
// soft-deleted, so existing chats keep their persona/model.
effectiveRoleId = chat.roleId ?? null;
if (effectiveRoleId) {
const role = await this.rolesService.findByIdForResolve(
effectiveRoleId,
workspaceId,
);
if (!role?.modelConfig) return this.ai.getChatModel(workspaceId);
return this.ai.getChatModel(workspaceId, role.modelConfig);
}
return this.ai.getChatModel(workspaceId);
}
// Stale chatId: stream() will create a new chat, so fall through to the
// new-chat resolution path using body.roleId.
}
// New chat (or stale chatId): mirror stream()'s picker-eligibility gate so
// a stale disabled/deleted role id is silently ignored here too — otherwise
// the controller would throw 503 on an alt-driver override while stream()
// proceeds without the role, diverging model and persona.
effectiveRoleId = roleIdFromBody || null;
if (effectiveRoleId) {
const eligible = await this.rolesService.findByIdForPicker(
effectiveRoleId,
workspaceId,
);
if (!eligible) effectiveRoleId = null;
}
if (!effectiveRoleId) {
return this.ai.getChatModel(workspaceId);
}
// Once gated as picker-eligible, use the full resolve view: the role is
// enabled and live, so findByIdForResolve === findByIdForPicker here, but
// findByIdForResolve returns the model_config-bearing row.
const role = await this.rolesService.findByIdForResolve(
effectiveRoleId,
workspaceId,
);
if (!role?.modelConfig) {
return this.ai.getChatModel(workspaceId);
}
return this.ai.getChatModel(workspaceId, role.modelConfig);
}
async stream({
user,
workspace,
@@ -101,9 +177,27 @@ export class AiChatService {
}
}
if (!chatId) {
// roleId is bound ONCE at chat creation (client sends it on the first
// turn of a new chat). Subsequent turns read it from the chat row below.
// Normalize with || (not ??) so a stray empty string also maps to null
// (the uuid column rejects ""; belt-and-braces with the client coercion).
let effectiveRoleId: string | null = body.roleId || null;
// Verify the role is picker-eligible (enabled, not soft-deleted, in this
// workspace). A stray id is either a stale client or a malicious one; the
// picker would never have offered a disabled/deleted role, so silently
// fall back to the universal assistant rather than throwing (avoids
// erroring an otherwise-legitimate new chat).
if (effectiveRoleId) {
const eligible = await this.rolesService.findByIdForPicker(
effectiveRoleId,
workspace.id,
);
if (!eligible) effectiveRoleId = null;
}
const chat = await this.aiChatRepo.insert({
creatorId: user.id,
workspaceId: workspace.id,
roleId: effectiveRoleId,
});
chatId = chat.id;
isNewChat = true;
@@ -143,9 +237,29 @@ export class AiChatService {
// The model is resolved by the controller before hijack (clean 503 path).
// Here we only need the admin-configured system prompt.
const resolved = await this.aiSettings.resolve(workspace.id);
// Resolve the role's persona for THIS turn. For an existing chat the role is
// fixed on the chat row (read there, never from the request body, so it
// cannot be swapped per-turn); for a brand-new chat the role was just bound
// above from body.roleId. findByIdForResolve returns the role even if
// disabled or soft-deleted, so existing chats keep their persona.
let roleInstructions: string | null = null;
{
const chat = await this.aiChatRepo.findById(chatId, workspace.id);
const roleId = chat?.roleId ?? null;
if (roleId) {
const role = await this.rolesService.findByIdForResolve(
roleId,
workspace.id,
);
if (role) roleInstructions = role.instructions;
}
}
const system = buildSystemPrompt({
workspace,
adminPrompt: resolved?.systemPrompt,
roleInstructions,
openedPage: body.openPage,
});

View File

@@ -0,0 +1,109 @@
import {
Body,
Controller,
ForbiddenException,
HttpCode,
HttpStatus,
Post,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
import { AuthUser } from '../../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../../common/decorators/auth-workspace.decorator';
import { User, Workspace } from '@docmost/db/types/entity.types';
import WorkspaceAbilityFactory from '../../casl/abilities/workspace-ability.factory';
import {
WorkspaceCaslAction,
WorkspaceCaslSubject,
} from '../../casl/interfaces/workspace-ability.type';
import { AiAgentRolesService } from './ai-agent-roles.service';
import { CreateAgentRoleDto } from './dto/create-agent-role.dto';
import { UpdateAgentRoleDto } from './dto/update-agent-role.dto';
import { AgentRoleIdDto } from './dto/agent-role-id.dto';
/**
* Agent roles management (admin CRUD + member-readable picker list). Mounted at
* `/workspace/ai-agent-roles` alongside `ai-mcp-servers` (both are admin AI
* settings). Routes are POST to match this codebase's convention.
*
* CRITICAL access asymmetry:
* - `list` (the base POST) returns the PICKER view and is open to ANY workspace
* member — otherwise a non-admin could not pick a role when starting a chat.
* The picker view deliberately omits `instructions` (admin-authored trusted
* content) so it is safe to expose to non-admins.
* - `admin-list` / `create` / `update` / `delete` are admin-only (the same gate
* as `/workspace/update` and the AI provider settings).
*/
@UseGuards(JwtAuthGuard)
@Controller('workspace/ai-agent-roles')
export class AiAgentRolesController {
constructor(
private readonly rolesService: AiAgentRolesService,
private readonly workspaceAbility: WorkspaceAbilityFactory,
) {}
private assertAdmin(user: User, workspace: Workspace): void {
const ability = this.workspaceAbility.createForUser(user, workspace);
if (
ability.cannot(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Settings)
) {
throw new ForbiddenException();
}
}
/**
* Picker list — open to ANY workspace member (no assertAdmin). Returns only
* id/name/emoji/description; never `instructions` (so it is safe for
* non-admins to read).
*/
@HttpCode(HttpStatus.OK)
@Post()
async list(@AuthWorkspace() workspace: Workspace) {
return this.rolesService.listForPicker(workspace.id);
}
/** Admin list — full view including `instructions` + `modelConfig`. */
@HttpCode(HttpStatus.OK)
@Post('admin-list')
async adminList(
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
this.assertAdmin(user, workspace);
return this.rolesService.listForAdmin(workspace.id);
}
@HttpCode(HttpStatus.OK)
@Post('create')
async create(
@Body() dto: CreateAgentRoleDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
this.assertAdmin(user, workspace);
return this.rolesService.create(workspace.id, user.id, dto);
}
@HttpCode(HttpStatus.OK)
@Post('update')
async update(
@Body() idDto: AgentRoleIdDto,
@Body() dto: UpdateAgentRoleDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
this.assertAdmin(user, workspace);
return this.rolesService.update(workspace.id, idDto.id, dto);
}
@HttpCode(HttpStatus.OK)
@Post('delete')
async remove(
@Body() idDto: AgentRoleIdDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
this.assertAdmin(user, workspace);
return this.rolesService.remove(workspace.id, idDto.id);
}
}

View File

@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { AiAgentRolesController } from './ai-agent-roles.controller';
import { AiAgentRolesService } from './ai-agent-roles.service';
/**
* Agent roles unit: reusable personas that customize the agent's system-prompt
* persona (and optionally the model) for chats bound to a role.
*
* AiAgentRoleRepo (DatabaseModule, global) and WorkspaceAbilityFactory
* (CaslModule, global) are resolved without explicit imports. The service is
* exported so AiChatService can resolve a role's instructions/model during
* streaming.
*/
@Module({
controllers: [AiAgentRolesController],
providers: [AiAgentRolesService],
exports: [AiAgentRolesService],
})
export class AiAgentRolesModule {}

View File

@@ -0,0 +1,196 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { AiAgentRoleRepo } from '@docmost/db/repos/ai-chat/ai-agent-role.repo';
import { AiAgentRole } from '@docmost/db/types/entity.types';
import { RoleModelConfig } from '@docmost/db/types/ai-agent-roles.types';
import { CreateAgentRoleDto } from './dto/create-agent-role.dto';
import { UpdateAgentRoleDto } from './dto/update-agent-role.dto';
/**
* Public (picker) view of a role. SECURITY: this shape intentionally excludes
* `instructions` and `modelConfig` — it is the only shape reachable by non-admin
* workspace members (the chat-start picker). A non-admin must be able to list
* roles to pick one when starting a chat, but must not read the instructions.
*/
export interface AgentRolePickerView {
id: string;
name: string;
emoji: string | null;
description: string | null;
}
/**
* Admin-facing view of a role. Includes `instructions` and `modelConfig` (the
* fields only an admin should read/edit). Admin-only.
*/
export interface AgentRoleAdminView {
id: string;
name: string;
emoji: string | null;
description: string | null;
instructions: string;
modelConfig: RoleModelConfig | null;
enabled: boolean;
createdAt: Date;
updatedAt: Date;
}
/**
* Admin business logic for agent roles: CRUD plus the resolve path used by the
* stream. Roles are workspace-scoped; soft-deleted roles stay readable for the
* resolve path so existing chats keep their persona.
*/
@Injectable()
export class AiAgentRolesService {
constructor(private readonly repo: AiAgentRoleRepo) {}
/**
* Picker list (all workspace members). Returns only enabled, non-deleted
* roles, projected to the picker view (NO instructions).
*/
async listForPicker(workspaceId: string): Promise<AgentRolePickerView[]> {
const rows = await this.repo.listForPicker(workspaceId);
return rows.map((r) => this.toPickerView(r));
}
/**
* Admin list. Returns all non-deleted roles (including disabled), projected to
* the full admin view (with instructions + modelConfig).
*/
async listForAdmin(workspaceId: string): Promise<AgentRoleAdminView[]> {
const rows = await this.repo.listForAdmin(workspaceId);
return rows.map((r) => this.toAdminView(r));
}
/**
* Resolve a role for the stream path: returns the role INCLUDING soft-deleted
* and disabled rows, so existing chats keep their persona. Returns undefined
* only when the row is gone (hard delete already nulled the chat's roleId via
* ON DELETE SET NULL).
*/
async findByIdForResolve(
id: string,
workspaceId: string,
): Promise<AiAgentRole | undefined> {
return this.repo.findByIdForResolve(id, workspaceId);
}
/**
* Picker-eligibility check: returns the role ONLY when enabled, not
* soft-deleted, and in this workspace. Used to validate a client-supplied
* roleId at new-chat creation so a disabled/soft-deleted role cannot be bound
* to a fresh chat. Returns undefined otherwise.
*/
async findByIdForPicker(
id: string,
workspaceId: string,
): Promise<AiAgentRole | undefined> {
return this.repo.findByIdEnabled(id, workspaceId);
}
async create(
workspaceId: string,
creatorId: string,
dto: CreateAgentRoleDto,
): Promise<AgentRoleAdminView> {
const row = await this.repo.insert({
workspaceId,
creatorId,
name: dto.name.trim(),
emoji: dto.emoji?.trim() || null,
description: dto.description?.trim() || null,
instructions: dto.instructions.trim(),
modelConfig: normalizeModelConfig(dto.modelConfig),
enabled: dto.enabled ?? true,
});
return this.toAdminView(row);
}
async update(
workspaceId: string,
id: string,
dto: UpdateAgentRoleDto,
): Promise<AgentRoleAdminView> {
const existing = await this.repo.findById(id, workspaceId);
if (!existing) {
throw new BadRequestException('Agent role not found');
}
await this.repo.update(id, workspaceId, {
name: dto.name !== undefined ? dto.name.trim() : undefined,
emoji: dto.emoji !== undefined ? dto.emoji.trim() || null : undefined,
description:
dto.description !== undefined
? dto.description.trim() || null
: undefined,
instructions:
dto.instructions !== undefined ? dto.instructions.trim() : undefined,
// `modelConfig` is a nested object on the DTO; normalize it only when the
// key was present in the patch. The DTO loader turns a raw null/undefined
// into undefined here, so we distinguish "leave unchanged" (key absent)
// from "clear" (null). ValidationPipe delivers the validated DTO value
// as-is for present keys.
modelConfig:
dto.modelConfig === undefined
? undefined
: normalizeModelConfig(dto.modelConfig),
enabled: dto.enabled,
});
const updated = await this.repo.findById(id, workspaceId);
return this.toAdminView(updated as AiAgentRole);
}
async remove(workspaceId: string, id: string): Promise<{ success: true }> {
await this.repo.softDelete(id, workspaceId);
return { success: true };
}
// --- internals ---
private toPickerView(row: AiAgentRole): AgentRolePickerView {
return {
id: row.id,
name: row.name,
emoji: row.emoji,
description: row.description,
};
}
private toAdminView(row: AiAgentRole): AgentRoleAdminView {
return {
id: row.id,
name: row.name,
emoji: row.emoji,
description: row.description,
instructions: row.instructions,
modelConfig: row.modelConfig,
enabled: row.enabled,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
}
/**
* Normalize the model config for storage. Returns null when there is no override
* (so the column reads as the workspace default). When a config object is
* present, `chatModel` is required (enforced by the DTO) and `driver` is kept
* only when set.
*/
function normalizeModelConfig(
config:
| { driver?: string; chatModel?: string }
| null
| undefined,
): RoleModelConfig | null {
if (!config) return null;
const chatModel = typeof config.chatModel === 'string' ? config.chatModel.trim() : '';
if (!chatModel) return null;
const out: RoleModelConfig = { chatModel };
const driver =
typeof config.driver === 'string' ? config.driver.trim() : '';
if (driver === 'openai' || driver === 'gemini' || driver === 'ollama') {
out.driver = driver;
}
return out;
}

View File

@@ -0,0 +1,7 @@
import { IsString } from 'class-validator';
/** Path param for the per-role routes (update/delete). */
export class AgentRoleIdDto {
@IsString()
id: string;
}

View File

@@ -0,0 +1,68 @@
import {
IsBoolean,
IsIn,
IsObject,
IsOptional,
IsString,
MaxLength,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
/** Allowed model override drivers (mirrors the workspace provider drivers). */
export const ROLE_DRIVERS = ['openai', 'gemini', 'ollama'] as const;
export type RoleDriver = (typeof ROLE_DRIVERS)[number];
/**
* Optional model override on a role. When `driver` differs from the workspace's
* configured driver, the role's chats use that driver's credentials (loaded
* from ai_provider_credentials). `chatModel` is always required when
* `modelConfig` is provided.
*/
export class RoleModelConfigDto {
@IsOptional()
@IsIn(ROLE_DRIVERS)
driver?: RoleDriver;
@IsString()
@MaxLength(200)
chatModel: string;
}
/**
* Admin create payload for an agent role. The global ValidationPipe runs with
* `whitelist: true`, so unknown fields are stripped.
*
* SECURITY: `instructions` is admin-authored trusted content that only enters
* the system prompt of chats in this workspace. It is never returned to
* non-admin (picker) clients.
*/
export class CreateAgentRoleDto {
@IsString()
@MaxLength(100)
name: string;
@IsOptional()
@IsString()
@MaxLength(32)
emoji?: string;
@IsOptional()
@IsString()
@MaxLength(500)
description?: string;
@IsString()
@MaxLength(8000)
instructions: string;
@IsOptional()
@IsObject()
@ValidateNested()
@Type(() => RoleModelConfigDto)
modelConfig?: RoleModelConfigDto;
@IsOptional()
@IsBoolean()
enabled?: boolean;
}

View File

@@ -0,0 +1,63 @@
import {
IsBoolean,
IsIn,
IsObject,
IsOptional,
IsString,
MaxLength,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { ROLE_DRIVERS, RoleDriver } from './create-agent-role.dto';
/**
* Optional model override update payload. Every field is optional (partial
* update). Passing `modelConfig: null` clears the override; omitting it leaves
* the stored override unchanged.
*/
export class UpdateRoleModelConfigDto {
@IsOptional()
@IsIn(ROLE_DRIVERS)
driver?: RoleDriver;
@IsOptional()
@IsString()
@MaxLength(200)
chatModel?: string;
}
/**
* Admin update payload for an agent role. Every field is optional (partial
* update). The global ValidationPipe runs with `whitelist: true`.
*/
export class UpdateAgentRoleDto {
@IsOptional()
@IsString()
@MaxLength(100)
name?: string;
@IsOptional()
@IsString()
@MaxLength(32)
emoji?: string;
@IsOptional()
@IsString()
@MaxLength(500)
description?: string;
@IsOptional()
@IsString()
@MaxLength(8000)
instructions?: string;
@IsOptional()
@IsObject()
@ValidateNested()
@Type(() => UpdateRoleModelConfigDto)
modelConfig?: UpdateRoleModelConfigDto;
@IsOptional()
@IsBoolean()
enabled?: boolean;
}

View File

@@ -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-chat/ai-agent-role.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,
],
})

View File

@@ -0,0 +1,78 @@
import { type Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
// Per-workspace reusable agent roles (personas). A role customizes the agent's
// system prompt persona (and optionally the model) for chats created under it.
// Roles are admin-owned and shared across the workspace (no personal roles v1).
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(),
)
// Audit: who created the role. ON DELETE SET NULL — a role is shared and
// outlives its creator (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())
// Presentation emoji (nullable; the picker falls back to a default).
.addColumn('emoji', 'varchar', (col) => col)
// Human-readable description shown in the picker/admin list.
.addColumn('description', 'text', (col) => col)
// The persona fragment that REPLACES the admin system prompt's base persona
// for chats bound to this role. The non-removable SAFETY_FRAMEWORK is always
// appended after it by buildSystemPrompt.
.addColumn('instructions', 'text', (col) => col.notNull())
// Optional model override: { driver?: 'openai'|'gemini'|'ollama', chatModel: string }.
// NULL -> use the workspace default model. Credentials for an alternate driver
// are loaded from ai_provider_credentials (no per-role keys).
.addColumn('model_config', 'jsonb', (col) => col)
// enabled=false removes the role from the picker but existing chats keep it.
.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). Soft-deleted roles disappear from
// the picker but existing chats keep applying their instructions (the resolve
// path reads past deleted_at).
.addColumn('deleted_at', 'timestamptz', (col) => col)
.execute();
// Scoped lookups (listForPicker / listForAdmin) hit workspace_id first.
await db.schema
.createIndex('ai_agent_roles_workspace_id_idx')
.ifNotExists()
.on('ai_agent_roles')
.column('workspace_id')
.execute();
// Bind a chat to a role. Set ONCE at chat creation; subsequent turns read it
// from the chat row (never from the request body). ON DELETE SET NULL: if a
// role is hard-deleted, the chat degrades to the universal assistant rather
// than breaking. The primary deletion path is soft-delete (deleted_at), under
// which roleId stays populated so existing chats keep the persona.
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.dropTable('ai_agent_roles').execute();
}

View File

@@ -0,0 +1,205 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { sql } from 'kysely';
import { KyselyTransaction } from '../../types/kysely.types';
import { dbOrTx } from '../../utils';
import { AiAgentRole } from '@docmost/db/types/entity.types';
import { KyselyDB } from '../../types/kysely.types';
import { RoleModelConfig } from '@docmost/db/types/ai-agent-roles.types';
/**
* Repository for per-workspace agent roles (reusable personas). All lookups are
* workspace-scoped (multitenant safety). Roles are soft-deleted (`deletedAt`)
* so existing chats keep applying their instructions — see `findByIdForResolve`.
*
* SECURITY: `instructions` is admin-authored trusted content. The picker-facing
* `listForPicker` returns only enabled, non-deleted rows; callers projecting to
* the picker view MUST NOT include `instructions` (non-admin clients only see
* id/name/emoji/description).
*/
@Injectable()
export class AiAgentRoleRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
/**
* Find a single non-deleted role by id, workspace-scoped. Used by the admin
* update path. Returns undefined for soft-deleted rows.
*/
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();
}
/**
* Find a role by id INCLUDING soft-deleted and disabled rows, workspace-scoped.
* Used by the stream resolve path: an existing chat keeps its persona even if
* the role was later disabled or soft-deleted. Returns undefined only when the
* row is gone (hard delete already nulled the chat's roleId via ON DELETE SET
* NULL, so this is a belt-and-braces guard).
*/
async findByIdForResolve(
id: string,
workspaceId: string,
): Promise<AiAgentRole | undefined> {
return this.db
.selectFrom('aiAgentRoles')
.selectAll('aiAgentRoles')
.where('id', '=', id)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();
}
/**
* Roles shown in the chat-start picker: enabled, non-deleted, newest-last.
* Reachable by any workspace member (so a non-admin can pick a role when
* starting a chat). Project to the picker view before returning to a
* non-admin — do NOT leak `instructions`.
*/
async listForPicker(workspaceId: string): Promise<AiAgentRole[]> {
return this.db
.selectFrom('aiAgentRoles')
.selectAll('aiAgentRoles')
.where('workspaceId', '=', workspaceId)
.where('deletedAt', 'is', null)
.where('enabled', '=', true)
.orderBy('createdAt', 'asc')
.execute();
}
/**
* Find a single role by id, workspace-scoped, ONLY when enabled and not
* soft-deleted — i.e. the same filter as `listForPicker`. Used to validate a
* client-supplied roleId at new-chat creation so a disabled/soft-deleted role
* cannot be bound to a fresh chat (picker-eligibility gate). Returns undefined
* otherwise.
*/
async findByIdEnabled(
id: string,
workspaceId: string,
): Promise<AiAgentRole | undefined> {
return this.db
.selectFrom('aiAgentRoles')
.selectAll('aiAgentRoles')
.where('id', '=', id)
.where('workspaceId', '=', workspaceId)
.where('deletedAt', 'is', null)
.where('enabled', '=', true)
.executeTakeFirst();
}
/**
* All roles for the admin management view: includes disabled, excludes
* soft-deleted. Admin-only (controller asserts admin).
*/
async listForAdmin(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: unknown;
enabled?: boolean;
},
trx?: KyselyTransaction,
): Promise<AiAgentRole> {
const db = dbOrTx(this.db, trx);
return db
.insertInto('aiAgentRoles')
.values({
workspaceId: values.workspaceId,
creatorId: values.creatorId,
name: values.name,
emoji: values.emoji,
description: values.description,
instructions: values.instructions,
// jsonb column: bind the JSON text and cast it so the postgres driver
// does not encode a JS object as a Postgres record literal.
modelConfig: jsonbValue(values.modelConfig),
enabled: values.enabled ?? true,
})
.returningAll()
.executeTakeFirst();
}
/**
* Partial update of a non-deleted role, workspace-scoped. Refuses to touch
* soft-deleted rows (they are immutable history for existing chats).
*/
async update(
id: string,
workspaceId: string,
patch: {
name?: string;
emoji?: string | null;
description?: string | null;
instructions?: string;
modelConfig?: unknown;
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 = jsonbValue(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: set `deletedAt`. The row stays so existing chats keep the persona. */
async softDelete(
id: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.updateTable('aiAgentRoles')
.set({ deletedAt: new Date(), updatedAt: new Date() })
.where('id', '=', id)
.where('workspaceId', '=', workspaceId)
.where('deletedAt', 'is', null)
.execute();
}
}
/**
* Encode a value as a jsonb bind for the `model_config` column. Passing a plain
* JS object to the postgres driver would serialize it as a Postgres record
* literal (incompatible with jsonb), so the JSON text is bound and cast.
*/
function jsonbValue(value: unknown) {
if (value === null || value === undefined) return null;
return sql<RoleModelConfig>`${JSON.stringify(value)}::jsonb`;
}

View File

@@ -29,20 +29,44 @@ export class AiChatRepo {
workspaceId: string,
pagination: PaginationOptions,
) {
// LEFT JOIN ai_agent_roles for the role badge (list is not a hot path, so no
// denormalization). Include soft-deleted/disabled roles so existing chats
// still show their badge — a soft-deleted role's name/emoji remain visible,
// consistent with how its instructions keep applying in the stream.
// The JOIN is workspace-scoped on top of the id match so a chat whose
// roleId somehow points at another workspace's role (bug / direct DB edit)
// does not surface that foreign role's name/emoji here (multitenant safety).
const query = this.db
.selectFrom('aiChats')
.selectAll('aiChats')
.where('creatorId', '=', creatorId)
.where('workspaceId', '=', workspaceId)
.where('deletedAt', 'is', null);
.leftJoin('aiAgentRoles', (jb) =>
jb
.onRef('aiAgentRoles.id', '=', 'aiChats.roleId')
.onRef('aiAgentRoles.workspaceId', '=', 'aiChats.workspaceId'),
)
.select([
'aiAgentRoles.name as roleName',
'aiAgentRoles.emoji as roleEmoji',
])
// Qualify every column ref: both ai_chats and ai_agent_roles expose
// workspace_id / creator_id / created_at / updated_at / deleted_at, so
// an unqualified ref is ambiguous and Postgres rejects the query at
// runtime (→ 500 on POST /ai-chat/chats).
.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' },
// expression must be table-qualified (ambiguous otherwise); `key`
// stays the UNQUALIFIED name so the serialized cursor remains
// backward-compatible with existing clients (the encoder/decoder
// key is the property name in the cursor payload, not the SQL ref).
{ expression: 'aiChats.createdAt', direction: 'desc', key: 'createdAt' },
{ expression: 'aiChats.id', direction: 'desc', key: 'id' },
],
parseCursor: (cursor) => ({
createdAt: new Date(cursor.createdAt),

View File

@@ -0,0 +1,45 @@
import { Timestamp, Generated } from '@docmost/db/types/db';
// ai_agent_roles type
// Hand-written (not generated) because codegen requires a live DB.
// Mirrors the migration 20260620T150000-ai-agent-roles.ts.
//
// A role is a workspace-admin-owned preset that customizes the agent's persona
// (system prompt base) and optionally the model. Chats bind to a role once at
// creation via ai_chats.role_id. SECURITY: `instructions` is admin-authored
// trusted content that only ever enters the system prompt of chats in THIS
// workspace; it must NOT be returned to non-admin (picker) clients — the picker
// view intentionally exposes only id/name/emoji/description.
export interface AiAgentRoles {
id: Generated<string>;
workspaceId: string;
// Audit: who created the role. Nullable because ON DELETE SET NULL (a role is
// shared and outlives its creator).
creatorId: string | null;
name: string;
emoji: string | null;
description: string | null;
// Persona fragment that REPLACES the admin system prompt base for bound chats.
instructions: string;
// { driver?: AiDriver, chatModel: string } | null = use workspace default.
// jsonb column: the postgres driver returns the stored value as a JS object.
modelConfig: RoleModelConfig | null;
enabled: Generated<boolean>;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
// Soft delete: row stays so existing chats keep the persona, but the role is
// hidden from the picker and admin list.
deletedAt: Timestamp | null;
}
/**
* Optional model override stored on a role. When present, the role's chats use
* this model instead of the workspace default. If `driver` is set and differs
* from the workspace's configured driver, credentials for that driver are
* loaded from ai_provider_credentials; if none are configured the turn fails
* with a clear 503 (no silent fallback).
*/
export interface RoleModelConfig {
driver?: 'openai' | 'gemini' | 'ollama';
chatModel: string;
}

View File

@@ -561,6 +561,9 @@ export interface AiChats {
workspaceId: string;
creatorId: string;
title: string | null;
// The role bound at chat creation (nullable = universal assistant). Set ONCE;
// subsequent turns read it from the row, never from the request body.
roleId: Generated<string | null>;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
deletedAt: Timestamp | null;

View File

@@ -2,9 +2,11 @@ import { DB } from '@docmost/db/types/db';
import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
import { AiProviderCredentials } from '@docmost/db/types/ai-provider-credentials.types';
import { AiMcpServers } from '@docmost/db/types/ai-mcp-servers.types';
import { AiAgentRoles } from '@docmost/db/types/ai-agent-roles.types';
export interface DbInterface extends DB {
pageEmbeddings: PageEmbeddings;
aiProviderCredentials: AiProviderCredentials;
aiMcpServers: AiMcpServers;
aiAgentRoles: AiAgentRoles;
}

View File

@@ -41,6 +41,7 @@ import {
import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
import { AiProviderCredentials as AiProviderCredentialsTable } from '@docmost/db/types/ai-provider-credentials.types';
import { AiMcpServers as AiMcpServersTable } from '@docmost/db/types/ai-mcp-servers.types';
import { AiAgentRoles as AiAgentRolesTable } from '@docmost/db/types/ai-agent-roles.types';
// AI Chat
export type AiChat = Selectable<AiChats>;
@@ -74,6 +75,15 @@ export type AiMcpServer = Selectable<AiMcpServersTable>;
export type InsertableAiMcpServer = Insertable<AiMcpServersTable>;
export type UpdatableAiMcpServer = Updateable<Omit<AiMcpServersTable, 'id'>>;
// AI Agent Roles (reusable personas the agent takes on for bound chats).
// SECURITY: `instructions` is admin-authored trusted content and must NOT be
// returned to non-admin (picker) clients — the picker view exposes only
// id/name/emoji/description. The non-removable SAFETY_FRAMEWORK is always
// appended after `instructions` by buildSystemPrompt.
export type AiAgentRole = Selectable<AiAgentRolesTable>;
export type InsertableAiAgentRole = Insertable<AiAgentRolesTable>;
export type UpdatableAiAgentRole = Updateable<Omit<AiAgentRolesTable, 'id'>>;
// Workspace
export type Workspace = Selectable<Workspaces>;
export type InsertableWorkspace = Insertable<Workspaces>;

View File

@@ -156,6 +156,38 @@ export class AiSettingsService {
return config;
}
/**
* Resolve just the credentials (and base URL) for a specific driver, bypassing
* the workspace's configured driver. Used by the agent-role model override
* path: when a role forces a DIFFERENT driver, that driver's own key/baseUrl
* must be loaded from ai_provider_credentials. Returns apiKey undefined when
* no credentials are stored for that driver (the caller surfaces a 503). The
* baseUrl falls back to the workspace's chat baseUrl (a driver-specific base
* URL is not stored separately, mirroring how embeddings fall back). The key
* is never logged.
*/
async resolveDriverCredentials(
workspaceId: string,
driver: string,
): Promise<{ apiKey?: string; baseUrl?: string }> {
const provider = await this.readProvider(workspaceId);
if (driver !== 'ollama') {
const creds = await this.aiProviderCredentialsRepo.find(
workspaceId,
driver,
);
if (creds?.apiKeyEnc) {
return {
apiKey: this.secretBox.decryptSecret(creds.apiKeyEnc),
baseUrl: provider.baseUrl,
};
}
return { baseUrl: provider.baseUrl };
}
// Ollama needs no key; just surface the base URL.
return { baseUrl: provider.baseUrl };
}
/**
* Masked settings safe for admin clients. NEVER includes any key (even
* encrypted); only `hasApiKey` / `hasEmbeddingApiKey` for the current driver.

View File

@@ -1,4 +1,4 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { BadRequestException, Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
import {
embedMany,
experimental_transcribe as transcribe,
@@ -14,6 +14,7 @@ import { AiNotConfiguredException } from './ai-not-configured.exception';
import { AiEmbeddingNotConfiguredException } from './ai-embedding-not-configured.exception';
import { AiSttNotConfiguredException } from './ai-stt-not-configured.exception';
import { describeProviderError } from './ai-error.util';
import type { RoleModelConfig } from '@docmost/db/types/ai-agent-roles.types';
/**
* Builds AI SDK language models from per-workspace config and runs cheap
@@ -32,18 +33,75 @@ export class AiService {
/**
* Resolve the workspace config and build the chat language model.
* Throws AiNotConfiguredException (→ 503) when the config is incomplete.
*
* With `override`, the role's model config is applied on top of the workspace
* config: `override.chatModel` replaces the workspace chat model, and when
* `override.driver` is set AND differs from the workspace driver, credentials
* for that driver are loaded from ai_provider_credentials. If those
* credentials are missing the call throws a clear 503 naming the driver — it
* never silently falls back to the workspace model (an explicit, surfaced
* error is preferred over a quiet change of model).
*/
async getChatModel(workspaceId: string): Promise<LanguageModel> {
async getChatModel(
workspaceId: string,
override?: RoleModelConfig | null,
): Promise<LanguageModel> {
const cfg = await this.aiSettings.resolve(workspaceId);
// Effective driver + chat model after applying the role override.
const driver = override?.driver ? override.driver : cfg?.driver;
const chatModel = override?.chatModel ? override.chatModel : cfg?.chatModel;
// Alternate creds are only loaded when override.driver is set AND differs
// from the workspace's configured driver. In every other case the workspace
// apiKey is what gets used, so the missing-key 503 must still fire.
const usesWorkspaceKey = !override?.driver || cfg?.driver === override?.driver;
if (
!cfg?.driver ||
!cfg?.chatModel ||
(cfg.driver !== 'ollama' && !cfg.apiKey)
!driver ||
!chatModel ||
(driver !== 'ollama' && !cfg?.apiKey && usesWorkspaceKey)
) {
throw new AiNotConfiguredException();
}
switch (cfg.driver) {
// When the role forces a DIFFERENT driver, the workspace chat key does not
// apply — load that driver's own credentials. A missing credential here is a
// hard 503 (never a silent fallback) so the admin sees the misconfiguration.
let apiKey = cfg?.apiKey;
let baseUrl = cfg?.baseUrl;
if (override?.driver && cfg?.driver !== override.driver) {
if (override.driver === 'ollama') {
// Ollama is key-less and needs its own base URL. There is currently no
// per-driver ollama endpoint setting, so a cross-driver ollama override
// cannot be served correctly — surface a clear 503 instead of silently
// hitting the workspace's chat base URL with the ollama client.
throw new ServiceUnavailableException(
`Agent role is configured to use the 'ollama' driver, but this ` +
`workspace's driver is '${cfg?.driver}'. A cross-driver override to ` +
`ollama requires a dedicated ollama base URL, which is not yet ` +
`supported. Either set the role's driver to '${cfg?.driver ?? 'openai'}' ` +
`or configure the workspace to use ollama.`,
);
}
const altCreds = await this.aiSettings.resolveDriverCredentials(
workspaceId,
override.driver,
);
if (!altCreds?.apiKey) {
// Clear, role-specific 503 — never a silent fallback. Naming the role
// would require a DB lookup here; the driver is enough for the admin to
// locate the misconfigured role in settings.
throw new ServiceUnavailableException(
`Agent role is configured to use driver '${override.driver}', but no ` +
`${override.driver} credentials are set in this workspace. Add them ` +
`in Workspace settings → AI and try again.`,
);
}
apiKey = altCreds.apiKey;
baseUrl = altCreds.baseUrl;
}
switch (driver) {
case 'openai':
// baseURL (when set) covers openai-compatible endpoints. Use Chat
// Completions (/chat/completions) — the portable OpenAI-compatible
@@ -51,14 +109,12 @@ export class AiService {
// Responses API (/responses), which OpenAI-compatible gateways
// (OpenRouter, etc.) reject on multi-turn requests (history with
// assistant messages) → 400.
return createOpenAI({ apiKey: cfg.apiKey, baseURL: cfg.baseUrl }).chat(
cfg.chatModel,
);
return createOpenAI({ apiKey, baseURL: baseUrl }).chat(chatModel);
case 'gemini':
return createGoogleGenerativeAI({ apiKey: cfg.apiKey })(cfg.chatModel);
return createGoogleGenerativeAI({ apiKey })(chatModel);
case 'ollama':
// Ollama needs no API key.
return createOllama({ baseURL: cfg.baseUrl })(cfg.chatModel);
return createOllama({ baseURL: baseUrl })(chatModel);
default:
throw new AiNotConfiguredException();
}

View File

@@ -0,0 +1,117 @@
# Обновление зависимостей: устаревшие версии и аудит безопасности
Статус: **зафиксировано в беклоге, зависимости не менялись.** Это снимок состояния
на дату проверки — список задач на обновление, а не баг. Бо́льшая часть версий
**унаследована от upstream `docmost/main`**, поэтому массовые бампы разумно делать
вместе с ребейзом на upstream, а мажорные апгрейды — отдельными задачами.
## Методика
- Дата проверки: **2026-06-20**, ветка форка `feat/ai-agent-roles`.
- `pnpm outdated -r` (рекурсивно по воркспейсу: root `docmost`, `server`, `client`,
`@docmost/mcp`, `@docmost/editor-ext`).
- `pnpm audit --prod` (+ `--json`) — только прод-зависимости.
- Итог: **162** устаревших записи, из них **50** отстают на мажор и больше;
**51 уязвимость** (16 high / 26 moderate / 8 low).
---
## 1. Безопасность — приоритет (51 уязвимость)
### 1.1. Самый дешёвый фикс: `pnpm.overrides` пинят УЯЗВИМЫЕ версии
В корневом `package.json` секция `pnpm.overrides` фиксирует ряд пакетов ровно на тех
версиях, на которые ругается `pnpm audit`. Достаточно поднять пины — код не трогаем.
| override (текущий пин) | advisory | severity | поднять до |
|---|---|---|---|
| `ws: 8.20.1` | DoS из мелких фрагментов (`<8.21.0`) | **high** | `8.21.0+` |
| `undici: 7.24.0` | обход проверки TLS-сертификата (`<7.28.0`) | **high** | `7.28.0+` |
| `tmp: 0.2.6` | path traversal, обход `_assertPath` (`<0.2.7`) | **high** | `0.2.7+` |
| `hono: 4.12.18` | CORS отражает любой Origin с credentials (`<4.12.25`) | **high** | `4.12.25+` |
| `protobufjs: 7.5.8` | DoS через unbounded Any (`<=7.6.0`) | **high** | `7.6.3+` |
| `dompurify: 3.4.1` | мутация `allowedTags` в хуке (`<3.4.7`) | moderate | `3.4.11` |
> Важно: `dompurify` — наш XSS-санитайзер, а override держит его на 3.4.1 (уязвимой),
> хотя в реестре уже 3.4.11. Это сводит на нет смысл санитайзера в части кейсов.
### 1.2. Прямые зависимости — фикс бампом версии
| пакет | у нас | где | advisory | severity | фикс |
|---|---|---|---|---|---|
| `@nestjs/platform-fastify` | `^11.1.19` (резолв 11.1.19) | server | обход middleware через trailing slash (`<=11.1.23`) | **high** | поднять lockfile до `11.1.27` |
| `nodemailer` | `^8.0.5` | server | `raw`-опция обходит `disableFileAccess` (`<=9.0.0`) | **high** | мажор `9.0.1` |
| `form-data` | `^4.0.0` (резолв 4.0.5) | @docmost/mcp | CRLF-инъекция (`<4.0.6`) | **high** | обновить lockfile до `4.0.6` |
| `react-router-dom` | `7.13.1` | client | произвольный контент через turbo-stream (`<=7.14.1`); CSRF на PUT/PATCH/DELETE (`<7.15.1`) | **high** + low | `7.15.1+` |
> `@nestjs/platform-fastify`: middleware-bypass напрямую касается auth-цепочки —
> это самый «горящий» из прямых. Caret `^11.1.19` уже разрешает `11.1.27`, нужен
> только пересбор lockfile.
### 1.3. Транзитивные (через зависимости) — фикс через override или бамп родителя
- `fast-uri <=3.1.0` (**high**, path traversal) — через `fastify`.
- Прочие moderate, всплывающие транзитивно: `markdown-it <=14.1.1` (DoS),
`qs` (DoS), `uuid` (bounds check), `nanoid@^3` (предсказуемость),
`@opentelemetry/core` (unbounded memory), `undici` (cross-user disclosure),
`esbuild`/`@babel/core` (low, только dev-сервер/сборка).
---
## 2. Очень старые — отставание на мажор (тех-долг)
### 2.1. Рискованные мажоры — каждый отдельной задачей с тестированием
| пакет | у нас | latest | замечание |
|---|---|---|---|
| `@mantine/*` 8 → 9 + `react`/`react-dom` 18 → 19 + `@types/react` 18 → 19 | 8.3.18 / 18.3.1 | 9.3.2 / 19.2.7 | Это апгрейд из upstream **PR #2293** (`chore: migrate to Mantine 9 and React 19`). Делать как у них: бамп + 3 паттерна (`useRef(undefined)`, обёртка `onChange`, шим `v8CssVariablesResolver`). Затрагивает в т.ч. EE-компоненты. |
| `@hocuspocus/{provider,server,transformer}` | 3.4.4 | 4.3.0 | Realtime-collab. Связано с `y-prosemirror`/`yjs` (на `yjs` уже есть патч `patches/yjs@13.6.30.patch` — учесть при бампе). upstream `main` тоже ещё на 3.x — координировать. |
| `@casl/ability` 6 → 7, `@casl/react` 5 → 7 | 6.8.0 / 5.0.1 | 7.0.0 | Библиотека прав доступа (авторизация) — мажор требует аккуратной проверки правил CASL. |
| `typescript` 5 → 6 | 5.9.3 | 6.0.3 | Мажор TS — глобально по трём пакетам, возможны новые ошибки типов. |
| `zod` 3 → 4 | 3.25.76 | 4.4.3 | В `@docmost/mcp`; zod 4 ломающий. Критично, т.к. zod описывает схемы инструментов AI/MCP (см. бэклог `ai-chat-tool-definitions-duplicated.md`). |
| `stripe` 17 → 22 | 17.7.0 | 22.2.2 | **+5 мажоров** (EE-биллинг). Если биллинг не используется — низкий приоритет, но разрыв самый большой. |
### 2.2. Тулинг и прочие мажоры (рутинно, пачкой)
`eslint` 9→10 + `@eslint/js` 9→10, `nx`/`@nx/js` 22→23, `i18next` 25→26 +
`react-i18next` 16→17 + `i18next-http-backend` 3→4, `undici` 7→8 (само приложение;
для security достаточно 7.28, мажор 8 — отдельно), `nodemailer` 8→9 (см. §1.2),
`marked` 17→18, `msgpackr` 1→2, `diff` 8→9, `concurrently` 9→10,
`@atlaskit/pragmatic-drag-and-drop*` 1→2/3 (DnD дерева), `react-clear-modal` 2→3,
`jsdom` 25/27→29 (dev/тесты), `@casl` (см. §2.1), плюс dev-types:
`@types/node`, `@types/nodemailer` 7→8, `@types/supertest` 6→7, `@types/yauzl` 2→3.
---
## 3. Deprecated и несогласованность
- **`@types/form-data` (2.5.2) — DEPRECATED.** Пакет `form-data` теперь поставляет
собственные типы. Зависимость в `@docmost/mcp` нужно **удалить**, а не обновлять.
- **`@types/node` рассинхронизирован по воркспейсу:** `@docmost/mcp` — 20.19,
`client` — 22.19, `server` — 25.5 (latest 26). Привести к единой мажорной линии
(по фактической версии Node в рантайме/Docker; в `package.json` поле `engines`
не задано — стоит зафиксировать).
---
## 4. Рекомендованный порядок работ
1. **Security-патч одной задачей (низкий риск):** поднять пины в `pnpm.overrides`
(§1.1) + пересобрать lockfile для caret-зависимостей (§1.2: fastify-platform,
form-data) + `react-router-dom` → 7.15.1 + `nodemailer` → 9. Прогнать
`pnpm audit --prod` до нуля high/critical. Убрать `@types/form-data`.
2. **Рутинные минор/патч-бампы** (большинство из 162) — пачкой вместе с
ближайшим ребейзом на upstream `docmost/main`.
3. **Мажоры из §2.1 — каждый отдельной веткой/задачей** с ручным тестом
соответствующей подсистемы (редактор, collab, права, i18n, AI-схемы).
4. Перепроверить, не конфликтуют ли бампы с локальными патчами
`patches/yjs@13.6.30.patch` и `patches/scimmy@1.3.5.patch` — при смене версии
путь патча (`yjs@13.6.30`) перестанет совпадать и `pnpm install` упадёт.
## Оговорки
- Снимок версий быстро устаревает — перед работой повторить `pnpm outdated -r`
и `pnpm audit --prod`.
- Многие «текущие» версии унаследованы от upstream; часть мажоров (Mantine9/React19,
Hocuspocus 4) upstream ещё не сделал — есть смысл дождаться/подсмотреть их подход,
чтобы не расходиться с веткой обновлений.

View File

@@ -0,0 +1,266 @@
# История страниц: производительность инлайн-диффа — дизайн
> Статус: **черновик / дизайн**. Реализация ещё не начата.
> Исходный кейс: при открытии истории страницы инлайн-дифф «адски тормозит» —
> вкладка фризится на больших страницах и **повторно** на каждый щелчок тумблера
> «Highlight changes». Цель — убрать фриз, сохранив визуальный результат диффа.
>
> Принятые на старте решения:
> - Серверную часть истории (снапшоты версий, REST `usePageHistoryQuery`) **не трогаем**.
> - Визуальный результат (что подсвечивается) должен остаться **эквивалентным** текущему — это рефактор производительности, не смена UX.
> - Корень проблемы — клиентский алгоритм восстановления шагов (`recreateTransform`) и то, как React гоняет его в `useEffect`.
## 1. Что есть сейчас (как устроен дифф)
Путь рендеринга:
- [history-modal-body.tsx](../apps/client/src/features/page-history/components/history-modal-body.tsx) — модалка, тумблер `highlightChanges`, навигация по изменениям (`useDiffNavigation`), счётчики `diffCounts`.
- [history-view.tsx](../apps/client/src/features/page-history/components/history-view.tsx) — тянет **две** версии через `usePageHistoryQuery(historyId)` и `usePageHistoryQuery(prevHistoryId)`, передаёт `content` + `previousContent` в редактор.
- [history-editor.tsx](../apps/client/src/features/page-history/components/history-editor.tsx) — поднимает **второй** инстанс TipTap (`useEditor({ extensions: mainExtensions, editable: false })`) и в одном большом `useEffect` считает дифф и строит декорации.
- Движок диффа — вендоренный [recreate-transform](../packages/editor-ext/src/lib/recreate-transform/) (форк `prosemirror-recreate-transform`, на `rfc6902` + `diff`).
Ядро вычисления в `history-editor.tsx`:
```ts
const tr = recreateTransform(oldContent, newContent, {
complexSteps: false,
wordDiffs: true,
simplifyDiff: true,
});
const changeSet = ChangeSet.create(oldContent).addSteps(tr.doc, tr.mapping.maps, []);
const changes = simplifyChanges(changeSet.changes, newContent);
// ... дальше из `changes` (fromA/toA/fromB/toB) строятся Decoration'ы ...
```
То есть `recreateTransform` нужен **только** чтобы получить набор шагов (`tr.mapping.maps`), который потом скармливается в `prosemirror-changeset`. Финальный набор `changes` и построение декораций уже идут через стандартный `ChangeSet` + `simplifyChanges`.
## 2. Почему тормозит
### 2.1 Алгоритм `recreateTransform` — приблизительно O(K · D)
В [recreateTransform.ts](../packages/editor-ext/src/lib/recreate-transform/recreateTransform.ts) на каждую операцию JSON-патча выполняется работа над **всем документом целиком**:
```ts
this.ops = createPatch(this.currentJSON, this.finalJSON); // rfc6902: diff JSON-деревьев, квадратичный по массивам
while (this.ops.length) {
const afterStepJSON = copy(this.currentJSON); // deep-clone ВСЕГО документа на каждую op
applyPatch(afterStepJSON, [op]);
toDoc = this.schema.nodeFromJSON(afterStepJSON); // пересборка ВСЕГО PM-дерева
toDoc.check(); // валидация ВСЕГО документа
// ... addReplaceStep -> this.schema.nodeFromJSON(this.currentJSON) — ещё одна полная пересборка
}
```
При `K` изменениях между версиями и документе размера `D` это даёт порядка `K · D` полных клонирований + `nodeFromJSON` + `check()`. Плюс сам `createPatch` (`rfc6902`) квадратичен по массивам узлов. На длинной странице с большим числом правок между ревизиями — **секунды синхронной работы на main-thread**. Это основной источник фриза.
### 2.2 Полный пересчёт диффа на каждый тумблер «Highlight changes»
В [history-editor.tsx](../apps/client/src/features/page-history/components/history-editor.tsx) весь расчёт сидит в одном `useEffect`, и в его зависимостях висит `highlightChanges`:
```ts
}, [ title, content, editor, previousContent, highlightChanges, setDiffCounts ]);
```
При включении/выключении подсветки заново гоняется `recreateTransform` + `ChangeSet` + построение всех декораций + `editor.commands.setContent(content)`. Хотя для тумблера достаточно подменить **уже посчитанный** `decorationSet` на `DecorationSet.empty`. Каждый щелчок повторно платит всю стоимость п. 2.1.
### 2.3 Второй полноценный редактор + `setContent` + `setOptions`
`useEditor({ extensions: mainExtensions })` поднимает весь стек редактора ради read-only превью; `editor.commands.setContent(content)` повторно парсит документ; `editor.setOptions({ editorProps: … })` переконфигурирует плагины на каждом прогоне эффекта. Это оверхед поверх п. 2.1, особенно при переключении версий.
### 2.4 Всё синхронно
Расчёт идёт синхронно в обработчике эффекта — UI блокируется до конца. Нет ни воркера, ни отменяемости, ни лоадера: визуально это «зависшая» вкладка.
**Сводка вкладов:**
| Источник | Когда бьёт | Стоимость |
|---|---|---|
| `recreateTransform` (rfc6902 + per-op полный rebuild) | смена версии, тумблер | 🔴 O(K·D), главный |
| Пересчёт на тумблере | каждый щелчок | 🔴 повтор всего п. 2.1 |
| Второй TipTap + `setContent`/`setOptions` | смена версии, тумблер | 🟠 средний |
| Синхронность (нет воркера/лоадера) | всегда | 🟠 фриз вместо «думает…» |
| `diffWordsWithSpace` по узлам | смена версии | 🟢 мелочь |
## 3. Цели
- Тумблер «Highlight changes» — **мгновенный** (никакого пересчёта диффа).
- Смена версии — без фриза вкладки; тяжёлый расчёт не блокирует main-thread, либо укладывается в единицы–десятки мс на типичных страницах.
- Большие страницы не вешают UI (деградация вместо фриза).
- **Визуальный паритет**: тот же набор подсвеченных диапазонов, те же счётчики, та же навигация.
- Серверную часть и формат снапшотов не трогаем.
- Ошибки — по правилам [AGENTS.md](../AGENTS.md): полный лог + конкретное человекочитаемое сообщение, без «тихого» фолбэка.
## 4. Ключевая идея: выкинуть `recreateTransform`, диффать через `prosemirror-changeset` напрямую
`prosemirror-changeset@2.4.0` (уже в зависимостях) **сам умеет токенный дифф**. Внутри `ChangeSet.addSteps()` по изменённому диапазону прогоняется `computeDiff` (token-based, с детектом границ слов) — см. `node_modules/prosemirror-changeset/dist/index.js:577`. Нам не нужно кропотливо «восстанавливать» все шаги через JSON-патч ради `tr.mapping.maps`.
В репозитории уже есть [getReplaceStep.ts](../packages/editor-ext/src/lib/recreate-transform/getReplaceStep.ts) — он строит **один минимальный `ReplaceStep`** между двумя документами через `findDiffStart`/`findDiffEnd` (это `O(D)`, а не `O(K·D)`). Достаточно скормить его map в `addSteps`, а дальше `prosemirror-changeset` сам разложит изменение до слов/символов.
**Было:**
```ts
const tr = recreateTransform(oldContent, newContent, {
complexSteps: false, wordDiffs: true, simplifyDiff: true,
});
const changeSet = ChangeSet.create(oldContent).addSteps(tr.doc, tr.mapping.maps, []);
const changes = simplifyChanges(changeSet.changes, newContent);
```
**Стало:**
```ts
import { ChangeSet, simplifyChanges } from "@tiptap/pm/changeset";
import { getReplaceStep } from "@docmost/editor-ext"; // см. §4.1 — нужно до-экспортировать
// один минимальный ReplaceStep между версиями — O(размер документа)
const step = getReplaceStep(oldContent, newContent);
let changes: Change[] = [];
if (step) {
// addSteps внутри прогоняет computeDiff (token-diff) по изменённому диапазону → слова/символы
const changeSet = ChangeSet.create(oldContent).addSteps(
newContent,
[step.getMap()],
[],
);
changes = simplifyChanges(changeSet.changes, newContent);
}
```
Почему это корректно и эквивалентно:
- `getReplaceStep(old, new)` подбирает замену так, что её применение к `old` даёт `new`; `step.getMap()` — её `StepMap`. `addSteps(newDoc, maps, …)` ожидает именно документ-после-шагов и его карты — мы передаём `newContent` и `[step.getMap()]`.
- `addSteps` для затронутого диапазона вызывает `computeDiff(oldContent.content, newContent.content, range, encoder)` — тот же токенный дифф, что обеспечивал бы `wordDiffs`. Гранулярность «по словам» восстанавливает `simplifyChanges` (он расширяет смешанные вставки/удаления до границ слов — это ровно текущее поведение).
- На выходе — массив `Change` с теми же `fromA/toA/fromB/toB`. **Построитель декораций в `history-editor.tsx` не меняется вообще** (спец-ноды, виджеты удалений, счётчики) — он потребляет тот же контракт. Это и есть главный фактор низкого риска.
- Сложность: `getReplaceStep``O(D)` (два прохода `findDiffStart`/`findDiffEnd`); `addSteps`/`computeDiff` — пропорционально размеру **изменённого** диапазона, а не всему документу и не числу правок. Уходит и квадратичность `rfc6902`, и per-op полный rebuild.
После этого `recreateTransform` / `rfc6902` / `diff` в пути истории больше не используются (можно оставить вендоренный модуль на месте, см. §10 про откат).
### 4.1 Мелочь: до-экспортировать `getReplaceStep`
Сейчас [recreate-transform/index.ts](../packages/editor-ext/src/lib/recreate-transform/index.ts) реэкспортит только `recreateTransform`. Добавить:
```ts
export { getReplaceStep } from "./getReplaceStep";
```
Корневой `packages/editor-ext/src/index.ts` уже делает `export * from "./lib/recreate-transform"`, так что символ станет доступен как `@docmost/editor-ext`. (Альтернатива — продублировать 25-строчную функцию прямо в `page-history`, чтобы вообще не зависеть от вендоренного модуля; но переиспользование чище.)
## 5. Развязать вычисление и подсветку (React)
Тумблер не должен пересчитывать дифф. Разносим один `useEffect` на два.
### Вариант A (рекомендуется) — кэшировать `decorationSet`, тумблер только переключает
```ts
const [decorationSet, setDecorationSet] = useState<DecorationSet>(DecorationSet.empty);
// тяжёлое: считаем дифф ТОЛЬКО когда реально сменилась пара версий/документ
useEffect(() => {
if (!editor || !content) return;
// ... §4: getReplaceStep -> ChangeSet -> changes -> построение decorations ...
editor.commands.setContent(content);
setDiffCounts({ added, deleted, total });
setDecorationSet(DecorationSet.create(newContent, decorations));
}, [editor, content, previousContent]); // <-- highlightChanges УБРАН
// дешёвое: тумблер лишь подменяет набор декораций, без пересчёта диффа
useEffect(() => {
if (!editor) return;
editor.setOptions({
editorProps: {
...editor.options.editorProps,
decorations: () => (highlightChanges ? decorationSet : DecorationSet.empty),
},
});
}, [editor, highlightChanges, decorationSet]);
```
- **Плюсы:** тумблер мгновенный; минимальная правка; контракт декораций не трогаем.
- **Минусы:** один лишний `useState` и аккуратность с зависимостями.
### Вариант B — вынести расчёт в `useMemo`, keyed по `(prevHistoryId, historyId)`
Считать `{ decorations, counts }` в `useMemo`, зависящем от идентификаторов версий (а не от ссылок на объекты `content`). React-Query и так отдаёт стабильные ссылки, но явный ключ по id защищает от лишних прогонов.
- **Плюсы:** явная мемоизация; нет эффект-«дёрганья».
- **Минусы:** строить `DecorationSet` нужно от схемы редактора, который живёт в эффекте — `useMemo` придётся аккуратно синхронизировать с инстансом редактора.
**Решение:** Вариант A (кэш `decorationSet` + два эффекта). B можно наложить сверху как ключевание тяжёлого эффекта по `(prevHistoryId, historyId)`, если профиль покажет лишние прогоны.
## 6. Снять фриз на больших документах
После §4 типичные страницы должны считаться за единицы мс. Для патологий — два рубежа:
### 6.1 Guard по размеру документа
Перед расчётом — порог (например, по числу узлов или суммарной длине текста, вынести в константу `HISTORY_DIFF_MAX_SIZE`). Если превышен:
- не строить инлайн-подсветку, показать только счётчики и плашку «дифф слишком большой для подсветки» (i18n-строка);
- либо считать дифф **на уровне блоков** (узел добавлен/удалён/изменён) без захода внутрь текста.
Это гарантирует деградацию вместо фриза независимо от качества алгоритма.
### 6.2 Асинхронность / Web Worker (опционально, по результатам профиля)
Если даже корректный дифф на гигантских страницах ощутим:
- завернуть расчёт в отменяемую async-задачу + лоадер (`isDiffing`), чтобы переключение версий не морозило вкладку (отменять предыдущий расчёт при быстром перещёлкивании);
- либо вынести дифф в **Web Worker**: на вход — два документа в JSON, на выход — массив `changes` (он `JSON`-сериализуем; ноды восстанавливаются в основном потоке для декораций). `ChangeSet.computeDiff` чист и переносим.
Делать только если §4 + §6.1 окажется недостаточно — добавляет заметную сложность (сериализация, восстановление схемы в воркере).
### 6.3 Нужен ли второй редактор (отдельно, низкий приоритет)
Поднятие полного `mainExtensions`-редактора ради read-only превью — оверхед. Возможная оптимизация — рендер через `DOMSerializer` + ручной слой декораций без полного TipTap. Это бóльшая переделка с риском по верстке/нодам; выносим в отдельный тикет, **не** в этот рефактор.
## 7. Обработка ошибок (по AGENTS.md)
Сейчас при сбое диффа — `console.error("History diff failed:", e)` и тихий фолбэк на контент без подсветки. По конвенции это надо усилить:
- логировать полностью (`name`, `message`, `stack`, `cause`);
- показать пользователю **конкретную** причину (например, нотификация «Не удалось построить дифф версий: …»), а не молча скрывать подсветку. Контент при этом всё равно показываем (graceful degradation), но факт сбоя не замалчиваем.
## 8. План внедрения по фазам
**Фаза 0 (P0) — ядро, низкий риск, основной выигрыш.**
- §4: заменить `recreateTransform` на `getReplaceStep` + `ChangeSet.addSteps`; до-экспортировать `getReplaceStep` (§4.1).
- §5 Вариант A: разнести эффект, кэшировать `decorationSet` (тумблер мгновенный).
- Файлы: [history-editor.tsx](../apps/client/src/features/page-history/components/history-editor.tsx), [recreate-transform/index.ts](../packages/editor-ext/src/lib/recreate-transform/index.ts).
- Контракт `changes`/декораций не меняется → визуальный паритет.
**Фаза 1 (P1) — устойчивость к патологиям.**
- §6.1 guard по размеру + i18n-плашка/counts-only.
- §7 нормальная обработка ошибок.
- Лоадер `isDiffing` при переключении версий (без воркера).
**Фаза 2 (P2) — по необходимости.**
- §6.2 Web Worker offload, если профиль на больших страницах требует.
- §6.3 отказ от второго полного редактора (отдельный тикет).
## 9. Тестирование и верификация
- **Юнит (паритет диффа):** util, возвращающий `changes` для пар (old, new), на наборе кейсов: вставка/удаление слова, замена, добавление/удаление абзаца, спец-ноды (`image`, `table`, `callout`, `mathBlock`…), правка только марок (bold/italic), идентичные документы (`getReplaceStep``false` → пустой дифф). Снять «золотые» `changes` на текущем `recreateTransform`-пути и сверить с новым (диапазоны `fromB/toB` должны совпадать или быть эквивалентны после `simplifyChanges`).
- **Профиль до/после:** DevTools → Performance на «тяжёлой» странице; зафиксировать длительность смены версии и щелчка тумблера. Ожидание: исчезают длинные таски `createPatch`/`nodeFromJSON`/`check`; тумблер пропадает из профиля.
- **Большой фикстур:** страница на сотни абзацев с десятками правок — проверка отсутствия фриза и срабатывания guard (Фаза 1).
- **Edge cases:** удаления (виджет-декорации с `DOMSerializer`), спец-ноды целиком в диапазоне, навигация по изменениям (`useDiffNavigation`), счётчики `diffCounts`.
## 10. Риски и откат
- **Гранулярность диффа может чуть отличаться** от `recreateTransform` на смешанных правках. Снимаем golden-тестами (§9); при расхождении подкручиваем через `TokenEncoder` в `ChangeSet.create` (по умолчанию сравнение нод по имени и текста посимвольно, марки/атрибуты игнорируются — это совпадает с текущим поведением).
- **Правки только марок:** один `ReplaceStep` по диапазону марки покрывает кейс; явно покрыть тестом.
- **Откат:** `recreateTransform` остаётся в пакете нетронутым; вернуть старый путь — это revert одного блока в `history-editor.tsx`. Можно временно спрятать новый путь за флагом, пока golden-тесты не подтвердят паритет.
## 11. Открытые вопросы
- Порог `HISTORY_DIFF_MAX_SIZE` — в узлах или символах, и какое значение (подобрать по профилю).
- Нужен ли вообще второй TipTap-инстанс (§6.3) — решаем после Фазы 0/1.
- Воркер (§6.2) — оправдан ли на реальных страницах, или хватает §4 + §6.1.
## Приложение: задействованный API `prosemirror-changeset@2.4.0`
- `ChangeSet.create(doc, combine?, tokenEncoder?, changes?)` — создаёт набор от базового документа.
- `changeSet.addSteps(newDoc, maps: StepMap[], data)` — добавляет шаги; **внутри** по изменённым диапазонам прогоняет `computeDiff` (token-diff) и упрощает результат.
- `simplifyChanges(changes, doc)` — расширяет смешанные вставки/удаления до границ слов (наша «word-level» гранулярность).
- `ChangeSet.computeDiff(fragA, fragB, range, encoder?)` — низкоуровневый токенный дифф (доступен статически, если захотим обойтись без `addSteps`).
- `Change { fromA, toA, fromB, toB, deleted: Span[], inserted: Span[] }` — контракт, который потребляет построитель декораций (не меняется).

View File

@@ -0,0 +1,144 @@
# Векторный / гибридный поиск в основном поиске (вынос из агента) — план
> Статус: план (не реализовано). **Важно про текущее состояние:** векторный
> (pgvector) и гибридный (RRF) поиск в форке **уже есть** — но только внутри
> агента. Пользовательский поиск `/search` (а с ним и UI-поиск, и MCP-инструмент
> `search`) всё ещё **чисто лексический**. Эта фича — вынести существующий
> семантический/гибридный движок на общий поисковый поверхностный слой.
## Как сверялось с реальным кодом (что есть, чего нет)
**Семантика уже реализована — но только для агента:**
- `page_embeddings` — pgvector, **dimension-agnostic** колонка `embedding`,
`model_name`/`model_dimensions` по строке; per-workspace; индексация через
BullMQ (`reindexPage`/`reindexWorkspace`). Активная модель деплоя — OpenAI
`text-embedding-3-large` (3072d). (См. [rag-improvements-plan.md](./rag-improvements-plan.md).)
- [page-embedding.repo.ts](../apps/server/src/database/repos/ai-chat/page-embedding.repo.ts):
- `searchByEmbedding()` — косинус `<=>` по чанкам (~стр. 143).
- `hybridSearch()`**RRF-слияние** косинуса и полнотекста (`fts`-CTE на
`websearch_to_tsquery`), `k = 60`, равные веса, scope по workspace +
доступным спейсам, фильтр по совпадающей размерности эмбеддинга (~стр. 211).
- Поиск идёт **seq-scan** по `<=>` (ANN-индекса нет; в комментарии репо прямо
сказано «re-add an HNSW index if [scale grows]»).
- Потребитель — **только** агент: инструмент `searchPages` в
[ai-chat-tools.service.ts](../apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts).
**Основной поиск — лексический:**
- [search.service.ts](../apps/server/src/core/search/search.service.ts) (`/search`):
только `pages.tsv` + `to_tsquery('english', …)`. Никаких эмбеддингов.
- **MCP-инструмент `search`** дергает именно этот REST:
[packages/mcp/src/client.ts:1818](../packages/mcp/src/client.ts#L1818) → `POST /search`.
Значит, вынеся семантику в `/search`, мы автоматически прокачаем и MCP-поиск.
- `AiService.getEmbeddingModel(workspaceId)` ([ai.service.ts](../apps/server/src/integrations/ai/ai.service.ts))
умеет строить embedding-модель из per-workspace конфига — то есть всё нужное для
получения вектора запроса уже есть.
- В окружении есть `SEARCH_DRIVER` (`database` | `typesense`). Семантику делаем
как улучшение драйвера `database`, **не** переопределяя `SEARCH_DRIVER`.
**Вывод:** «добавить векторный поиск» = не писать с нуля, а **переиспользовать
`hybridSearch` в `SearchService`** с тем же контролем доступа, что у лексического
`/search`, + graceful-фолбэк. Это главная мысль плана.
## Цель
Дать семантический/гибридный результат на общем поисковом слое (UI-поиск, REST
`/search`, MCP `search`), а не только агенту — чтобы «уволить» находило
«расторжение трудового договора», и чтобы это было доступно вне чата.
## Архитектура
### Контракт API
Добавить в `SearchDTO` параметр `mode: 'lexical' | 'semantic' | 'hybrid'`.
- Дефолт — `lexical` (обратная совместимость; тайп-ахед остаётся дешёвым).
- `hybrid`/`semantic` — включается явно (страница полнотекстового поиска, тумблер
в UI, или MCP с `mode:'hybrid'`).
### Поток в `SearchService.searchPage` для hybrid/semantic
1. Если эмбеддинги настроены (`getEmbeddingModel` не кидает) → эмбеддить **запрос**
(один вызов на поиск).
2. Вызвать `hybridSearch(workspaceId, queryVector, queryText, candidates, accessibleSpaceIds)`
— over-fetch чанков.
3. Чанки → страницы: дедуп по `pageId` (лучший score), маппинг в `SearchResponseDto`.
4. **Контроль доступа 1-в-1 с лексическим путём**: пост-фильтр через
`pagePermissionRepo.filterAccessiblePageIds(...)` (как в текущем `/search`,
стр. 129–139). Scope по доступным спейсам уже внутри `hybridSearch`, но
post-filter по правам страниц обязателен.
5. Highlight — `ts_headline` по `content` чанка (релевантнее, чем по странице).
6. Любой сбой/некочиг (эмбеддинги не настроены, embedding упал, нет доступных
спейсов, гибрид пуст) → **graceful fallback на лексический путь** (тот же
паттерн, что уже использует агентский инструмент).
### MCP и UI
- MCP: после появления `mode` в `/search` — прокинуть его в
[packages/mcp/src/client.ts](../packages/mcp/src/client.ts) и в схему MCP-тула.
Помнить: `packages/mcp` держит **свою копию** схемы (по `AGENTS.md`).
- UI: тайп-ахед (`searchSuggestions`) остаётся лексическим; семантику включать на
полной странице поиска / тумблером, не на каждое нажатие клавиши.
## Итерации
### Итерация 1 (MVP, backend)
`mode` в DTO; ветка hybrid в `SearchService` (эмбеддинг запроса → `hybridSearch`
→ чанк→страница дедуп → пост-фильтр прав → highlight; иначе лексический фолбэк).
Спеки на: паритет прав (закрытая страница не утекает), фолбэк без эмбеддингов,
дедуп страниц.
### Итерация 2 (MCP + UI)
Прокинуть `mode` в MCP-тул `search` (+ синхронизировать схему-зеркало) и добавить
переключатель/режим на странице поиска клиента.
### Итерация 3 (производительность и качество)
- Кеш/дебаунс эмбеддингов запроса (не эмбеддить одинаковые запросы повторно).
- ANN-индекс при росте корпуса (см. оговорку про dimension-agnostic ниже).
- Общий оценочный харнес с [rag-improvements-plan.md §C](./rag-improvements-plan.md)
(один золотой датасет на агентский и пользовательский поиск).
## Точки изменения
- [search.service.ts](../apps/server/src/core/search/search.service.ts) — ветвление по `mode`,
переиспользование `hybridSearch`, маппинг чанк→страница, общий пост-фильтр прав.
- `search.dto.ts` — поле `mode`.
- [page-embedding.repo.ts](../apps/server/src/database/repos/ai-chat/page-embedding.repo.ts) —
`hybridSearch`/`searchByEmbedding` уже есть; при необходимости — перегрузка,
возвращающая поля под `SearchResponseDto` (без дублирования логики).
- `search.module.ts` — подключить доступ к embedding-модели и репозиторию
эмбеддингов (DI).
- [packages/mcp/src/client.ts](../packages/mcp/src/client.ts) + схема MCP-тула — `mode`.
- Клиент: страница/тумблер поиска (итерация 2).
## Безопасность и граничные случаи
- **Паритет прав — риск №1.** Агентский `searchPages` скоупит по доступным
спейсам и пост-фильтрует права; общий поиск **обязан** делать то же
(`filterAccessiblePageIds`), иначе семантика утечёт чанки закрытых страниц.
Покрыть спеком утечки.
- **Путь шар (`shareId`)** в `/search` — анонимный, без per-user скоупа
эмбеддингов. Для шар оставить лексический поиск (или строго ограничить
поддеревом шары); семантику для анонимов в MVP не включать.
- **Стоимость/латентность.** Каждый семантический запрос = 1 вызов embedding-API
(~сотни мс + токены). Поэтому дефолт `lexical`, семантика — по явному режиму,
не на тайп-ахед.
- **Чанк→страница.** Страница может прийти из нескольких чанков — дедуп с лучшим
score; иначе дубликаты в выдаче.
- **Свежие страницы.** Только что созданная/изменённая страница попадёт в
семантику после отработки BullMQ-`reindexPage`. До этого её ловит лексическая
сторона (если есть `fts`-чанк) либо общий лексический фолбэк. Документировать
как осознанный лаг.
- **Фильтр размерности.** `hybridSearch` сравнивает только чанки с
`model_dimensions == dim(query)`. После смены embedding-модели старые чанки
невидимы до переиндексации (свойство уже существующего движка).
- **ANN-индекс vs dimension-agnostic колонка.** Сейчас seq-scan по `<=>` — норм на
масштабе вики. HNSW/IVFFlat требуют фиксированной размерности, а колонка
намеренно dimension-agnostic → ANN потребует либо фиксации размерности, либо
частичных индексов на размерность. Решать при реальном росте, не в MVP.
- **Связь с морфологией.** Лексический CTE гибрида использует `'english'`
(`page_embeddings.fts`). План [search-morphology-language-plan.md](./search-morphology-language-plan.md)
меняет этот конфиг — координировать, чтобы язык был единым в обоих поисках.
## Оговорки
- Это **не дубль** [rag-improvements-plan.md](./rag-improvements-plan.md): тот про
качество retrieval агента (реранкер, чанкинг, вложения, харнес). Здесь — про
**поверхность** (вынос уже готового движка в пользовательский/MCP поиск).
- Реранкер из rag-плана (бэклог §A), когда появится, можно переиспользовать и
здесь — точка вставки та же (между over-fetch гибрида и финальным срезом).

View File

@@ -0,0 +1,178 @@
# Выбор языка морфологии для полнотекстового поиска — план
> Статус: план (не реализовано). Контекст: gitmost — форк Docmost. Весь
> лексический поиск сейчас жёстко прибит к конфигу `'english'`, из-за чего на
> русской вики не работает стемминг (по запросу «сервер» не находятся
> «серверы / серверов / сервером»). Цель — сделать язык текстового поиска
> конфигурируемым, с разумным дефолтом для русско-английского контента.
## Как сверялось с реальным кодом
Все факты ниже проверены по дереву `develop` на момент написания.
### Где сейчас зашит `'english'`
**Индексная сторона (DDL — конфиг «запечён» в схему):**
1. `pages.tsv` — наполняется **триггером** `pages_tsvector_trigger()`
(`BEFORE INSERT OR UPDATE ON pages`). Тело функции:
`setweight(to_tsvector('english', f_unaccent(coalesce(new.title,''))),'A') || setweight(to_tsvector('english', f_unaccent(...text_content...)),'B')`.
Заведено в [20240324T086800-pages-tsvector-trigger.ts](../apps/server/src/database/migrations/20240324T086800-pages-tsvector-trigger.ts),
обновлено под `f_unaccent` в [20250729T213756-add-unaccent-pg_trm-update-tsvector..ts](../apps/server/src/database/migrations/20250729T213756-add-unaccent-pg_trm-update-tsvector..ts).
GIN-индекс — `pages_tsv_idx`.
2. `page_embeddings.fts`**GENERATED ALWAYS … STORED** колонка:
`to_tsvector('english', f_unaccent(content))`, GIN-индекс `idx_page_embeddings_fts`.
Заведена в [20260618T150000-page-embeddings-fts.ts](../apps/server/src/database/migrations/20260618T150000-page-embeddings-fts.ts).
Это лексическая сторона гибридного (RRF) поиска агента.
3. `attachments.tsv` — колонка `tsvector` + GIN `attachments_tsv_idx`
([20250901T184612-attachments-search.ts](../apps/server/src/database/migrations/20250901T184612-attachments-search.ts)).
**Путь наполнения этой колонки в коде не локализован** (в миграции триггера
нет) — перед реализацией нужно найти, кто и каким конфигом её пишет, и
привести к тому же языку (или признать колонку неиспользуемой).
**Сторона запроса (рантайм SQL — должна совпадать с индексом):**
4. [search.service.ts](../apps/server/src/core/search/search.service.ts) — пользовательский
REST-поиск `/search`. Три вхождения `'english'`: `ts_rank(tsv, to_tsquery('english', …))`
(стр. 50), `ts_headline('english', …)` (стр. 53), `WHERE tsv @@ to_tsquery('english', …)`
(стр. 60). **Через этот же эндпоинт ходит MCP-инструмент `search`**
([packages/mcp/src/client.ts:1818](../packages/mcp/src/client.ts#L1818) → `POST /search`),
поэтому фикс автоматически чинит и MCP-поиск.
5. [page-embedding.repo.ts](../apps/server/src/database/repos/ai-chat/page-embedding.repo.ts) —
лексический CTE гибридного поиска: `websearch_to_tsquery('english', f_unaccent(queryText))`
(~стр. 252) + `ts_rank(pe.fts, q.query)`.
### Что уже есть и помогает
- Расширения `unaccent` и `pg_trgm` **уже установлены** (миграция 20250729T213756),
`f_unaccent(text)` объявлена `IMMUTABLE` (важно: только IMMUTABLE-функцию можно
использовать в выражении GENERATED-колонки и в индексе).
- `searchSuggestions` (тайп-ахед по `users`/`groups`/`pages.title`) работает не
через `tsvector`, а через `ILIKE`/`f_unaccent` (подстрока) — он **уже
языконезависим**, морфология его не касается. Трогать не нужно.
- В окружении уже есть абстракция `SEARCH_DRIVER` (`database` | `typesense`,
дефолт `database`) — см. `environment.service.ts` / `environment.validation.ts`.
Наша задача относится к драйверу `database`.
## Ключевое ограничение (почему это не «рантайм-переключатель»)
Конфиг текстового поиска у GIN-индекса и у `tsvector`-колонки **запечён в DDL**
(в теле триггера и в выражении GENERATED-колонки). При запросе конфиг в
`to_tsquery(<config>, …)` **обязан совпадать** с тем, которым построен индекс,
иначе токены не сматчатся. Поэтому язык — это **выбор уровня деплоя**, а не
параметр запроса. Сделать конфиг по-настоящему «на строку» (per-workspace) можно,
но дорого: `to_tsvector(regconfig_column, text)` неиммутабельна, значит её
**нельзя** положить в GENERATED-колонку `page_embeddings.fts` (только в
триггер-наполняемую), а запрос по корпусу со смешанными конфигами требует знать
конфиг каждой строки. Это вариант D ниже — откладываем.
Ещё нюанс выбора конфига:
- `russian` — снежковый стемминг + русские стоп-слова. Минус: режет английские
технические термины и выкидывает русские стоп-слова (по «и»/«в»/«не» не найти).
- `english` (как сейчас) — стеммит по-английски, для русского почти бесполезен.
- `simple` — без стемминга и стоп-слов: только токенизация + lowercase (+ наш
`f_unaccent`). Языконезависим, но нет морфологии («серверы» ≠ «сервер»).
- Технические RU+EN-вики (как WirenBoard) — это **смесь**: лучший охват даёт
объединённый вектор `to_tsvector('russian', x) || to_tsvector('english', x)`.
## Варианты решения (по возрастанию сложности)
### Вариант A — глобальный конфиг через env (рекомендуемый механизм)
Ввести `SEARCH_TS_CONFIG` (значения из белого списка: `english` | `russian` |
`simple` | `ru_en`), дефолт `english` (обратная совместимость для текущих
инсталляций). Значение применяется в трёх местах: тело триггера `pages`,
выражение GENERATED-колонки `page_embeddings.fts`, и интерполяция в запросах
(`search.service.ts`, `page-embedding.repo.ts`).
- **Плюсы:** одна понятная ручка; покрывает 95 % кейсов (один язык на инсталляцию).
- **Минусы:** смена значения требует пересборки индексов и переиндексации (см. ниже).
### Вариант B — объединённый RU+EN вектор (значение `ru_en` варианта A)
В тех же местах генерировать `to_tsvector('russian', f_unaccent(x)) || to_tsvector('english', f_unaccent(x))`,
а на стороне запроса OR-ить два `to_tsquery`. **Рекомендуемый дефолт для этой
русско-английской вики.**
- **Плюсы:** морфология и для русского, и для английского без per-row конфига.
- **Минусы:** ~2× размер `tsvector`, чуть «шумнее» ранжирование (приемлемо на
масштабе вики в сотни–тысячи страниц).
### Вариант C — `simple` + pg_trgm
`SEARCH_TS_CONFIG=simple` + триграммный фолбэк (`pg_trgm` уже стоит) для нечёткого
совпадения по `title`/`text_content`.
- **Плюсы:** работает на любом языке без выбора; дёшево.
- **Минусы:** нет морфологии; trgm даёт только похожесть подстрок, не словоформы.
Запасной вариант, если не хотим фиксировать язык.
### Вариант D — per-workspace/per-space `regconfig`
Колонка `search_config regconfig` + триггерное наполнение `pages.tsv`; для
`page_embeddings.fts` пришлось бы заменить GENERATED-колонку на
триггер-наполняемую. Максимум гибкости для мультиязычных инсталляций, максимум
сложности и риска. **Откладываем**, пока не появится реальная мультиязычность.
## Рекомендация
Механизм — **вариант A** (env `SEARCH_TS_CONFIG`, белый список, дефолт `english`),
с поддержкой значения **`ru_en`** (вариант B) и рекомендацией ставить именно его
на этой вики. `simple`/`russian` остаются доступными значениями.
## Точки изменения
**Backend / DB:**
- Новая миграция (timestamp — позже последней применённой, см. правило
упорядочивания миграций в `AGENTS.md`):
- `CREATE OR REPLACE` функции `pages_tsvector_trigger()` с выбранным конфигом.
- Пересборка существующих строк: одноразовый `UPDATE pages SET title = title`
(перефайр триггера) либо явный `UPDATE pages SET tsv = <новое выражение>`.
- `page_embeddings.fts`: `DROP COLUMN fts` + повторный `ADD COLUMN fts … GENERATED …`
с новым конфигом (GENERATED-колонка пересчитается для всех строк
автоматически; переэмбеддинг **не нужен** — это только текст), пересоздать
`idx_page_embeddings_fts`.
- `attachments.tsv` — привести к тому же конфигу после локализации её писателя.
- `environment.validation.ts` / `environment.service.ts`: добавить `SEARCH_TS_CONFIG`
+ геттер `getSearchTsConfig()` с **валидацией по белому списку** (см. безопасность).
- Запросы: [search.service.ts](../apps/server/src/core/search/search.service.ts) (3 места) и
[page-embedding.repo.ts](../apps/server/src/database/repos/ai-chat/page-embedding.repo.ts)
(лексический CTE) — взять конфиг из `EnvironmentService`. Для `ru_en` — OR двух
`to_tsquery`/`websearch_to_tsquery` и `||` двух `to_tsvector`.
- `.env.example` — задокументировать переменную.
**Frontend:** изменений не требуется (поиск получает результаты как раньше).
## Безопасность
Имя `regconfig` **нельзя** интерполировать в SQL как сырую строку из env — это
SQL-инъекция/невалидный конфиг → 500. Разрешать только из **белого списка**
(`english`/`russian`/`simple`/`ru_en`) на уровне геттера; в SQL подставлять уже
сматченное константное имя, а не пользовательский ввод.
## Граничные случаи и оговорки
- **Highlight (`ts_headline`) должен использовать тот же конфиг**, что и матч,
иначе подсветка «съедет». Для `ru_en` подсветку проще делать одним конфигом
(`russian`) либо вызывать `ts_headline` по тому конфигу, который дал матч.
- **Стоп-слова `russian`** удаляются из индекса — по ним искать нельзя (компромисс
морфологии). `simple`/`ru_en` это смягчают.
- **Свежесозданные/изменённые страницы**: `pages.tsv` пересчитывается триггером
на каждый write — без проблем. `page_embeddings.fts` пересчитывается при
следующей переиндексации чанков (BullMQ `reindexPage`), но миграция уже
пересоберёт колонку для всех текущих строк.
- **Переиндексация после смены конфига обязательна** (иначе старые `tsv` останутся
в прежнем языке). Для `pages`/`attachments` — в самой миграции; для крошек/
контента эмбеддингов — кнопка «Reindex now» (см.
[rag-improvements-plan.md](./rag-improvements-plan.md)).
- **Связь с гибридным поиском агента**: меняя конфиг в `page_embeddings.fts` и в
лексическом CTE `page-embedding.repo.ts`, мы меняем и качество RRF-поиска агента
— это согласованное улучшение, но проверить регрессии тестами `ai-chat`.
- **Зависимость с планом «гибридный поиск в основном поиске»**
([hybrid-search-general-plan.md](./hybrid-search-general-plan.md)): оба плана
трогают `'english'` в лексических запросах. Координировать порядок, чтобы конфиг
везде был единым.
## Тестирование
- Интеграционный спек: проиндексировать страницу со словом «серверы», искать
«сервер» → при `russian`/`ru_en` находит, при `english` — нет.
- Смешанный RU+EN документ под `ru_en`: матч и по русской словоформе, и по
английскому термину.
- Проверка whitelist: некорректное значение env → конфигурация падает на старте
(validation), а не уходит в SQL.
- Регрессия MCP `search` и REST `/search` на латинице (поведение `english`
сохраняется при дефолте).