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:
@@ -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",
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
11
apps/client/src/features/ai-chat/atoms/ai-chat-atom.ts
Normal file
11
apps/client/src/features/ai-chat/atoms/ai-chat-atom.ts
Normal 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);
|
||||
157
apps/client/src/features/ai-chat/components/ai-chat-panel.tsx
Normal file
157
apps/client/src/features/ai-chat/components/ai-chat-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
81
apps/client/src/features/ai-chat/components/chat-input.tsx
Normal file
81
apps/client/src/features/ai-chat/components/chat-input.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
129
apps/client/src/features/ai-chat/components/chat-thread.tsx
Normal file
129
apps/client/src/features/ai-chat/components/chat-thread.tsx
Normal 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.");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
77
apps/client/src/features/ai-chat/components/message-item.tsx
Normal file
77
apps/client/src/features/ai-chat/components/message-item.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
apps/client/src/features/ai-chat/components/message-list.tsx
Normal file
45
apps/client/src/features/ai-chat/components/message-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
116
apps/client/src/features/ai-chat/queries/ai-chat-query.ts
Normal file
116
apps/client/src/features/ai-chat/queries/ai-chat-query.ts
Normal 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",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
48
apps/client/src/features/ai-chat/services/ai-chat-service.ts
Normal file
48
apps/client/src/features/ai-chat/services/ai-chat-service.ts
Normal 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 });
|
||||
}
|
||||
38
apps/client/src/features/ai-chat/types/ai-chat.types.ts
Normal file
38
apps/client/src/features/ai-chat/types/ai-chat.types.ts
Normal 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;
|
||||
}
|
||||
20
apps/client/src/features/ai-chat/utils/markdown.ts
Normal file
20
apps/client/src/features/ai-chat/utils/markdown.ts
Normal 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);
|
||||
}
|
||||
124
apps/client/src/features/ai-chat/utils/tool-parts.tsx
Normal file
124
apps/client/src/features/ai-chat/utils/tool-parts.tsx
Normal 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;
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
);
|
||||
@@ -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)',
|
||||
|
||||
@@ -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.
|
||||
|
||||
118
apps/server/src/core/ai-chat/tools/ai-chat-tools.service.spec.ts
Normal file
118
apps/server/src/core/ai-chat/tools/ai-chat-tools.service.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
102
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
Reference in New Issue
Block a user