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

@@ -12,6 +12,7 @@
"test:watch": "vitest"
},
"dependencies": {
"@ai-sdk/react": "^3.0.208",
"@atlaskit/pragmatic-drag-and-drop": "1.8.1",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "2.1.5",
"@atlaskit/pragmatic-drag-and-drop-flourish": "2.0.15",
@@ -33,10 +34,12 @@
"@tabler/icons-react": "3.40.0",
"@tanstack/react-query": "5.90.17",
"@tanstack/react-virtual": "3.13.24",
"ai": "6.0.207",
"alfaaz": "1.1.0",
"axios": "1.16.0",
"blueimp-load-image": "5.16.0",
"clsx": "2.1.1",
"dompurify": "3.4.1",
"file-saver": "2.0.5",
"highlightjs-sap-abap": "0.3.0",
"i18next": "25.10.1",

View File

@@ -688,6 +688,19 @@
"Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.": "Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.",
"View the <anchor>API documentation</anchor> for usage details.": "View the <anchor>API documentation</anchor> for usage details.",
"View the <anchor>MCP documentation</anchor>.": "View the <anchor>MCP documentation</anchor>.",
"AI / Models": "AI / Models",
"Provider": "Provider",
"•••• set": "•••• set",
"Clear key": "Clear key",
"Base URL": "Base URL",
"Chat model": "Chat model",
"Embedding model": "Embedding model",
"System message": "System message",
"A built-in safety framework is always appended.": "A built-in safety framework is always appended.",
"Test connection": "Test connection",
"Connection successful": "Connection successful",
"Connection failed": "Connection failed",
"Only workspace admins can manage AI provider settings.": "Only workspace admins can manage AI provider settings.",
"Sources": "Sources",
"AI Answers not available for attachments": "AI Answers not available for attachments",
"No answer available": "No answer available",
@@ -1085,5 +1098,30 @@
"Added {{name}} to favorites": "Added {{name}} to favorites",
"Removed {{name}} from favorites": "Removed {{name}} from favorites",
"Page menu for {{name}}": "Page menu for {{name}}",
"Create subpage of {{name}}": "Create subpage of {{name}}"
"Create subpage of {{name}}": "Create subpage of {{name}}",
"AI chat": "AI chat",
"AI agent": "AI agent",
"Send": "Send",
"Stop": "Stop",
"Chat menu": "Chat menu",
"No chats yet.": "No chats yet.",
"Delete this chat?": "Delete this chat?",
"Ask the AI agent…": "Ask the AI agent…",
"Ask the AI agent anything about your workspace.": "Ask the AI agent anything about your workspace.",
"Failed to rename chat": "Failed to rename chat",
"Failed to delete chat": "Failed to delete chat",
"Something went wrong": "Something went wrong",
"AI chat is disabled for this workspace.": "AI chat is disabled for this workspace.",
"The AI provider is not configured. Ask an administrator to set it up.": "The AI provider is not configured. Ask an administrator to set it up.",
"The AI agent could not respond. Please try again.": "The AI agent could not respond. Please try again.",
"Searched pages": "Searched pages",
"Read page": "Read page",
"Created page": "Created page",
"Updated page": "Updated page",
"Renamed page": "Renamed page",
"Moved page": "Moved page",
"Deleted page (to trash)": "Deleted page (to trash)",
"Commented": "Commented",
"Resolved comment": "Resolved comment",
"Ran tool {{name}}": "Ran tool {{name}}"
}

View File

@@ -9,6 +9,7 @@ import { TableOfContents } from "@/features/editor/components/table-of-contents/
import { useAtomValue } from "jotai";
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
import { PageDetailsAside } from "@/features/page-details/components/page-details-aside.tsx";
import AiChatPanel from "@/features/ai-chat/components/ai-chat-panel.tsx";
import { ASIDE_PANEL_ID } from "@/hooks/use-toggle-aside.tsx";
export default function Aside() {
@@ -38,11 +39,26 @@ export default function Aside() {
component = <PageDetailsAside />;
title = "Details";
break;
case "ai-chat":
// The AI chat panel renders its own header (title + new-chat + close) and
// manages its own scrolling, so it bypasses the shared Aside chrome below.
component = <AiChatPanel />;
title = "AI chat";
break;
default:
component = null;
title = null;
}
// The AI chat panel owns the full aside area (its own header + layout).
if (tab === "ai-chat") {
return (
<Box p="md" style={{ height: "100%", display: "flex", flexDirection: "column" }}>
{component}
</Box>
);
}
return (
<Box p="md" style={{ height: "100%", display: "flex", flexDirection: "column" }}>
{component && (

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;
}

View File

@@ -2,13 +2,16 @@ import SettingsTitle from "@/components/settings/settings-title.tsx";
import WorkspaceNameForm from "@/features/workspace/components/settings/components/workspace-name-form";
import WorkspaceIcon from "@/features/workspace/components/settings/components/workspace-icon.tsx";
import McpSettings from "@/features/workspace/components/settings/components/mcp-settings.tsx";
import AiProviderSettings from "@/features/workspace/components/settings/components/ai-provider-settings.tsx";
import { useTranslation } from "react-i18next";
import { getAppName } from "@/lib/config.ts";
import { Helmet } from "react-helmet-async";
import { Divider } from "@mantine/core";
import useUserRole from "@/hooks/use-user-role.tsx";
export default function WorkspaceSettings() {
const { t } = useTranslation();
const { isAdmin } = useUserRole();
return (
<>
<Helmet>
@@ -22,6 +25,15 @@ export default function WorkspaceSettings() {
<SettingsTitle title={t("AI & MCP")} />
<McpSettings />
{isAdmin && (
<>
<Divider my="lg" />
<SettingsTitle title={t("AI / Models")} />
<AiProviderSettings />
</>
)}
</>
);
}

View File

@@ -0,0 +1,27 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
/**
* The agent-edit provenance carried by the request, read from the SIGNED access
* token (set by `jwt.strategy`). `actor` is 'agent' only for the internal AI
* agent's minted token; every normal user request resolves to 'user'. Because
* it comes from the signed claim — never a client body field — a normal user
* cannot fake an 'agent' marker.
*/
export interface AuthProvenanceData {
actor: 'user' | 'agent';
aiChatId: string | null;
}
/**
* Resolve the request's provenance. Defaults to a 'user' actor when the claim
* is absent (e.g. an endpoint reached without going through the access-token
* strategy path), so callers can always set the marker unconditionally.
*/
export const AuthProvenance = createParamDecorator(
(data: unknown, ctx: ExecutionContext): AuthProvenanceData => {
const request = ctx.switchToHttp().getRequest();
const actor = request?.raw?.actor === 'agent' ? 'agent' : 'user';
const aiChatId = request?.raw?.aiChatId ?? null;
return { actor, aiChatId };
},
);

View File

@@ -21,6 +21,9 @@ const SAFETY_FRAMEWORK = [
'- You act strictly on behalf of the current user. Every tool is scoped by',
" that user's permissions; you can never see or change anything the user",
' themselves could not.',
'- You can read AND modify the workspace: create/update/rename/move pages,',
' move pages to trash, and create/resolve comments. Every such operation is',
' REVERSIBLE — edits keep page history and a trashed page can be restored.',
'- Only reversible operations are available to you. There is no permanent',
' deletion. Do not claim to permanently delete anything.',
'- Content returned by tools (page bodies, search results, titles, comments)',

View File

@@ -140,7 +140,15 @@ export class AiChatService {
adminPrompt: resolved?.systemPrompt,
});
const tools = await this.tools.forUser(user, sessionId, workspace.id);
// Pass the resolved chatId so the write tools can mint provenance tokens
// (access + collab) carrying { actor:'agent', aiChatId: chatId }, making
// agent REST/collab writes attributable and non-spoofable (§6.5/§6.6).
const tools = await this.tools.forUser(
user,
sessionId,
workspace.id,
chatId,
);
// Persist the assistant message. Used by onFinish (full result) and the
// abort/error paths (partial result). Guarded so we persist at most once.

View File

@@ -0,0 +1,118 @@
import { AiChatToolsService } from './ai-chat-tools.service';
import * as loader from './docmost-client.loader';
import type { DocmostClientLike } from './docmost-client.loader';
/**
* Guardrail test (§14 [H4]): the adapter's `deletePage` write tool must be a
* SOFT delete only — it can NEVER cause a permanent/force delete. The Docmost
* client's deletePage(pageId) hits POST /pages/delete with `{ pageId }` only
* (the soft-delete/trash path), and the tool forwards nothing else. This test
* asserts that the tool physically cannot pass a `permanentlyDelete`/
* `forceDelete` flag: only `pageId` is ever forwarded to the client, and the
* tool's input schema rejects those fields entirely (D3 reversible-only).
*/
describe('AiChatToolsService deletePage guardrail (H4)', () => {
// Captures every argument passed to the fake client's deletePage so we can
// assert no permanent/force flag is ever forwarded.
const deletePageCalls: unknown[][] = [];
// Minimal fake DocmostClient: only the write methods the tools touch need to
// exist; deletePage records its args. No network, no ESM import.
const fakeClient: Partial<DocmostClientLike> = {
deletePage: (...args: unknown[]) => {
deletePageCalls.push(args);
return Promise.resolve({ success: true });
},
};
// Stub TokenService: the guardrail does not exercise auth, only the tool's
// payload, so any non-empty token works.
const tokenServiceStub = {
generateAccessToken: jest.fn().mockResolvedValue('access-token'),
generateCollabToken: jest.fn().mockResolvedValue('collab-token'),
};
let service: AiChatToolsService;
beforeEach(() => {
deletePageCalls.length = 0;
// Intercept the ESM loader so `new DocmostClient(config)` returns our fake.
jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue({
DocmostClient: function () {
return fakeClient as DocmostClientLike;
} as unknown as loader.DocmostClientCtor,
});
service = new AiChatToolsService(tokenServiceStub as never);
});
afterEach(() => {
jest.restoreAllMocks();
});
function buildTools() {
return service.forUser(
{ id: 'user-1', email: 'u@example.com', workspaceId: 'ws-1' } as never,
'session-1',
'ws-1',
'chat-1',
);
}
it('forwards ONLY pageId to the client (no permanent/force flag)', async () => {
const tools = await buildTools();
const deletePage = tools.deletePage;
await deletePage.execute(
{ pageId: 'page-123' } as never,
{} as never,
);
expect(deletePageCalls).toHaveLength(1);
// The client must be called with exactly one positional argument: pageId.
expect(deletePageCalls[0]).toEqual(['page-123']);
});
it('ignores any permanentlyDelete/forceDelete passed in the input', async () => {
const tools = await buildTools();
const deletePage = tools.deletePage;
// Even if a (compromised) model emitted these fields, the execute body only
// destructures `pageId`, so they can never reach the client.
await deletePage.execute(
{
pageId: 'page-456',
permanentlyDelete: true,
forceDelete: true,
} as never,
{} as never,
);
expect(deletePageCalls).toHaveLength(1);
const [forwardedArgs] = deletePageCalls;
// Only pageId is forwarded — no second arg, and the forwarded value is a
// bare string id, never an object carrying a delete flag.
expect(forwardedArgs).toEqual(['page-456']);
expect(typeof forwardedArgs[0]).toBe('string');
});
it('does not declare permanentlyDelete/forceDelete in the tool input schema', async () => {
const tools = await buildTools();
const deletePage = tools.deletePage;
// The Zod input schema only allows `pageId`; parsing strips/ignores extra
// keys, so a permanent/force flag is never part of the validated input.
const schema = (deletePage as unknown as { inputSchema: unknown })
.inputSchema as {
parse: (v: unknown) => Record<string, unknown>;
};
const parsed = schema.parse({
pageId: 'page-789',
permanentlyDelete: true,
forceDelete: true,
});
expect(parsed).toHaveProperty('pageId', 'page-789');
expect(parsed).not.toHaveProperty('permanentlyDelete');
expect(parsed).not.toHaveProperty('forceDelete');
});
});

View File

@@ -29,23 +29,47 @@ export class AiChatToolsService {
async forUser(
user: User,
sessionId: string,
// workspaceId is accepted for symmetry with the rest of the chat pipeline
// and to document the single-workspace assumption; the loopback client is
// workspaceId scopes the provenance collab token (which is workspace-bound),
// and documents the single-workspace assumption; the loopback REST client is
// scoped by the user's JWT, not by an explicit workspace argument.
_workspaceId: string,
workspaceId: string,
// The resolved AI chat id. Threaded into both provenance tokens so every
// agent write (REST + collab) records { actor:'agent', aiChatId } off a
// SIGNED claim — non-spoofable, never a client body field (§6.5/§6.6).
aiChatId: string,
): Promise<Record<string, Tool>> {
const apiUrl =
process.env.MCP_DOCMOST_API_URL ||
`http://127.0.0.1:${process.env.PORT || 3000}/api`;
// BARE access JWT (the client adds the "Bearer " prefix and re-calls this
// on a 401). Minted against the live session so jwt.strategy validates it
// (§15[C1]).
// BARE access JWT carrying the agent provenance claim (the client adds the
// "Bearer " prefix and re-calls this on a 401). Minted against the live
// session so jwt.strategy validates it (§15[C1]); the signed actor/aiChatId
// drives the REST write provenance (create/rename/move page, comment
// create/resolve) server-side.
const getToken = () =>
this.tokenService.generateAccessToken(user, sessionId);
this.tokenService.generateAccessToken(user, sessionId, {
actor: 'agent',
aiChatId,
});
// Provenance COLLAB token for content mutations (which go over the collab
// websocket). Signed with the same agent claim so onAuthenticate ->
// onStoreDocument record 'agent'/aiChatId on the page (§6.6/§15 C2). The
// client routes every content mutation through this provider instead of
// POST /auth/collab-token.
const getCollabToken = () =>
this.tokenService.generateCollabToken(user, workspaceId, {
actor: 'agent',
aiChatId,
});
const { DocmostClient } = await loadDocmostMcp();
const client: DocmostClientLike = new DocmostClient({ apiUrl, getToken });
const client: DocmostClientLike = new DocmostClient({
apiUrl,
getToken,
getCollabToken,
});
return {
searchPages: tool({
@@ -104,6 +128,172 @@ export class AiChatToolsService {
};
},
}),
// --- WRITE tools (all reversible — history/trash; §6.5 / D3) ---
createPage: tool({
description:
'Create a new page with a Markdown body in a space, optionally under ' +
'a parent page. Returns the new page id and title. Reversible: a page ' +
'can be moved to trash later.',
inputSchema: z.object({
title: z.string().describe('The title of the new page.'),
content: z
.string()
.describe('The page body as Markdown (may be empty).'),
spaceId: z
.string()
.describe('The id of the space to create the page in.'),
parentPageId: z
.string()
.optional()
.describe('Optional parent page id to nest the new page under.'),
}),
execute: async ({ title, content, spaceId, parentPageId }) => {
// createPage(title, content, spaceId, parentPageId?) ->
// { data: filterPage(page, markdown), success }.
const result = await client.createPage(
title,
content ?? '',
spaceId,
parentPageId,
);
const data = (result?.data ?? {}) as {
id?: string;
slugId?: string;
title?: string;
};
return { id: data.id ?? data.slugId, title: data.title ?? title };
},
}),
updatePageContent: tool({
description:
"Replace a page's body with new Markdown content (and optionally its " +
'title). Reversible: the previous version is kept in page history.',
inputSchema: z.object({
pageId: z.string().describe('The id of the page to update.'),
content: z.string().describe('The new page body as Markdown.'),
title: z
.string()
.optional()
.describe('Optional new title for the page.'),
}),
execute: async ({ pageId, content, title }) => {
// updatePage mutates the live collab doc -> provenance flows from the
// collab-token provider. Returns { success, modified, message, pageId }.
const result = (await client.updatePage(pageId, content, title)) as {
success?: boolean;
};
return { pageId, updated: result?.success ?? true };
},
}),
renamePage: tool({
description:
"Rename a page (change its title only; the body is untouched). " +
'Reversible: rename back at any time.',
inputSchema: z.object({
pageId: z.string().describe('The id of the page to rename.'),
title: z.string().describe('The new title.'),
}),
execute: async ({ pageId, title }) => {
// renamePage(pageId, title) -> { success, pageId, title }.
await client.renamePage(pageId, title);
return { pageId, title };
},
}),
movePage: tool({
description:
'Move a page under a new parent page, or to the space root when no ' +
'parent is given. Reversible: move it back at any time.',
inputSchema: z.object({
pageId: z.string().describe('The id of the page to move.'),
parentPageId: z
.string()
.nullable()
.optional()
.describe(
'Target parent page id. Null/omitted moves the page to the ' +
'space root.',
),
}),
execute: async ({ pageId, parentPageId }) => {
// movePage(pageId, parentPageId, position?) -> raw move response.
await client.movePage(pageId, parentPageId ?? null);
return { pageId, parentPageId: parentPageId ?? null, moved: true };
},
}),
deletePage: tool({
description:
'Move a page to the trash (SOFT delete only — fully reversible; the ' +
'page can be restored from trash). This NEVER permanently deletes.',
inputSchema: z.object({
pageId: z.string().describe('The id of the page to move to trash.'),
}),
// GUARDRAIL (§14 H4): the only field ever passed to the client is
// pageId. permanentlyDelete/forceDelete are not part of the schema and
// are never forwarded, so the agent physically cannot permanently
// delete a page through this tool.
execute: async ({ pageId }) => {
// deletePage(pageId) hits POST /pages/delete with { pageId } only,
// which is the soft-delete (trash) path on the server.
await client.deletePage(pageId);
return { pageId, trashed: true };
},
}),
createComment: tool({
description:
'Add a comment to a page, or reply to an existing top-level comment ' +
'(one level only — the backend rejects replies to replies). ' +
'Reversible via the comment UI.',
inputSchema: z.object({
pageId: z.string().describe('The id of the page to comment on.'),
content: z.string().describe('The comment body as Markdown.'),
parentCommentId: z
.string()
.optional()
.describe(
'Optional id of a TOP-LEVEL comment to reply to (one level ' +
'of replies only).',
),
}),
execute: async ({ pageId, content, parentCommentId }) => {
// createComment(pageId, content, type, selection?, parentCommentId?).
// Page-type comment (no inline selection); replies inherit the anchor.
const result = await client.createComment(
pageId,
content,
'page',
undefined,
parentCommentId,
);
const data = (result?.data ?? {}) as { id?: string };
return { commentId: data.id, pageId };
},
}),
resolveComment: tool({
description:
'Resolve or reopen a top-level comment thread (reversible — toggle ' +
'the resolved flag). Only top-level comments can be resolved.',
inputSchema: z.object({
commentId: z
.string()
.describe('The id of the top-level comment to resolve/reopen.'),
resolved: z
.boolean()
.describe('true to resolve the thread, false to reopen it.'),
}),
execute: async ({ commentId, resolved }) => {
// resolveComment(commentId, resolved) -> { success, commentId, resolved }.
await client.resolveComment(commentId, resolved);
return { commentId, resolved };
},
}),
};
}
}

View File

@@ -2,11 +2,12 @@ import { pathToFileURL } from 'node:url';
/**
* Minimal structural type for the `DocmostClient` class we consume from the
* ESM-only `@docmost/mcp` package. We only need the constructor + the read
* ESM-only `@docmost/mcp` package. We only need the constructor + the read/write
* methods used by the per-user tool adapter; the full client surface lives in
* `packages/mcp/src/client.ts`.
* `packages/mcp/src/client.ts`. Signatures here mirror that file exactly.
*/
export interface DocmostClientLike {
// --- read ---
search(
query: string,
spaceId?: string,
@@ -15,11 +16,52 @@ export interface DocmostClientLike {
getPage(
pageId: string,
): Promise<{ data: Record<string, unknown>; success: boolean }>;
// --- write (page) ---
createPage(
title: string,
content: string,
spaceId: string,
parentPageId?: string,
): Promise<{ data: Record<string, unknown>; success: boolean }>;
// Markdown content update via the collab path (carries provenance via the
// collab-token provider). Optionally also updates the title.
updatePage(
pageId: string,
content: string,
title?: string,
): Promise<Record<string, unknown>>;
// Title-only rename via REST.
renamePage(
pageId: string,
title: string,
): Promise<Record<string, unknown>>;
// Move via REST. parentPageId null => move to space root.
movePage(
pageId: string,
parentPageId: string | null,
position?: string,
): Promise<unknown>;
// SOFT delete only (POST /pages/delete with { pageId }). NEVER permanent.
deletePage(pageId: string): Promise<unknown>;
// --- write (comment) ---
createComment(
pageId: string,
content: string,
type?: 'page' | 'inline',
selection?: string,
parentCommentId?: string,
): Promise<{ data: Record<string, unknown>; success: boolean }>;
resolveComment(
commentId: string,
resolved: boolean,
): Promise<Record<string, unknown>>;
}
export type DocmostClientConfig = {
apiUrl: string;
getToken: () => Promise<string>;
// Provenance collab-token provider for content mutations (signed agent claim).
getCollabToken?: () => Promise<string>;
};
export interface DocmostClientCtor {

View File

@@ -14,6 +14,13 @@ export type JwtPayload = {
workspaceId: string;
type: 'access';
sessionId?: string;
// Optional agent-edit provenance, signed into the access token. Absent for a
// normal user token (treated as 'user'); set only when the internal agent
// mints a provenance access token so REST writes (create/rename/move page,
// comment create/resolve) record a non-spoofable 'agent' marker (§6.5 / §15
// C3 / §14 N2).
actor?: 'user' | 'agent';
aiChatId?: string;
};
export type JwtCollabPayload = {

View File

@@ -27,7 +27,15 @@ export class TokenService {
private environmentService: EnvironmentService,
) {}
async generateAccessToken(user: User, sessionId: string): Promise<string> {
async generateAccessToken(
user: User,
sessionId: string,
// Optional agent-edit provenance. When omitted (the normal user path), the
// token carries no actor/aiChatId and is treated as 'user' downstream. The
// internal agent passes { actor:'agent', aiChatId } so REST writes record a
// non-spoofable 'agent' marker off the signed claim (§6.5 / §15 C3 / §14 N2).
provenance?: { actor: 'agent'; aiChatId: string },
): Promise<string> {
if (isUserDisabled(user)) {
throw new ForbiddenException();
}
@@ -38,6 +46,9 @@ export class TokenService {
workspaceId: user.workspaceId,
type: JwtType.ACCESS,
sessionId,
...(provenance
? { actor: provenance.actor, aiChatId: provenance.aiChatId }
: {}),
};
return this.jwtService.sign(payload);
}

View File

@@ -71,6 +71,15 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
this.sessionActivityService.trackActivity(sessionId, payload.sub, payload.workspaceId);
}
// Propagate the signed agent-edit provenance claim onto the request so REST
// services/controllers can set the 'agent' marker off it. A normal user
// token carries no actor claim and resolves to 'user' (unchanged behaviour);
// only the internal agent's minted token sets actor='agent' + aiChatId. This
// is read server-side from the SIGNED token, never from a client body field,
// so a normal user cannot fake an 'agent' badge.
req.raw.actor = (payload as JwtPayload).actor ?? 'user';
req.raw.aiChatId = (payload as JwtPayload).aiChatId ?? null;
return { user, workspace };
}

View File

@@ -17,6 +17,10 @@ import { ResolveCommentDto } from './dto/resolve-comment.dto';
import { PageIdDto, CommentIdDto } from './dto/comments.input';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import {
AuthProvenance,
AuthProvenanceData,
} from '../../common/decorators/auth-provenance.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { User, Workspace } from '@docmost/db/types/entity.types';
@@ -54,6 +58,7 @@ export class CommentController {
@Body() createCommentDto: CreateCommentDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
@AuthProvenance() provenance: AuthProvenanceData,
) {
const page = await this.pageRepo.findById(createCommentDto.pageId);
if (!page || page.deletedAt) {
@@ -69,6 +74,7 @@ export class CommentController {
user,
},
createCommentDto,
provenance,
);
this.auditService.log({
@@ -147,6 +153,7 @@ export class CommentController {
@Body() dto: ResolveCommentDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
@AuthProvenance() provenance: AuthProvenanceData,
) {
const comment = await this.commentRepo.findById(dto.commentId, {
includeCreator: true,
@@ -172,6 +179,7 @@ export class CommentController {
comment,
dto.resolved,
user,
provenance,
);
this.auditService.log({

View File

@@ -22,6 +22,7 @@ import {
ICommentResolvedNotificationJob,
} from '../../integrations/queue/constants/queue.interface';
import { WsService } from '../../ws/ws.service';
import { AuthProvenanceData } from '../../common/decorators/auth-provenance.decorator';
@Injectable()
export class CommentService {
@@ -52,9 +53,14 @@ export class CommentService {
async create(
opts: { page: Page; workspaceId: string; user: User },
createCommentDto: CreateCommentDto,
// Optional agent-edit provenance (from the signed access claim). When the
// actor is 'agent', stamp created_source/ai_chat_id so an agent-authored
// comment (incl. a reply) shows the AI marker (§15 C3). Normal user: default.
provenance?: AuthProvenanceData,
) {
const { page, workspaceId, user } = opts;
const commentContent = JSON.parse(createCommentDto.content);
const isAgent = provenance?.actor === 'agent';
if (createCommentDto.parentCommentId) {
const parentComment = await this.commentRepo.findById(
@@ -79,6 +85,11 @@ export class CommentService {
creatorId: user.id,
workspaceId: workspaceId,
spaceId: page.spaceId,
// Agent-edit provenance: the user stays creatorId; this only annotates the
// source. Normal user requests leave the column default ('user').
...(isAgent
? { createdSource: 'agent', aiChatId: provenance.aiChatId }
: {}),
});
if (createCommentDto.yjsSelection) {
@@ -213,12 +224,22 @@ export class CommentService {
comment: Comment,
resolved: boolean,
authUser: User,
// Optional agent-edit provenance (from the signed access claim). When the
// actor is 'agent' and the thread is being resolved, stamp resolved_source
// so the "resolved by" mark shows the AI marker (§15 C3). On unresolve the
// source is cleared alongside resolvedAt/resolvedById.
provenance?: AuthProvenanceData,
): Promise<Comment> {
const resolvedAt = resolved ? new Date() : null;
const resolvedById = resolved ? authUser.id : null;
const isAgent = provenance?.actor === 'agent';
// Set the agent marker only when resolving; on unresolve clear it back to
// null so a reopened thread carries no stale source. A normal user resolve
// leaves resolved_source null (no agent annotation).
const resolvedSource = resolved && isAgent ? 'agent' : null;
await this.commentRepo.updateComment(
{ resolvedAt, resolvedById },
{ resolvedAt, resolvedById, resolvedSource },
comment.id,
);

View File

@@ -25,6 +25,10 @@ import {
import { PageHistoryService } from './services/page-history.service';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import {
AuthProvenance,
AuthProvenanceData,
} from '../../common/decorators/auth-provenance.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { Page, User, Workspace } from '@docmost/db/types/entity.types';
@@ -203,6 +207,7 @@ export class PageController {
@Body() createPageDto: CreatePageDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
@AuthProvenance() provenance: AuthProvenanceData,
) {
if (createPageDto.parentPageId) {
// Creating under a parent page - check edit permission on parent
@@ -232,6 +237,7 @@ export class PageController {
user.id,
workspace.id,
createPageDto,
provenance,
);
const { canEdit, hasRestriction } =
@@ -269,7 +275,11 @@ export class PageController {
@HttpCode(HttpStatus.OK)
@Post('update')
async update(@Body() updatePageDto: UpdatePageDto, @AuthUser() user: User) {
async update(
@Body() updatePageDto: UpdatePageDto,
@AuthUser() user: User,
@AuthProvenance() provenance: AuthProvenanceData,
) {
const page = await this.pageRepo.findById(updatePageDto.pageId);
if (!page) {
@@ -285,6 +295,7 @@ export class PageController {
page,
updatePageDto,
user,
provenance,
);
const permissions = { canEdit: true, hasRestriction };
@@ -572,6 +583,7 @@ export class PageController {
async movePageToSpace(
@Body() dto: MovePageToSpaceDto,
@AuthUser() user: User,
@AuthProvenance() provenance: AuthProvenanceData,
) {
const movedPage = await this.pageRepo.findById(dto.pageId);
if (!movedPage) {
@@ -602,6 +614,7 @@ export class PageController {
movedPage,
dto.spaceId,
user.id,
provenance,
);
this.auditService.log({
@@ -706,7 +719,11 @@ export class PageController {
@HttpCode(HttpStatus.OK)
@Post('move')
async movePage(@Body() dto: MovePageDto, @AuthUser() user: User) {
async movePage(
@Body() dto: MovePageDto,
@AuthUser() user: User,
@AuthProvenance() provenance: AuthProvenanceData,
) {
const movedPage = await this.pageRepo.findById(dto.pageId);
if (!movedPage) {
throw new NotFoundException('Moved page not found');
@@ -733,7 +750,7 @@ export class PageController {
await this.pageAccessService.validateCanEdit(targetParent, user);
}
return this.pageService.movePage(dto, movedPage);
return this.pageService.movePage(dto, movedPage, provenance);
}
@HttpCode(HttpStatus.OK)

View File

@@ -55,6 +55,7 @@ import { markdownToHtml } from '@docmost/editor-ext';
import { WatcherService } from '../../watcher/watcher.service';
import { sql } from 'kysely';
import { TransclusionService } from '../transclusion/transclusion.service';
import { AuthProvenanceData } from '../../../common/decorators/auth-provenance.decorator';
@Injectable()
export class PageService {
@@ -92,6 +93,11 @@ export class PageService {
userId: string,
workspaceId: string,
createPageDto: CreatePageDto,
// Optional agent-edit provenance (from the signed access claim). When the
// actor is 'agent', stamp the page's source marker so a freshly created page
// shows it was created by the AI agent (§14 N2) — create goes through REST,
// not collab, so the collab-token claim never reaches it.
provenance?: AuthProvenanceData,
): Promise<Page> {
let parentPageId = undefined;
@@ -127,6 +133,8 @@ export class PageService {
ydoc = createYdocFromJson(prosemirrorJson);
}
const isAgent = provenance?.actor === 'agent';
const page = await this.pageRepo.insertPage({
slugId: generateSlugId(),
title: createPageDto.title,
@@ -140,6 +148,15 @@ export class PageService {
creatorId: userId,
workspaceId: workspaceId,
lastUpdatedById: userId,
// Agent-edit provenance. The human stays the responsible author
// (creatorId/lastUpdatedById); these only annotate the source. A normal
// user request leaves the column default ('user').
...(isAgent
? {
lastUpdatedSource: 'agent',
lastUpdatedAiChatId: provenance.aiChatId,
}
: {}),
content,
textContent,
ydoc,
@@ -204,16 +221,29 @@ export class PageService {
page: Page,
updatePageDto: UpdatePageDto,
user: User,
// Optional agent-edit provenance (from the signed access claim). Stamps the
// source marker on a REST rename/update by the agent (§6.6 REST path).
provenance?: AuthProvenanceData,
): Promise<Page> {
const contributors = new Set<string>(page.contributorIds);
contributors.add(user.id);
const contributorIds = Array.from(contributors);
const isAgent = provenance?.actor === 'agent';
await this.pageRepo.updatePage(
{
title: updatePageDto.title,
icon: updatePageDto.icon,
lastUpdatedById: user.id,
// Agent-edit provenance: annotate the source without changing the
// responsible author. A normal user request leaves the column default.
...(isAgent
? {
lastUpdatedSource: 'agent',
lastUpdatedAiChatId: provenance.aiChatId,
}
: {}),
updatedAt: new Date(),
contributorIds: contributorIds,
},
@@ -374,8 +404,16 @@ export class PageService {
return result;
}
async movePageToSpace(rootPage: Page, spaceId: string, userId: string) {
async movePageToSpace(
rootPage: Page,
spaceId: string,
userId: string,
// Optional agent-edit provenance (from the signed access claim). Stamps the
// source marker on the moved root page when the agent moves it (§6.6 REST).
provenance?: AuthProvenanceData,
) {
let childPageIds: string[] = [];
const isAgent = provenance?.actor === 'agent';
const allPages = await this.pageRepo.getPageAndDescendants(rootPage.id, {
includeContent: false,
@@ -415,7 +453,20 @@ export class PageService {
// Update root page
const nextPosition = await this.nextPagePosition(spaceId);
await this.pageRepo.updatePage(
{ spaceId, parentPageId: null, position: nextPosition },
{
spaceId,
parentPageId: null,
position: nextPosition,
// Agent-edit provenance on the moved root page. Child pages are bulk
// re-parented to the new space (no content change), so the marker is
// stamped on the root the agent acted on. Normal user: no change.
...(isAgent
? {
lastUpdatedSource: 'agent',
lastUpdatedAiChatId: provenance.aiChatId,
}
: {}),
},
rootPage.id,
trx,
);
@@ -787,7 +838,13 @@ export class PageService {
};
}
async movePage(dto: MovePageDto, movedPage: Page) {
async movePage(
dto: MovePageDto,
movedPage: Page,
// Optional agent-edit provenance (from the signed access claim). Stamps the
// source marker when the agent moves a page via REST (§6.6 REST path).
provenance?: AuthProvenanceData,
) {
// validate position value by attempting to generate a key
try {
generateJitteredKeyBetween(dto.position, null);
@@ -813,10 +870,20 @@ export class PageService {
}
}
const isAgent = provenance?.actor === 'agent';
await this.pageRepo.updatePage(
{
position: dto.position,
parentPageId: parentPageId,
// Agent-edit provenance: annotate the source on an agent move. A normal
// user request leaves the column default ('user').
...(isAgent
? {
lastUpdatedSource: 'agent',
lastUpdatedAiChatId: provenance.aiChatId,
}
: {}),
},
dto.pageId,
);

View File

@@ -29,6 +29,10 @@ export class DocmostClient {
// Per-user token provider. When set, login() calls it to obtain a BARE access
// JWT instead of performLogin, and the 401/403 re-auth path re-calls it.
getTokenFn = null;
// Optional collab-token provider. When set, getCollabTokenWithReauth() returns
// its token instead of calling POST /auth/collab-token; on a 401/403 it is
// re-invoked once. Used by the internal agent to carry signed provenance.
getCollabTokenFn = null;
// In-flight login dedup: when the token expires, the 401 interceptor,
// ensureAuthenticated, getCollabTokenWithReauth and the two multipart retries
// can all call login() at once. Memoizing a single promise collapses that
@@ -50,6 +54,11 @@ export class DocmostClient {
this.email = config.email;
this.password = config.password;
}
// Optional, available to both variants. When present, content mutations get
// their collab token from here instead of POST /auth/collab-token.
if (config.getCollabToken) {
this.getCollabTokenFn = config.getCollabToken;
}
this.client = axios.create({
baseURL: this.apiUrl,
// Default request timeout so a hung connection cannot wedge a per-page
@@ -112,6 +121,13 @@ export class DocmostClient {
: performLogin(this.apiUrl, this.email, this.password);
this.loginPromise = fetchToken
.then((token) => {
// Guard against an empty/invalid token (e.g. a getToken provider that
// resolves to "" or null): without this an empty token would set a
// literal "Authorization: Bearer null"/"Bearer " header and every
// request would 401 with a confusing error. Fail loudly instead.
if (typeof token !== "string" || token.length === 0) {
throw new Error("getToken returned an empty token");
}
this.token = token;
this.client.defaults.headers.common["Authorization"] =
`Bearer ${token}`;
@@ -135,6 +151,37 @@ export class DocmostClient {
* expired-token auth error perform a fresh login and retry exactly once.
*/
async getCollabTokenWithReauth() {
// Collab-token PROVIDER path: when a getCollabToken provider was supplied
// (the internal agent's provenance collab token), use it instead of the
// REST /auth/collab-token endpoint. Re-invoke it once on a 401/403 (e.g. the
// signed token expired between content mutations in a long agent turn).
if (this.getCollabTokenFn) {
try {
const token = await this.getCollabTokenFn();
if (typeof token !== "string" || token.length === 0) {
throw new Error("getCollabToken returned an empty token");
}
return token;
}
catch (e) {
const axiosStatus = axios.isAxiosError(e)
? e.response?.status
: undefined;
const attachedStatus = e?.status;
const isAuthError = axiosStatus === 401 ||
axiosStatus === 403 ||
attachedStatus === 401 ||
attachedStatus === 403;
if (isAuthError) {
const token = await this.getCollabTokenFn();
if (typeof token !== "string" || token.length === 0) {
throw new Error("getCollabToken returned an empty token");
}
return token;
}
throw e;
}
}
await this.ensureAuthenticated();
try {
return await getCollabToken(this.apiUrl, this.token);
@@ -1466,6 +1513,25 @@ export class DocmostClient {
.post("/comments/delete", { commentId })
.then((res) => res.data);
}
/**
* Resolve or reopen a top-level comment thread (reversible — `resolved`
* toggles the state). Only top-level comments can be resolved; the server
* rejects resolving a reply. Hits POST /comments/resolve.
*/
async resolveComment(commentId, resolved) {
await this.ensureAuthenticated();
const response = await this.client.post("/comments/resolve", {
commentId,
resolved,
});
const comment = response.data?.data ?? response.data;
return {
success: true,
commentId,
resolved,
comment,
};
}
/**
* Check for new comments across pages in a space (optionally scoped to a
* subtree): pages updated after `since` are scanned and their comments

View File

@@ -61,13 +61,25 @@ import vm from "node:vm";
* a token getter (getToken — the client uses the returned BARE access JWT as
* the Bearer and never calls performLogin; used for the internal per-user path).
*
* Both branches may ALSO carry an optional `getCollabToken` provider. When set,
* content mutations (which go over the collaboration websocket) use the token it
* returns INSTEAD of calling `POST /auth/collab-token`. The internal per-user
* agent path uses this to hand the client a provenance collab token (signed
* `actor:'agent'`+`aiChatId`), so agent content edits are attributed without a
* spoofable client-side field. When absent the client keeps the original
* `/auth/collab-token` path (service-account/stdio unchanged).
*
* Housed here (not in index.ts) so client.ts has no type dependency on index.ts;
* index.ts re-exports it for the package's public surface.
*/
export type DocmostMcpConfig = { apiUrl: string } & (
| { email: string; password: string }
| { getToken: () => Promise<string> } // returns a BARE JWT; the client adds "Bearer "
);
) & {
// Optional collab-token provider (returns a ready collab JWT). Common to
// both branches; see the type doc above.
getCollabToken?: () => Promise<string>;
};
export class DocmostClient {
private client: AxiosInstance;
@@ -80,6 +92,10 @@ export class DocmostClient {
// Per-user token provider. When set, login() calls it to obtain a BARE access
// JWT instead of performLogin, and the 401/403 re-auth path re-calls it.
private getTokenFn: (() => Promise<string>) | null = null;
// Optional collab-token provider. When set, getCollabTokenWithReauth() returns
// its token instead of calling POST /auth/collab-token; on a 401/403 it is
// re-invoked once. Used by the internal agent to carry signed provenance.
private getCollabTokenFn: (() => Promise<string>) | null = null;
// In-flight login dedup: when the token expires, the 401 interceptor,
// ensureAuthenticated, getCollabTokenWithReauth and the two multipart retries
// can all call login() at once. Memoizing a single promise collapses that
@@ -114,6 +130,11 @@ export class DocmostClient {
this.email = config.email;
this.password = config.password;
}
// Optional, available to both variants. When present, content mutations get
// their collab token from here instead of POST /auth/collab-token.
if (config.getCollabToken) {
this.getCollabTokenFn = config.getCollabToken;
}
this.client = axios.create({
baseURL: this.apiUrl,
// Default request timeout so a hung connection cannot wedge a per-page
@@ -184,6 +205,13 @@ export class DocmostClient {
: performLogin(this.apiUrl, this.email!, this.password!);
this.loginPromise = fetchToken
.then((token) => {
// Guard against an empty/invalid token (e.g. a getToken provider that
// resolves to "" or null): without this an empty token would set a
// literal "Authorization: Bearer null"/"Bearer " header and every
// request would 401 with a confusing error. Fail loudly instead.
if (typeof token !== "string" || token.length === 0) {
throw new Error("getToken returned an empty token");
}
this.token = token;
this.client.defaults.headers.common["Authorization"] =
`Bearer ${token}`;
@@ -209,6 +237,38 @@ export class DocmostClient {
* expired-token auth error perform a fresh login and retry exactly once.
*/
private async getCollabTokenWithReauth(): Promise<string> {
// Collab-token PROVIDER path: when a getCollabToken provider was supplied
// (the internal agent's provenance collab token), use it instead of the
// REST /auth/collab-token endpoint. Re-invoke it once on a 401/403 (e.g. the
// signed token expired between content mutations in a long agent turn).
if (this.getCollabTokenFn) {
try {
const token = await this.getCollabTokenFn();
if (typeof token !== "string" || token.length === 0) {
throw new Error("getCollabToken returned an empty token");
}
return token;
} catch (e) {
const axiosStatus = axios.isAxiosError(e)
? e.response?.status
: undefined;
const attachedStatus = (e as any)?.status;
const isAuthError =
axiosStatus === 401 ||
axiosStatus === 403 ||
attachedStatus === 401 ||
attachedStatus === 403;
if (isAuthError) {
const token = await this.getCollabTokenFn();
if (typeof token !== "string" || token.length === 0) {
throw new Error("getCollabToken returned an empty token");
}
return token;
}
throw e;
}
}
await this.ensureAuthenticated();
try {
return await getCollabToken(this.apiUrl, this.token!);
@@ -1798,6 +1858,26 @@ export class DocmostClient {
.then((res) => res.data);
}
/**
* Resolve or reopen a top-level comment thread (reversible — `resolved`
* toggles the state). Only top-level comments can be resolved; the server
* rejects resolving a reply. Hits POST /comments/resolve.
*/
async resolveComment(commentId: string, resolved: boolean) {
await this.ensureAuthenticated();
const response = await this.client.post("/comments/resolve", {
commentId,
resolved,
});
const comment = response.data?.data ?? response.data;
return {
success: true,
commentId,
resolved,
comment,
};
}
/**
* Check for new comments across pages in a space (optionally scoped to a
* subtree): pages updated after `since` are scanned and their comments

102
pnpm-lock.yaml generated
View File

@@ -248,6 +248,9 @@ importers:
apps/client:
dependencies:
'@ai-sdk/react':
specifier: ^3.0.208
version: 3.0.209(react@18.3.1)(zod@4.3.6)
'@atlaskit/pragmatic-drag-and-drop':
specifier: 1.8.1
version: 1.8.1
@@ -311,6 +314,9 @@ importers:
'@tanstack/react-virtual':
specifier: 3.13.24
version: 3.13.24(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
ai:
specifier: 6.0.207
version: 6.0.207(zod@4.3.6)
alfaaz:
specifier: 1.1.0
version: 1.1.0
@@ -323,6 +329,9 @@ importers:
clsx:
specifier: 2.1.1
version: 2.1.1
dompurify:
specifier: 3.4.1
version: 3.4.1
file-saver:
specifier: 2.0.5
version: 2.0.5
@@ -951,6 +960,12 @@ packages:
'@adobe/css-tools@4.4.3':
resolution: {integrity: sha512-VQKMkwriZbaOgVCby1UDY/LDk5fIjhQicCvVPFqfe+69fWaPWydbWJ3wRt59/YzIwda1I81loas3oCoHxnqvdA==}
'@ai-sdk/gateway@3.0.133':
resolution: {integrity: sha512-Ebs+7iS9zUgJu5B0RlxM2JmDWzq79Cpd6YdiqcCzB5qFdpfQJPUDiXutqlQP89F2XGjOdDeidulBTXUdXWzOxw==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/gateway@3.0.77':
resolution: {integrity: sha512-UdwIG2H2YMuntJQ5L+EmED5XiwnlvDT3HOmKfVFxR4Nq/RSLFA/HcchhwfNXHZ5UJjyuL2VO0huLbWSZ9ijemQ==}
engines: {node: '>=18'}
@@ -981,10 +996,26 @@ packages:
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/provider-utils@4.0.30':
resolution: {integrity: sha512-VO7I+vPffqI5sMnPoUq5DCSqKIgQIk/naJWRdQVpz2ma2zoprC/lqiJiUEl2s6DfvTD76TbhD3q39ROjlA6rGw==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/provider@3.0.10':
resolution: {integrity: sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==}
engines: {node: '>=18'}
'@ai-sdk/provider@3.0.8':
resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==}
engines: {node: '>=18'}
'@ai-sdk/react@3.0.209':
resolution: {integrity: sha512-0lZrb5St5EpRQAjAhrv9ZhAJNNl7NYtZYwyZRMp/nicx6EbL4NSeeVQBx6yGOYNGYj8Z2KsOV7qsZqBZZmlcig==}
engines: {node: '>=18'}
peerDependencies:
react: ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1
'@angular-devkit/core@19.2.23':
resolution: {integrity: sha512-RazHPQkUEsNU/OZ75w9UeHxGFMthRiuAW2B/uA7eXExBj/1meHrrBfoCA56ujW2GUxVjRtSrMjylKh4R4meiYA==}
engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
@@ -5310,6 +5341,10 @@ packages:
resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==}
engines: {node: '>= 20'}
'@vercel/oidc@3.2.0':
resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==}
engines: {node: '>= 20'}
'@vitejs/plugin-react@6.0.1':
resolution: {integrity: sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -5478,6 +5513,12 @@ packages:
peerDependencies:
zod: ^3.25.76 || ^4.1.8
ai@6.0.207:
resolution: {integrity: sha512-9rAHnqU+AvxyqO6WgiWj7hQENX6AprHXZWZEdBWwgnA854D2Mje/PiTmbcFqO+2Cck1lII0NLRQJY9lmdSorMw==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
ajv-formats@2.1.1:
resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==}
peerDependencies:
@@ -6811,6 +6852,10 @@ packages:
resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==}
engines: {node: '>=18.0.0'}
eventsource-parser@3.1.0:
resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==}
engines: {node: '>=18.0.0'}
eventsource@3.0.7:
resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==}
engines: {node: '>=18.0.0'}
@@ -9766,6 +9811,11 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
swr@2.4.1:
resolution: {integrity: sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA==}
peerDependencies:
react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
symbol-observable@4.0.0:
resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==}
engines: {node: '>=0.10'}
@@ -9823,6 +9873,10 @@ packages:
thread-stream@3.0.2:
resolution: {integrity: sha512-cBL4xF2A3lSINV4rD5tyqnKH4z/TgWPvT+NaVhJDSwK962oo/Ye7cHSMbDzwcu7tAE1SfU6Q4XtV6Hucmi6Hlw==}
throttleit@2.1.0:
resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==}
engines: {node: '>=18'}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
@@ -10664,6 +10718,13 @@ snapshots:
'@adobe/css-tools@4.4.3': {}
'@ai-sdk/gateway@3.0.133(zod@4.3.6)':
dependencies:
'@ai-sdk/provider': 3.0.10
'@ai-sdk/provider-utils': 4.0.30(zod@4.3.6)
'@vercel/oidc': 3.2.0
zod: 4.3.6
'@ai-sdk/gateway@3.0.77(zod@4.3.6)':
dependencies:
'@ai-sdk/provider': 3.0.8
@@ -10696,10 +10757,31 @@ snapshots:
eventsource-parser: 3.0.6
zod: 4.3.6
'@ai-sdk/provider-utils@4.0.30(zod@4.3.6)':
dependencies:
'@ai-sdk/provider': 3.0.10
'@standard-schema/spec': 1.1.0
eventsource-parser: 3.1.0
zod: 4.3.6
'@ai-sdk/provider@3.0.10':
dependencies:
json-schema: 0.4.0
'@ai-sdk/provider@3.0.8':
dependencies:
json-schema: 0.4.0
'@ai-sdk/react@3.0.209(react@18.3.1)(zod@4.3.6)':
dependencies:
'@ai-sdk/provider-utils': 4.0.30(zod@4.3.6)
ai: 6.0.207(zod@4.3.6)
react: 18.3.1
swr: 2.4.1(react@18.3.1)
throttleit: 2.1.0
transitivePeerDependencies:
- zod
'@angular-devkit/core@19.2.23(chokidar@4.0.3)':
dependencies:
ajv: 8.18.0
@@ -15707,6 +15789,8 @@ snapshots:
'@vercel/oidc@3.1.0': {}
'@vercel/oidc@3.2.0': {}
'@vitejs/plugin-react@6.0.1(vite@8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
'@rolldown/pluginutils': 1.0.0-rc.7
@@ -15896,6 +15980,14 @@ snapshots:
'@opentelemetry/api': 1.9.0
zod: 4.3.6
ai@6.0.207(zod@4.3.6):
dependencies:
'@ai-sdk/gateway': 3.0.133(zod@4.3.6)
'@ai-sdk/provider': 3.0.10
'@ai-sdk/provider-utils': 4.0.30(zod@4.3.6)
'@opentelemetry/api': 1.9.0
zod: 4.3.6
ajv-formats@2.1.1(ajv@8.18.0):
optionalDependencies:
ajv: 8.18.0
@@ -17473,6 +17565,8 @@ snapshots:
eventsource-parser@3.0.6: {}
eventsource-parser@3.1.0: {}
eventsource@3.0.7:
dependencies:
eventsource-parser: 3.0.6
@@ -20931,6 +21025,12 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
swr@2.4.1(react@18.3.1):
dependencies:
dequal: 2.0.3
react: 18.3.1
use-sync-external-store: 1.6.0(react@18.3.1)
symbol-observable@4.0.0: {}
symbol-tree@3.2.4: {}
@@ -20982,6 +21082,8 @@ snapshots:
dependencies:
real-require: 0.2.0
throttleit@2.1.0: {}
tiny-invariant@1.3.3: {}
tinybench@2.9.0: {}