- 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>
156 lines
4.4 KiB
TypeScript
156 lines
4.4 KiB
TypeScript
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>
|
|
);
|
|
}
|