From 44b340dc1a2375bdb8156b2b37c05f8eb478968d Mon Sep 17 00:00:00 2001 From: vvzvlad Date: Wed, 17 Jun 2026 02:39:26 +0300 Subject: [PATCH] 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) --- apps/client/package.json | 3 + .../public/locales/en-US/translation.json | 40 ++- .../src/components/layouts/global/aside.tsx | 16 ++ .../features/ai-chat/atoms/ai-chat-atom.ts | 11 + .../ai-chat/components/ai-chat-panel.tsx | 157 ++++++++++++ .../ai-chat/components/ai-chat.module.css | 79 ++++++ .../ai-chat/components/chat-input.tsx | 81 ++++++ .../ai-chat/components/chat-thread.tsx | 129 ++++++++++ .../ai-chat/components/conversation-list.tsx | 155 +++++++++++ .../ai-chat/components/message-item.tsx | 77 ++++++ .../ai-chat/components/message-list.tsx | 45 ++++ .../ai-chat/components/tool-call-card.tsx | 72 ++++++ .../features/ai-chat/queries/ai-chat-query.ts | 116 +++++++++ .../ai-chat/services/ai-chat-service.ts | 48 ++++ .../features/ai-chat/types/ai-chat.types.ts | 38 +++ .../src/features/ai-chat/utils/markdown.ts | 20 ++ .../src/features/ai-chat/utils/tool-parts.tsx | 124 +++++++++ .../components/header/page-header-menu.tsx | 17 ++ .../components/ai-provider-settings.tsx | 242 ++++++++++++++++++ .../workspace/queries/ai-settings-query.ts | 54 ++++ .../workspace/services/ai-settings-service.ts | 53 ++++ .../settings/workspace/workspace-settings.tsx | 12 + .../decorators/auth-provenance.decorator.ts | 27 ++ .../server/src/core/ai-chat/ai-chat.prompt.ts | 3 + .../src/core/ai-chat/ai-chat.service.ts | 10 +- .../tools/ai-chat-tools.service.spec.ts | 118 +++++++++ .../ai-chat/tools/ai-chat-tools.service.ts | 206 ++++++++++++++- .../ai-chat/tools/docmost-client.loader.ts | 46 +++- apps/server/src/core/auth/dto/jwt-payload.ts | 7 + .../src/core/auth/services/token.service.ts | 13 +- .../src/core/auth/strategies/jwt.strategy.ts | 9 + .../src/core/comment/comment.controller.ts | 8 + .../src/core/comment/comment.service.ts | 23 +- apps/server/src/core/page/page.controller.ts | 23 +- .../src/core/page/services/page.service.ts | 73 +++++- packages/mcp/build/client.js | 66 +++++ packages/mcp/src/client.ts | 82 +++++- pnpm-lock.yaml | 102 ++++++++ 38 files changed, 2384 insertions(+), 21 deletions(-) create mode 100644 apps/client/src/features/ai-chat/atoms/ai-chat-atom.ts create mode 100644 apps/client/src/features/ai-chat/components/ai-chat-panel.tsx create mode 100644 apps/client/src/features/ai-chat/components/ai-chat.module.css create mode 100644 apps/client/src/features/ai-chat/components/chat-input.tsx create mode 100644 apps/client/src/features/ai-chat/components/chat-thread.tsx create mode 100644 apps/client/src/features/ai-chat/components/conversation-list.tsx create mode 100644 apps/client/src/features/ai-chat/components/message-item.tsx create mode 100644 apps/client/src/features/ai-chat/components/message-list.tsx create mode 100644 apps/client/src/features/ai-chat/components/tool-call-card.tsx create mode 100644 apps/client/src/features/ai-chat/queries/ai-chat-query.ts create mode 100644 apps/client/src/features/ai-chat/services/ai-chat-service.ts create mode 100644 apps/client/src/features/ai-chat/types/ai-chat.types.ts create mode 100644 apps/client/src/features/ai-chat/utils/markdown.ts create mode 100644 apps/client/src/features/ai-chat/utils/tool-parts.tsx create mode 100644 apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx create mode 100644 apps/client/src/features/workspace/queries/ai-settings-query.ts create mode 100644 apps/client/src/features/workspace/services/ai-settings-service.ts create mode 100644 apps/server/src/common/decorators/auth-provenance.decorator.ts create mode 100644 apps/server/src/core/ai-chat/tools/ai-chat-tools.service.spec.ts diff --git a/apps/client/package.json b/apps/client/package.json index bd6585f1..e346b1dd 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -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", diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index a182138a..f43e92f4 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -688,6 +688,19 @@ "Manage API keys for all users in the workspace. View the API documentation for usage details.": "Manage API keys for all users in the workspace. View the API documentation for usage details.", "View the API documentation for usage details.": "View the API documentation for usage details.", "View the MCP documentation.": "View the MCP documentation.", + "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}}" } diff --git a/apps/client/src/components/layouts/global/aside.tsx b/apps/client/src/components/layouts/global/aside.tsx index 6faf853a..b4026c32 100644 --- a/apps/client/src/components/layouts/global/aside.tsx +++ b/apps/client/src/components/layouts/global/aside.tsx @@ -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 = ; 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 = ; + 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 ( + + {component} + + ); + } + return ( {component && ( diff --git a/apps/client/src/features/ai-chat/atoms/ai-chat-atom.ts b/apps/client/src/features/ai-chat/atoms/ai-chat-atom.ts new file mode 100644 index 00000000..d143012f --- /dev/null +++ b/apps/client/src/features/ai-chat/atoms/ai-chat-atom.ts @@ -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(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); diff --git a/apps/client/src/features/ai-chat/components/ai-chat-panel.tsx b/apps/client/src/features/ai-chat/components/ai-chat-panel.tsx new file mode 100644 index 00000000..78314d31 --- /dev/null +++ b/apps/client/src/features/ai-chat/components/ai-chat-panel.tsx @@ -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 ( + + + + {t("AI chat")} + + + + + + + + + + + + + + + + + + + + {t("Chat history")} + + + + + + + + + + {waitingForHistory ? ( + + + + ) : ( + + )} + + + ); +} diff --git a/apps/client/src/features/ai-chat/components/ai-chat.module.css b/apps/client/src/features/ai-chat/components/ai-chat.module.css new file mode 100644 index 00000000..6cb47898 --- /dev/null +++ b/apps/client/src/features/ai-chat/components/ai-chat.module.css @@ -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); +} diff --git a/apps/client/src/features/ai-chat/components/chat-input.tsx b/apps/client/src/features/ai-chat/components/chat-input.tsx new file mode 100644 index 00000000..7e5033b0 --- /dev/null +++ b/apps/client/src/features/ai-chat/components/chat-input.tsx @@ -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): void => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + send(); + } + }; + + return ( + +