Files
gitmost/apps/client/src/features/ai-chat/components/conversation-list.tsx
vvzvlad 44b340dc1a 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>
2026-06-17 02:39:26 +03:00

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