feat(ai-chat): agent write tools, provenance wiring, chat panel + provider settings UI" -m "Backend:

- Add reversible write tools to the per-user agent toolset (page create/update/
  move/soft-delete; comment reply + resolve), exposed under the user's JWT and
  enforced by Docmost CASL; no permanent/force delete (D3).
- Non-spoofable agent provenance: sign actor/aiChatId into the access and collab
  tokens (TokenService), propagate via jwt.strategy onto the request, and set
  pages.last_updated_source/last_updated_ai_chat_id on REST create/update/move and
  comments.created_source/resolved_source/ai_chat_id.
- packages/mcp: add an optional getCollabToken provider (content-edit provenance)
  and guard against empty tokens; service-account /mcp path unchanged.

Frontend:
- Admin 'AI / Models' settings section: provider/model/embedding/base URL, a
  write-only API key field, system prompt, and Test connection.
- AI chat panel (useChat + DefaultChatTransport): conversation list, streamed
  messages, tool-call action log and page citations; header entry point gated on
  settings.ai.chat.

Compile-verified (server nest build + client tsc/vite); not yet live-tested.
Known gaps: history 'AI agent' badge (C3), vector RAG (D), external MCP (E);
chat tool-card citation links pending a fix.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
vvzvlad
2026-06-17 02:39:26 +03:00
parent 683da7a4c5
commit 44b340dc1a
38 changed files with 2384 additions and 21 deletions

View File

@@ -0,0 +1,11 @@
import { atom } from "jotai";
/**
* The currently selected chat id. `null` means a fresh (not-yet-created) chat:
* the server creates the chat row on the first streamed message and echoes its
* id, which the panel then adopts.
*/
// Note: declare via a cast default rather than `atom<string | null>(null)`,
// which mis-resolves the jotai useAtom overload to the read-only signature
// under this TS/jotai version (the setter would type as `never`).
export const activeAiChatIdAtom = atom(null as string | null);

View File

@@ -0,0 +1,157 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
ActionIcon,
Box,
Collapse,
Divider,
Group,
Loader,
Text,
Title,
Tooltip,
} from "@mantine/core";
import { IconChevronDown, IconPlus, IconX } from "@tabler/icons-react";
import { useDisclosure } from "@mantine/hooks";
import { useAtom } from "jotai";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import { activeAiChatIdAtom } from "@/features/ai-chat/atoms/ai-chat-atom.ts";
import {
AI_CHATS_RQ_KEY,
useAiChatMessagesQuery,
useAiChatsQuery,
} from "@/features/ai-chat/queries/ai-chat-query.ts";
import ConversationList from "@/features/ai-chat/components/conversation-list.tsx";
import ChatThread from "@/features/ai-chat/components/chat-thread.tsx";
/**
* Right-aside AI chat container (§7.1): a header (title + new-chat + close), a
* collapsible conversation switcher, the active chat thread (message list +
* input), all driven by `useChat` inside ChatThread.
*/
export default function AiChatPanel() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [, setAsideState] = useAtom(asideStateAtom);
const [activeChatId, setActiveChatId] = useAtom(activeAiChatIdAtom);
const [listOpen, { toggle: toggleList, close: closeList }] =
useDisclosure(false);
// Track whether we are awaiting the id of a just-created (new) chat, so we can
// adopt it once the chat list refreshes after the first turn finishes.
const adoptNewChat = useRef(false);
const { data: chats } = useAiChatsQuery();
const { data: messageRows, isLoading: messagesLoading } =
useAiChatMessagesQuery(activeChatId ?? undefined);
const closeAside = (): void =>
setAsideState((s) => ({ ...s, isAsideOpen: false }));
const startNewChat = (): void => {
setActiveChatId(null);
closeList();
};
const selectChat = (chatId: string): void => {
setActiveChatId(chatId);
closeList();
};
// After a turn finishes, refresh the chat list. For a brand-new chat (no id
// yet), the server has just created the row; adopt the newest chat id so the
// thread switches from "new" to the persisted chat (and loads its history on
// later opens).
const onTurnFinished = useCallback(() => {
if (activeChatId === null) adoptNewChat.current = true;
queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY });
}, [activeChatId, queryClient]);
// When awaiting a new chat's id, adopt the most-recent chat (the list is
// ordered newest-first) once it appears.
useEffect(() => {
if (!adoptNewChat.current) return;
const newest = chats?.items?.[0];
if (newest) {
adoptNewChat.current = false;
setActiveChatId(newest.id);
}
}, [chats, setActiveChatId]);
// The thread is remounted when the active chat changes so initial messages
// re-seed. For a new chat we key on "new"; adopting the id remounts the thread
// with the persisted history loaded.
const threadKey = activeChatId ?? "new";
const waitingForHistory = activeChatId !== null && messagesLoading;
return (
<Box style={{ height: "100%", display: "flex", flexDirection: "column" }}>
<Group justify="space-between" wrap="nowrap" mb="xs">
<Title order={2} size="h6" fw={500}>
{t("AI chat")}
</Title>
<Group gap={4} wrap="nowrap">
<Tooltip label={t("New chat")} withArrow>
<ActionIcon
variant="subtle"
color="gray"
onClick={startNewChat}
aria-label={t("New chat")}
>
<IconPlus size={18} />
</ActionIcon>
</Tooltip>
<Tooltip label={t("Close")} withArrow>
<ActionIcon
variant="subtle"
color="gray"
onClick={closeAside}
aria-label={t("Close")}
>
<IconX size={18} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
<Box
onClick={toggleList}
style={{ cursor: "pointer" }}
mb={listOpen ? "xs" : 0}
>
<Group gap={4} wrap="nowrap">
<IconChevronDown
size={14}
style={{
transform: listOpen ? "none" : "rotate(-90deg)",
transition: "transform 150ms ease",
}}
/>
<Text size="xs" c="dimmed">
{t("Chat history")}
</Text>
</Group>
</Box>
<Collapse in={listOpen}>
<ConversationList activeChatId={activeChatId} onSelect={selectChat} />
<Divider my="xs" />
</Collapse>
<Box style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
{waitingForHistory ? (
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
) : (
<ChatThread
key={threadKey}
chatId={activeChatId}
initialRows={activeChatId ? messageRows : []}
onTurnFinished={onTurnFinished}
/>
)}
</Box>
</Box>
);
}

View File

@@ -0,0 +1,79 @@
.panel {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.messages {
flex: 1 1 auto;
min-height: 0;
}
.messageRow {
margin-bottom: var(--mantine-spacing-md);
}
.userBubble {
background: var(--mantine-color-gray-light);
border-radius: var(--mantine-radius-md);
padding: 8px 12px;
white-space: pre-wrap;
overflow-wrap: break-word;
word-break: break-word;
}
/* Rendered markdown for assistant messages. Keep block margins compact. */
.markdown {
overflow-wrap: break-word;
word-break: break-word;
}
.markdown p {
margin-block-start: 0;
margin-block-end: 0.5em;
}
.markdown pre {
background: var(--mantine-color-gray-light);
border-radius: var(--mantine-radius-sm);
padding: 8px;
overflow-x: auto;
}
.markdown code {
font-size: 0.85em;
}
.markdown ul,
.markdown ol {
margin-block-start: 0;
margin-block-end: 0.5em;
padding-inline-start: 1.4em;
}
.toolCard {
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
border-radius: var(--mantine-radius-sm);
padding: 6px 10px;
margin-bottom: 6px;
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
.inputWrapper {
flex: 0 0 auto;
padding-top: var(--mantine-spacing-xs);
}
.conversationItem {
cursor: pointer;
border-radius: var(--mantine-radius-sm);
}
.conversationItem:hover {
background: var(--mantine-color-gray-light);
}
.conversationItemActive {
background: var(--mantine-color-gray-light);
}

View File

@@ -0,0 +1,81 @@
import { useState, KeyboardEvent } from "react";
import { ActionIcon, Group, Textarea, Tooltip } from "@mantine/core";
import { IconPlayerStopFilled, IconSend } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
interface ChatInputProps {
onSend: (text: string) => void;
onStop: () => void;
isStreaming: boolean;
disabled?: boolean;
}
/**
* Message composer. Enter sends, Shift+Enter inserts a newline. While the agent
* is streaming, the send button becomes a Stop button (calls `stop()`); the
* textarea stays usable so the user can draft the next turn.
*/
export default function ChatInput({
onSend,
onStop,
isStreaming,
disabled,
}: ChatInputProps) {
const { t } = useTranslation();
const [value, setValue] = useState("");
const send = (): void => {
const text = value.trim();
if (!text || isStreaming || disabled) return;
onSend(text);
setValue("");
};
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
send();
}
};
return (
<Group gap="xs" align="flex-end" wrap="nowrap">
<Textarea
style={{ flex: 1 }}
placeholder={t("Ask the AI agent…")}
value={value}
onChange={(e) => setValue(e.currentTarget.value)}
onKeyDown={handleKeyDown}
autosize
minRows={1}
maxRows={6}
disabled={disabled}
/>
{isStreaming ? (
<Tooltip label={t("Stop")} withArrow>
<ActionIcon
size="lg"
color="red"
variant="light"
onClick={onStop}
aria-label={t("Stop")}
>
<IconPlayerStopFilled size={18} />
</ActionIcon>
</Tooltip>
) : (
<Tooltip label={t("Send")} withArrow>
<ActionIcon
size="lg"
variant="filled"
onClick={send}
disabled={disabled || value.trim().length === 0}
aria-label={t("Send")}
>
<IconSend size={18} />
</ActionIcon>
</Tooltip>
)}
</Group>
);
}

View File

@@ -0,0 +1,129 @@
import { useMemo, useRef } from "react";
import { Alert, Box, Stack } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useChat, type UIMessage } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
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 classes from "@/features/ai-chat/components/ai-chat.module.css";
interface ChatThreadProps {
/** The open chat id, or null for a brand-new (not-yet-created) chat. */
chatId: string | null;
/** Persisted rows to seed initial messages (existing chats only). */
initialRows?: IAiChatMessageRow[];
/** Called when a turn finishes; the parent refreshes the chat list and, for
* a new chat, adopts the freshly created chat id. */
onTurnFinished: () => void;
}
/**
* Map a persisted server row to an AI SDK UIMessage. Mirrors the server's
* `rowToUiMessage`: `metadata.parts` are the UIMessage parts; otherwise fall
* back to a single text part built from the plain-text `content`.
*/
function rowToUiMessage(row: IAiChatMessageRow): UIMessage {
const role = row.role === "assistant" ? "assistant" : "user";
const parts =
Array.isArray(row.metadata?.parts) && row.metadata.parts.length > 0
? row.metadata.parts
: ([{ type: "text", text: row.content ?? "" }] as UIMessage["parts"]);
return { id: row.id, role, parts } as UIMessage;
}
/**
* Owns the AI SDK `useChat` lifecycle for ONE chat. The parent remounts this
* with a `key` when the selected chat changes, so initial messages re-seed
* cleanly (the v6 transport-based hook keeps its state per mount).
*/
export default function ChatThread({
chatId,
initialRows,
onTurnFinished,
}: ChatThreadProps) {
const { t } = useTranslation();
const initialMessages = useMemo<UIMessage[]>(
() => (initialRows ?? []).map(rowToUiMessage),
[initialRows],
);
// The server resolves/creates the chat from the `chatId` in the request body.
// A new chat starts as null; we keep the id in a ref so the SAME hook instance
// can keep streaming to a chat once it exists (the parent adopts the id on
// finish, but within this mount the body carries whatever we know).
const chatIdRef = useRef<string | null>(chatId);
chatIdRef.current = chatId;
const transport = useMemo(
() =>
new DefaultChatTransport<UIMessage>({
api: "/api/ai-chat/stream",
credentials: "include",
// Inject the chat id alongside the useChat messages so the server can
// resolve an existing chat (or create one when null).
prepareSendMessagesRequest: ({ messages, body }) => ({
body: { ...body, chatId: chatIdRef.current, messages },
}),
}),
[],
);
const { messages, sendMessage, status, stop, error } = useChat({
// Key the hook by the chat id so shared-id chats don't collide.
id: chatId ?? undefined,
messages: initialMessages,
transport,
onFinish: () => onTurnFinished(),
});
const isStreaming = status === "submitted" || status === "streaming";
return (
<Box className={classes.panel}>
<MessageList messages={messages} isStreaming={isStreaming} />
{error && (
<Alert
variant="light"
color="red"
icon={<IconAlertTriangle size={16} />}
mb="xs"
title={t("Something went wrong")}
>
{describeError(error, t)}
</Alert>
)}
<Stack gap={0} className={classes.inputWrapper}>
<ChatInput
onSend={(text) => sendMessage({ text })}
onStop={stop}
isStreaming={isStreaming}
/>
</Stack>
</Box>
);
}
/**
* Turn a useChat error into a friendly inline message. The transport throws on
* non-2xx with the response text/status in the message, so we pattern-match the
* gating responses (403 chat disabled, 503 provider not configured) and fall
* back to a generic message otherwise — never a crash.
*/
function describeError(
error: Error,
t: (key: string) => string,
): string {
const msg = error.message ?? "";
if (msg.includes("403") || /disabled/i.test(msg)) {
return t("AI chat is disabled for this workspace.");
}
if (msg.includes("503") || /not configured/i.test(msg)) {
return t("The AI provider is not configured. Ask an administrator to set it up.");
}
return t("The AI agent could not respond. Please try again.");
}

View File

@@ -0,0 +1,155 @@
import { useState } from "react";
import {
ActionIcon,
Box,
Group,
Loader,
Menu,
Text,
TextInput,
} from "@mantine/core";
import { IconDots, IconEdit, IconTrash } from "@tabler/icons-react";
import { modals } from "@mantine/modals";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import {
useAiChatsQuery,
useDeleteAiChatMutation,
useRenameAiChatMutation,
} from "@/features/ai-chat/queries/ai-chat-query.ts";
import { IAiChat } from "@/features/ai-chat/types/ai-chat.types.ts";
import classes from "@/features/ai-chat/components/ai-chat.module.css";
interface ConversationListProps {
activeChatId: string | null;
onSelect: (chatId: string) => void;
}
/**
* The user's chat history. Selecting a chat opens it; rename is inline; delete
* is confirmed. A brand-new (unsaved) chat is not in this list until the server
* persists it on the first message.
*/
export default function ConversationList({
activeChatId,
onSelect,
}: ConversationListProps) {
const { t } = useTranslation();
const { data, isLoading } = useAiChatsQuery();
const renameMutation = useRenameAiChatMutation();
const deleteMutation = useDeleteAiChatMutation();
const [editingId, setEditingId] = useState<string | null>(null);
const [draftTitle, setDraftTitle] = useState("");
const startRename = (chat: IAiChat): void => {
setEditingId(chat.id);
setDraftTitle(chat.title ?? "");
};
const commitRename = (chatId: string): void => {
const title = draftTitle.trim();
setEditingId(null);
if (title) renameMutation.mutate({ chatId, title });
};
const confirmDelete = (chatId: string): void => {
modals.openConfirmModal({
title: t("Delete this chat?"),
centered: true,
labels: { confirm: t("Delete"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => deleteMutation.mutate(chatId),
});
};
if (isLoading) {
return (
<Group justify="center" py="sm">
<Loader size="sm" />
</Group>
);
}
const chats = data?.items ?? [];
if (chats.length === 0) {
return (
<Text size="sm" c="dimmed" py="xs">
{t("No chats yet.")}
</Text>
);
}
return (
<Box>
{chats.map((chat) => {
const isActive = chat.id === activeChatId;
if (editingId === chat.id) {
return (
<Box key={chat.id} px="xs" py={4}>
<TextInput
size="xs"
value={draftTitle}
autoFocus
onChange={(e) => setDraftTitle(e.currentTarget.value)}
onBlur={() => commitRename(chat.id)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commitRename(chat.id);
} else if (e.key === "Escape") {
setEditingId(null);
}
}}
/>
</Box>
);
}
return (
<Group
key={chat.id}
justify="space-between"
wrap="nowrap"
px="xs"
py={6}
className={clsx(
classes.conversationItem,
isActive && classes.conversationItemActive,
)}
onClick={() => onSelect(chat.id)}
>
<Text size="sm" lineClamp={1} style={{ flex: 1 }}>
{chat.title || t("Untitled chat")}
</Text>
<Menu shadow="md" width={180} position="bottom-end">
<Menu.Target>
<ActionIcon
variant="subtle"
color="gray"
aria-label={t("Chat menu")}
onClick={(e) => e.stopPropagation()}
>
<IconDots size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown onClick={(e) => e.stopPropagation()}>
<Menu.Item
leftSection={<IconEdit size={14} />}
onClick={() => startRename(chat)}
>
{t("Rename")}
</Menu.Item>
<Menu.Item
color="red"
leftSection={<IconTrash size={14} />}
onClick={() => confirmDelete(chat.id)}
>
{t("Delete")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
);
})}
</Box>
);
}

View File

@@ -0,0 +1,77 @@
import { Box, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import type { UIMessage } from "@ai-sdk/react";
import ToolCallCard from "@/features/ai-chat/components/tool-call-card.tsx";
import { ToolUiPart } from "@/features/ai-chat/utils/tool-parts.tsx";
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
import classes from "@/features/ai-chat/components/ai-chat.module.css";
interface MessageItemProps {
message: UIMessage;
}
/** True for AI SDK tool parts (static `tool-*` or `dynamic-tool`). */
function isToolPart(type: string): boolean {
return type.startsWith("tool-") || type === "dynamic-tool";
}
/**
* Render a single UIMessage by iterating its `parts`:
* - `text` parts -> sanitized markdown.
* - `tool-*` / `dynamic-tool` parts -> an action-log card (with citations).
* Other part kinds (reasoning, sources, files, step-start) are ignored for v1.
* User messages render their text as a right-aligned plain bubble.
*/
export default function MessageItem({ message }: MessageItemProps) {
const { t } = useTranslation();
const isUser = message.role === "user";
if (isUser) {
const text = message.parts
.filter((p): p is { type: "text"; text: string } => p.type === "text")
.map((p) => p.text)
.join("");
return (
<Box className={classes.messageRow} style={{ display: "flex", justifyContent: "flex-end" }}>
<Box className={classes.userBubble} maw="85%">
{text}
</Box>
</Box>
);
}
return (
<Box className={classes.messageRow}>
<Text size="xs" c="dimmed" mb={4}>
{t("AI agent")}
</Text>
{message.parts.map((part, index) => {
if (part.type === "text") {
const html = renderChatMarkdown(part.text);
if (html) {
return (
<div
key={index}
className={classes.markdown}
// Sanitized by renderChatMarkdown (DOMPurify) before insertion.
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
// Fallback when markdown could not render synchronously: raw text.
return (
<Text key={index} className={classes.markdown} style={{ whiteSpace: "pre-wrap" }}>
{part.text}
</Text>
);
}
if (isToolPart(part.type)) {
return <ToolCallCard key={index} part={part as unknown as ToolUiPart} />;
}
return null;
})}
</Box>
);
}

View File

@@ -0,0 +1,45 @@
import { useEffect, useRef } from "react";
import { Center, ScrollArea, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import type { UIMessage } from "@ai-sdk/react";
import MessageItem from "@/features/ai-chat/components/message-item.tsx";
import classes from "@/features/ai-chat/components/ai-chat.module.css";
interface MessageListProps {
messages: UIMessage[];
isStreaming: boolean;
}
/**
* Scrollable transcript. Auto-scrolls to the newest message as it streams in
* (re-runs whenever the message count or the streaming flag changes).
*/
export default function MessageList({ messages, isStreaming }: MessageListProps) {
const { t } = useTranslation();
const viewportRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = viewportRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [messages.length, isStreaming, messages]);
if (messages.length === 0) {
return (
<Center className={classes.messages}>
<Text size="sm" c="dimmed" ta="center">
{t("Ask the AI agent anything about your workspace.")}
</Text>
</Center>
);
}
return (
<ScrollArea className={classes.messages} viewportRef={viewportRef} scrollbarSize={6} type="scroll">
<Stack gap={0} pr="xs">
{messages.map((message) => (
<MessageItem key={message.id} message={message} />
))}
</Stack>
</ScrollArea>
);
}

View File

@@ -0,0 +1,72 @@
import { Anchor, Group, Loader, Text, ThemeIcon } from "@mantine/core";
import { IconAlertCircle, IconCheck } from "@tabler/icons-react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
getToolName,
toolCitations,
toolLabelKey,
toolRunState,
ToolUiPart,
} from "@/features/ai-chat/utils/tool-parts.tsx";
import classes from "@/features/ai-chat/components/ai-chat.module.css";
interface ToolCallCardProps {
part: ToolUiPart;
}
/**
* Compact action-log card for a single agent tool invocation. It shows what the
* agent DID (the agent writes without confirmation — D2), its run state
* (running / done / error), and citation link(s) to any referenced page(s).
*/
export default function ToolCallCard({ part }: ToolCallCardProps) {
const { t } = useTranslation();
const toolName = getToolName(part);
const state = toolRunState(part.state);
const { key, values } = toolLabelKey(toolName);
const citations = toolCitations(part);
return (
<div className={classes.toolCard}>
<Group gap={6} wrap="nowrap" align="center">
{state === "running" && <Loader size={14} />}
{state === "done" && (
<ThemeIcon size={16} radius="xl" color="green" variant="light">
<IconCheck size={12} />
</ThemeIcon>
)}
{state === "error" && (
<ThemeIcon size={16} radius="xl" color="red" variant="light">
<IconAlertCircle size={12} />
</ThemeIcon>
)}
<Text size="sm" fw={500}>
{t(key, values)}
</Text>
</Group>
{state === "error" && part.errorText && (
<Text size="xs" c="red" mt={2}>
{part.errorText}
</Text>
)}
{citations.length > 0 && (
<Group gap={6} mt={4} wrap="wrap">
{citations.map((c) => (
<Anchor
key={c.pageId}
component={Link}
to={c.href}
size="xs"
lineClamp={1}
>
{c.title || t("Open page")}
</Anchor>
))}
</Group>
)}
</div>
);
}

View File

@@ -0,0 +1,116 @@
import {
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { notifications } from "@mantine/notifications";
import {
deleteAiChat,
getAiChatMessages,
getAiChats,
renameAiChat,
} from "@/features/ai-chat/services/ai-chat-service.ts";
import {
IAiChat,
IAiChatMessageRow,
} from "@/features/ai-chat/types/ai-chat.types.ts";
import { IPagination } from "@/lib/types.ts";
export const AI_CHATS_RQ_KEY = ["ai-chats"];
export const AI_CHAT_MESSAGES_RQ_KEY = (chatId: string) => [
"ai-chat-messages",
chatId,
];
/** Paginated list of the current user's chats (auto-loads further pages). */
export function useAiChatsQuery() {
const query = useInfiniteQuery({
queryKey: AI_CHATS_RQ_KEY,
queryFn: ({ pageParam }) =>
getAiChats({ cursor: pageParam, limit: 50 }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) =>
lastPage.meta.hasNextPage ? (lastPage.meta.nextCursor ?? undefined) : undefined,
});
const data = useMemo<IPagination<IAiChat> | undefined>(() => {
if (!query.data) return undefined;
return {
items: query.data.pages.flatMap((p) => p.items),
meta: query.data.pages[query.data.pages.length - 1].meta,
};
}, [query.data]);
return {
data,
isLoading: query.isLoading,
isError: query.isError,
refetch: query.refetch,
};
}
/**
* Load all persisted messages of a chat (oldest first), flattening the
* paginated server response. Used to seed `useChat` initial messages.
*/
export function useAiChatMessagesQuery(chatId: string | undefined) {
const query = useInfiniteQuery({
queryKey: AI_CHAT_MESSAGES_RQ_KEY(chatId ?? ""),
queryFn: ({ pageParam }) =>
getAiChatMessages({ chatId: chatId as string, cursor: pageParam }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) =>
lastPage.meta.hasNextPage ? (lastPage.meta.nextCursor ?? undefined) : undefined,
enabled: !!chatId,
});
const data = useMemo<IAiChatMessageRow[] | undefined>(() => {
if (!query.data) return undefined;
return query.data.pages.flatMap((p) => p.items);
}, [query.data]);
return {
data,
isLoading: query.isLoading || query.hasNextPage,
isError: query.isError,
};
}
export function useRenameAiChatMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, { chatId: string; title: string }>({
mutationFn: (data) => renameAiChat(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY });
},
onError: () => {
notifications.show({
message: t("Failed to rename chat"),
color: "red",
});
},
});
}
export function useDeleteAiChatMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, string>({
mutationFn: (chatId) => deleteAiChat(chatId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY });
},
onError: () => {
notifications.show({
message: t("Failed to delete chat"),
color: "red",
});
},
});
}

View File

@@ -0,0 +1,48 @@
import api from "@/lib/api-client";
import { IPagination } from "@/lib/types.ts";
import {
IAiChat,
IAiChatListParams,
IAiChatMessageRow,
IAiChatMessagesParams,
} from "@/features/ai-chat/types/ai-chat.types.ts";
/**
* Per-user AI chat CRUD. The server uses POST for reads (its convention) and
* wraps every (non-stream) response in `{ data }` via the global transform
* interceptor, which the axios client unwraps to the body — so we read `.data`
* (mirroring `comment-service`). The `/ai-chat/stream` endpoint is consumed by
* the AI SDK `useChat` transport directly, not here.
*/
/** List the current user's chats (most recent first, paginated). */
export async function getAiChats(
params: IAiChatListParams,
): Promise<IPagination<IAiChat>> {
const req = await api.post<IPagination<IAiChat>>("/ai-chat/chats", params);
return req.data;
}
/** Fetch a chat's messages (oldest first, paginated). */
export async function getAiChatMessages(
params: IAiChatMessagesParams,
): Promise<IPagination<IAiChatMessageRow>> {
const req = await api.post<IPagination<IAiChatMessageRow>>(
"/ai-chat/messages",
params,
);
return req.data;
}
/** Rename a chat. */
export async function renameAiChat(data: {
chatId: string;
title: string;
}): Promise<void> {
await api.post("/ai-chat/rename", data);
}
/** Soft-delete a chat. */
export async function deleteAiChat(chatId: string): Promise<void> {
await api.post("/ai-chat/delete", { chatId });
}

View File

@@ -0,0 +1,38 @@
import { QueryParams } from "@/lib/types.ts";
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.
*/
export interface IAiChat {
id: string;
title: string | null;
creatorId: string;
workspaceId: string;
createdAt: string;
updatedAt: string;
deletedAt?: string | null;
}
/**
* A persisted message row (mirrors the server `ai_chat_messages` baseFields
* returned by `POST /ai-chat/messages`, oldest first). `metadata.parts` holds
* the reconstructable AI SDK UIMessage parts; `content` is the plain-text
* fallback. `tsv` is never selected server-side, so it is not present here.
*/
export interface IAiChatMessageRow {
id: string;
role: "user" | "assistant" | string;
content: string | null;
toolCalls?: unknown;
metadata?: { parts?: UIMessage["parts"] } | null;
createdAt: string;
}
export interface IAiChatListParams extends QueryParams {}
export interface IAiChatMessagesParams {
chatId: string;
cursor?: string;
}

View File

@@ -0,0 +1,20 @@
import { markdownToHtml } from "@docmost/editor-ext";
import DOMPurify from "dompurify";
/**
* Render AI markdown to sanitized HTML for read-only display. We reuse the
* app's `markdownToHtml` (the same `marked` pipeline used for paste/import) so
* chat output matches the editor's markdown flavor, then sanitize with
* DOMPurify — LLM output is untrusted, so it must never reach the DOM unsanitized.
*
* `markdownToHtml` can return `string | Promise<string>` (it has async marked
* extensions registered). In practice plain chat markdown resolves
* synchronously, but we guard the Promise case by returning a safe empty string
* for that branch (the caller renders the raw text fallback instead).
*/
export function renderChatMarkdown(markdown: string): string {
if (!markdown) return "";
const html = markdownToHtml(markdown);
if (typeof html !== "string") return "";
return DOMPurify.sanitize(html);
}

View File

@@ -0,0 +1,124 @@
import { buildPageUrl } from "@/features/page/page.utils.ts";
/**
* Presentation helpers for AI SDK tool UI parts. The agent writes WITHOUT
* confirmation (D2), so a tool part is a LOG of what already happened — never a
* prompt for approval.
*
* A tool part's `type` is `tool-${toolName}` (AI SDK v6 static tool parts) and
* its `state` is one of input-streaming / input-available / output-available /
* output-error (we only surface running / done / error). The server tools are:
* searchPages, getPage, createPage, updatePageContent, renamePage, movePage,
* deletePage, createComment, resolveComment — see ai-chat-tools.service.ts.
*/
/** A tool UI part as it arrives from `useChat` / persisted history. */
export interface ToolUiPart {
type: string; // `tool-${name}` (or `dynamic-tool`)
toolName?: string; // present on dynamic-tool parts
toolCallId?: string;
state?: string;
input?: unknown;
output?: unknown;
errorText?: string;
}
/** Normalized run state surfaced in the action-log card. */
export type ToolRunState = "running" | "done" | "error";
/** A page reference resolved from a tool's input/output, with a citation link. */
export interface ToolCitation {
pageId: string;
title?: string;
/** Internal route; `/p/{slug}-{id}` resolves via PageRedirect by slugId. */
href: string;
}
/** Extract the tool name from a part `type` of `tool-${name}` (or dynamic). */
export function getToolName(part: ToolUiPart): string {
if (part.type === "dynamic-tool") return part.toolName ?? "";
return part.type.startsWith("tool-") ? part.type.slice("tool-".length) : part.type;
}
/** Map an AI SDK tool-part state to the 3 states the action-log renders. */
export function toolRunState(state: string | undefined): ToolRunState {
if (state === "output-error" || state === "output-denied") return "error";
if (state === "output-available") return "done";
// input-streaming / input-available / approval-* -> still running.
return "running";
}
/**
* i18n KEY for a tool's action-log label. Past-tense for completed actions
* (the card is a log). The caller passes the key through `t()`. Unknown tools
* fall back to a generic key with the raw name interpolated.
*/
export function toolLabelKey(toolName: string): {
key: string;
values?: Record<string, string>;
} {
switch (toolName) {
case "searchPages":
return { key: "Searched pages" };
case "getPage":
return { key: "Read page" };
case "createPage":
return { key: "Created page" };
case "updatePageContent":
return { key: "Updated page" };
case "renamePage":
return { key: "Renamed page" };
case "movePage":
return { key: "Moved page" };
case "deletePage":
return { key: "Deleted page (to trash)" };
case "createComment":
return { key: "Commented" };
case "resolveComment":
return { key: "Resolved comment" };
default:
return { key: "Ran tool {{name}}", values: { name: toolName } };
}
}
/** Coerce an unknown record field to a non-empty string, else undefined. */
function asString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
/**
* Resolve the page citation(s) a tool part references, from its input/output.
* Only output-available parts (the tool returned) yield citations. Search
* returns an array of pages; the page-ops return a single page id. We build the
* link from the page id alone — the `/p/{slug}-{id}` route resolves the page by
* its slugId (PageRedirect), so the space slug is not needed here.
*/
export function toolCitations(part: ToolUiPart): ToolCitation[] {
if (part.state !== "output-available") return [];
const out = part.output;
const input = (part.input ?? {}) as Record<string, unknown>;
const citations: ToolCitation[] = [];
const push = (id: string | undefined, title?: string): void => {
if (!id) return;
citations.push({ pageId: id, title, href: buildPageUrl(undefined, id, title) });
};
const toolName = getToolName(part);
if (toolName === "searchPages" && Array.isArray(out)) {
for (const raw of out) {
const item = (raw ?? {}) as Record<string, unknown>;
push(asString(item.id), asString(item.title));
}
return citations;
}
const o = (out ?? {}) as Record<string, unknown>;
// getPage/createPage echo { id?, title }; the page-mutating tools echo pageId.
const pageId =
asString(o.id) ?? asString(o.pageId) ?? asString(input.pageId);
const title = asString(o.title) ?? asString(input.title);
push(pageId, title);
return citations;
}

View File

@@ -12,6 +12,7 @@ import {
IconMarkdown,
IconMessage,
IconPrinter,
IconSparkles,
IconStar,
IconStarFilled,
IconTrash,
@@ -63,6 +64,7 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
const { t } = useTranslation();
const commentsTriggerProps = useAsideTriggerProps("comments");
const tocTriggerProps = useAsideTriggerProps("toc");
const aiChatTriggerProps = useAsideTriggerProps("ai-chat");
const { pageSlug } = useParams();
const { data: page } = usePageQuery({
pageId: extractPageSlugId(pageSlug),
@@ -71,6 +73,8 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
const [workspace] = useAtom(workspaceAtom);
// Community public-sharing entry point (replaces the removed EE PageShareModal)
const workspaceSharingDisabled = workspace?.settings?.sharing?.disabled === true;
// AI chat entry point: only shown when the workspace enables it (A7 gate).
const aiChatEnabled = workspace?.settings?.ai?.chat === true;
useHotkeys(
[
@@ -127,6 +131,19 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
</ActionIcon>
</Tooltip>
{aiChatEnabled && (
<Tooltip label={t("AI chat")} openDelay={250} withArrow>
<ActionIcon
variant="subtle"
color="dark"
aria-label={t("AI chat")}
{...aiChatTriggerProps}
>
<IconSparkles size={20} stroke={2} />
</ActionIcon>
</Tooltip>
)}
<PageActionMenu readOnly={readOnly} />
</>
);

View File

@@ -0,0 +1,242 @@
import { useEffect, useState } from "react";
import { z } from "zod/v4";
import {
Alert,
Button,
Group,
PasswordInput,
Select,
Stack,
Text,
Textarea,
TextInput,
} from "@mantine/core";
import { useForm } from "@mantine/form";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { IconCheck, IconX } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import useUserRole from "@/hooks/use-user-role.tsx";
import {
useAiSettingsQuery,
useTestAiConnectionMutation,
useUpdateAiSettingsMutation,
} from "@/features/workspace/queries/ai-settings-query.ts";
import {
AiDriver,
IAiSettingsUpdate,
} from "@/features/workspace/services/ai-settings-service.ts";
const formSchema = z.object({
driver: z.enum(["openai", "gemini", "ollama"]),
chatModel: z.string(),
embeddingModel: z.string(),
baseUrl: z.string(),
systemPrompt: z.string(),
// Write-only key buffer. Empty string means "do not change" (unless explicitly cleared).
apiKey: z.string(),
});
type FormValues = z.infer<typeof formSchema>;
export default function AiProviderSettings() {
const { t } = useTranslation();
const { isAdmin } = useUserRole();
// Only admins may read the (masked) AI settings; the server enforces this too.
const { data: settings, isLoading } = useAiSettingsQuery(isAdmin);
const updateMutation = useUpdateAiSettingsMutation();
const testMutation = useTestAiConnectionMutation();
// Whether a key is currently stored server-side (drives the placeholder).
const [hasApiKey, setHasApiKey] = useState(false);
// Tracks whether the user explicitly cleared the stored key.
const [keyCleared, setKeyCleared] = useState(false);
const form = useForm<FormValues>({
validate: zod4Resolver(formSchema),
initialValues: {
driver: "openai",
chatModel: "",
embeddingModel: "",
baseUrl: "",
systemPrompt: "",
apiKey: "",
},
});
// Hydrate the form once the masked settings load.
useEffect(() => {
if (!settings) return;
form.setValues({
driver: settings.driver ?? "openai",
chatModel: settings.chatModel ?? "",
embeddingModel: settings.embeddingModel ?? "",
baseUrl: settings.baseUrl ?? "",
systemPrompt: settings.systemPrompt ?? "",
apiKey: "",
});
form.resetDirty();
setHasApiKey(settings.hasApiKey);
setKeyCleared(false);
}, [settings]);
const driver = form.values.driver as AiDriver;
// Ollama runs locally and needs no API key.
const showApiKey = driver === "openai" || driver === "gemini";
// OpenAI and Ollama accept a custom base URL; Gemini does not.
const showBaseUrl = driver === "openai" || driver === "ollama";
function buildPayload(values: FormValues): IAiSettingsUpdate {
const payload: IAiSettingsUpdate = {
driver: values.driver,
chatModel: values.chatModel,
embeddingModel: values.embeddingModel,
// Send the base URL only for providers that use it.
baseUrl: showBaseUrl ? values.baseUrl : "",
systemPrompt: values.systemPrompt,
};
// Key semantics (never send the stored key back):
// - typed a value -> set it
// - explicitly cleared -> send '' to clear
// - untouched -> omit `apiKey` entirely (leave unchanged)
if (showApiKey) {
if (values.apiKey.length > 0) {
payload.apiKey = values.apiKey;
} else if (keyCleared) {
payload.apiKey = "";
}
}
return payload;
}
async function handleSubmit(values: FormValues) {
const updated = await updateMutation.mutateAsync(buildPayload(values));
// Reflect the new key state and reset the write-only buffer.
setHasApiKey(updated.hasApiKey);
setKeyCleared(false);
form.setFieldValue("apiKey", "");
form.resetDirty();
}
function handleClearKey() {
setKeyCleared(true);
setHasApiKey(false);
form.setFieldValue("apiKey", "");
}
const driverOptions = [
{ value: "openai", label: "OpenAI" },
{ value: "gemini", label: "Gemini" },
{ value: "ollama", label: "Ollama" },
];
const testResult = testMutation.data;
return (
<Stack mt="sm">
<Select
label={t("Provider")}
data={driverOptions}
allowDeselect={false}
disabled={!isAdmin || isLoading}
{...form.getInputProps("driver")}
/>
{showApiKey && (
<PasswordInput
label={t("API key")}
// Placeholder hints whether a key is already stored; the value is never shown.
placeholder={hasApiKey ? t("•••• set") : ""}
readOnly={!isAdmin}
autoComplete="off"
{...form.getInputProps("apiKey")}
/>
)}
{showApiKey && isAdmin && hasApiKey && (
<Group justify="flex-start" mt={-8}>
<Button
variant="subtle"
size="compact-sm"
color="red"
onClick={handleClearKey}
>
{t("Clear key")}
</Button>
</Group>
)}
{showBaseUrl && (
<TextInput
label={t("Base URL")}
readOnly={!isAdmin}
{...form.getInputProps("baseUrl")}
/>
)}
<TextInput
label={t("Chat model")}
readOnly={!isAdmin}
{...form.getInputProps("chatModel")}
/>
<TextInput
label={t("Embedding model")}
readOnly={!isAdmin}
{...form.getInputProps("embeddingModel")}
/>
<Textarea
label={t("System message")}
description={t(
"A built-in safety framework is always appended.",
)}
autosize
minRows={3}
maxRows={10}
readOnly={!isAdmin}
{...form.getInputProps("systemPrompt")}
/>
{testResult && (
<Alert
color={testResult.ok ? "green" : "red"}
icon={testResult.ok ? <IconCheck size={16} /> : <IconX size={16} />}
>
{testResult.ok
? t("Connection successful")
: testResult.error || t("Connection failed")}
</Alert>
)}
{isAdmin && (
<Group>
<Button
type="button"
onClick={() => handleSubmit(form.values)}
disabled={updateMutation.isPending || !form.isValid()}
loading={updateMutation.isPending}
>
{t("Save")}
</Button>
<Button
type="button"
variant="default"
onClick={() => testMutation.mutate()}
loading={testMutation.isPending}
>
{t("Test connection")}
</Button>
</Group>
)}
{!isAdmin && (
<Text size="sm" c="dimmed">
{t("Only workspace admins can manage AI provider settings.")}
</Text>
)}
</Stack>
);
}

View File

@@ -0,0 +1,54 @@
import {
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import {
getAiSettings,
updateAiSettings,
testAiConnection,
IAiSettings,
IAiSettingsUpdate,
IAiTestResult,
} from "@/features/workspace/services/ai-settings-service.ts";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
const aiSettingsKey = ["ai-settings"];
export function useAiSettingsQuery(
enabled: boolean = true,
): UseQueryResult<IAiSettings, Error> {
return useQuery({
queryKey: aiSettingsKey,
queryFn: () => getAiSettings(),
enabled,
});
}
export function useUpdateAiSettingsMutation() {
const { t } = useTranslation();
const queryClient = useQueryClient();
return useMutation<IAiSettings, Error, IAiSettingsUpdate>({
mutationFn: (data) => updateAiSettings(data),
onSuccess: () => {
notifications.show({ message: t("Updated successfully") });
queryClient.invalidateQueries({ queryKey: aiSettingsKey });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage ?? t("Failed to update data"),
color: "red",
});
},
});
}
export function useTestAiConnectionMutation() {
return useMutation<IAiTestResult, Error, void>({
mutationFn: () => testAiConnection(),
});
}

View File

@@ -0,0 +1,53 @@
import api from "@/lib/api-client";
// Supported LLM providers/drivers.
export type AiDriver = "openai" | "gemini" | "ollama";
// Masked AI provider settings returned by the server.
// The API key is NEVER returned; only `hasApiKey` indicates whether one is stored.
export interface IAiSettings {
driver?: AiDriver;
chatModel?: string;
embeddingModel?: string;
baseUrl?: string;
systemPrompt?: string;
hasApiKey: boolean;
}
// Update payload. Key semantics:
// - omit `apiKey` -> key unchanged
// - `apiKey: ''` -> clear the stored key
// - `apiKey: 'non-empty'`-> set the key
// Non-secret fields are saved as given.
export interface IAiSettingsUpdate {
driver?: AiDriver;
chatModel?: string;
embeddingModel?: string;
baseUrl?: string;
systemPrompt?: string;
apiKey?: string;
}
// Result of a connection test against the configured provider.
// The error string is already sanitized server-side.
export interface IAiTestResult {
ok: boolean;
error?: string;
}
export async function getAiSettings(): Promise<IAiSettings> {
const req = await api.post<IAiSettings>("/workspace/ai-settings");
return req.data;
}
export async function updateAiSettings(
data: IAiSettingsUpdate,
): Promise<IAiSettings> {
const req = await api.post<IAiSettings>("/workspace/ai-settings/update", data);
return req.data;
}
export async function testAiConnection(): Promise<IAiTestResult> {
const req = await api.post<IAiTestResult>("/workspace/ai-settings/test");
return req.data;
}