Compare commits
4 Commits
feat/228-i
...
24bf0ab18f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24bf0ab18f | ||
|
|
2936d16a43 | ||
|
|
ddfccb30f3 | ||
|
|
19e083596d |
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
109
apps/server/src/core/ai-chat/roles/ai-agent-roles.controller.ts
Normal file
109
apps/server/src/core/ai-chat/roles/ai-agent-roles.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
19
apps/server/src/core/ai-chat/roles/ai-agent-roles.module.ts
Normal file
19
apps/server/src/core/ai-chat/roles/ai-agent-roles.module.ts
Normal 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 {}
|
||||
196
apps/server/src/core/ai-chat/roles/ai-agent-roles.service.ts
Normal file
196
apps/server/src/core/ai-chat/roles/ai-agent-roles.service.ts
Normal 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;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
/** Path param for the per-role routes (update/delete). */
|
||||
export class AgentRoleIdDto {
|
||||
@IsString()
|
||||
id: string;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
205
apps/server/src/database/repos/ai-chat/ai-agent-role.repo.ts
Normal file
205
apps/server/src/database/repos/ai-chat/ai-agent-role.repo.ts
Normal 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`;
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
45
apps/server/src/database/types/ai-agent-roles.types.ts
Normal file
45
apps/server/src/database/types/ai-agent-roles.types.ts
Normal 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;
|
||||
}
|
||||
3
apps/server/src/database/types/db.d.ts
vendored
3
apps/server/src/database/types/db.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
117
docs/backlog/dependency-updates-and-security-audit.md
Normal file
117
docs/backlog/dependency-updates-and-security-audit.md
Normal 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 ещё не сделал — есть смысл дождаться/подсмотреть их подход,
|
||||
чтобы не расходиться с веткой обновлений.
|
||||
266
docs/history-diff-perf-plan.md
Normal file
266
docs/history-diff-perf-plan.md
Normal 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[] }` — контракт, который потребляет построитель декораций (не меняется).
|
||||
144
docs/hybrid-search-general-plan.md
Normal file
144
docs/hybrid-search-general-plan.md
Normal 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 гибрида и финальным срезом).
|
||||
178
docs/search-morphology-language-plan.md
Normal file
178
docs/search-morphology-language-plan.md
Normal 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`
|
||||
сохраняется при дефолте).
|
||||
Reference in New Issue
Block a user