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:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user