feat: 支持删除单条聊天消息(用户提问与模型回答)
- 后端新增 DELETE /api/threads/{thread_id}/messages/{message_id},从最新 checkpoint 的 messages 通道过滤掉指定 id 并写回新 checkpoint
- 前端 useDeleteMessage 钩子 + DeleteMessageButton(带确认框)组件
- 在用户问题气泡与助手回答工具栏上各加删除按钮,仅对已落库消息显示
- MessageList 本地隐藏已删消息并在切换会话时重置;只读/分享场景禁用删除
- 新增 deleteMessage 相关中英文案
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
925675ab54
commit
362a8268e8
8 files changed
+868
-594
No files matched your search
@@ -0,0 +1,95 @@
|
|||||||
|
import { Trash2Icon } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
|
import { useDeleteMessage } from "@/core/threads/hooks";
|
||||||
|
|
||||||
|
import { Tooltip } from "../tooltip";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hover affordance that deletes a single message (a user question or an
|
||||||
|
* assistant answer) after a confirmation step. The actual removal is persisted
|
||||||
|
* server-side; `onDeleted` lets the parent hide the bubble immediately without
|
||||||
|
* waiting for the stream cache to refresh.
|
||||||
|
*/
|
||||||
|
export function DeleteMessageButton({
|
||||||
|
threadId,
|
||||||
|
messageId,
|
||||||
|
onDeleted,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
threadId: string;
|
||||||
|
messageId: string;
|
||||||
|
onDeleted?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const deleteMessage = useDeleteMessage();
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
try {
|
||||||
|
await deleteMessage.mutateAsync({ threadId, messageId });
|
||||||
|
onDeleted?.();
|
||||||
|
setOpen(false);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "删除消息失败。",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tooltip content={t.inputBox.deleteMessage}>
|
||||||
|
<Button
|
||||||
|
size="icon-sm"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className={className}
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
<Trash2Icon size={12} />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent className="sm:max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t.inputBox.deleteMessageConfirmTitle}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t.inputBox.deleteMessageConfirmDescription}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
disabled={deleteMessage.isPending}
|
||||||
|
>
|
||||||
|
{t.common.cancel}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => void handleConfirm()}
|
||||||
|
disabled={deleteMessage.isPending}
|
||||||
|
>
|
||||||
|
{t.common.delete}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -57,6 +57,7 @@ import { CopyButton } from '../copy-button';
|
|||||||
import { Tooltip } from '../tooltip';
|
import { Tooltip } from '../tooltip';
|
||||||
import { SkillDisplayBlock } from '../skill-display-block';
|
import { SkillDisplayBlock } from '../skill-display-block';
|
||||||
import { ThreadContext } from './context';
|
import { ThreadContext } from './context';
|
||||||
|
import { DeleteMessageButton } from './delete-message-button';
|
||||||
import { DownloadWordButton } from './download-word-button';
|
import { DownloadWordButton } from './download-word-button';
|
||||||
import { MarkdownContent, type MarkdownContentProps } from './markdown-content';
|
import { MarkdownContent, type MarkdownContentProps } from './markdown-content';
|
||||||
import { MessageTokenUsage } from './message-token-usage';
|
import { MessageTokenUsage } from './message-token-usage';
|
||||||
@@ -135,6 +136,7 @@ export function MessageListItem({
|
|||||||
displaySources = [],
|
displaySources = [],
|
||||||
extraRehypePlugins,
|
extraRehypePlugins,
|
||||||
onEditResubmit,
|
onEditResubmit,
|
||||||
|
onMessageDeleted,
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
fitRowToContent?: boolean;
|
fitRowToContent?: boolean;
|
||||||
@@ -145,12 +147,18 @@ export function MessageListItem({
|
|||||||
displaySources?: RuntimeDisplaySource[];
|
displaySources?: RuntimeDisplaySource[];
|
||||||
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||||
onEditResubmit?: (text: string) => void;
|
onEditResubmit?: (text: string) => void;
|
||||||
|
onMessageDeleted?: (messageId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const isHuman = message.type === 'human';
|
const isHuman = message.type === 'human';
|
||||||
const messageMarkdown =
|
const messageMarkdown =
|
||||||
extractContentFromMessage(message) ??
|
extractContentFromMessage(message) ??
|
||||||
extractReasoningContentFromMessage(message) ??
|
extractReasoningContentFromMessage(message) ??
|
||||||
'';
|
'';
|
||||||
|
// Deletion is offered only for messages that already exist server-side
|
||||||
|
// (real, non-optimistic ids). Optimistic placeholders (`opt-*`) have no
|
||||||
|
// checkpoint entry to remove.
|
||||||
|
const canDelete =
|
||||||
|
!!onMessageDeleted && !!message.id && !message.id.startsWith('opt-');
|
||||||
return (
|
return (
|
||||||
<AIElementMessage
|
<AIElementMessage
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -170,6 +178,7 @@ export function MessageListItem({
|
|||||||
displaySources={displaySources}
|
displaySources={displaySources}
|
||||||
extraRehypePlugins={extraRehypePlugins}
|
extraRehypePlugins={extraRehypePlugins}
|
||||||
onEditResubmit={onEditResubmit}
|
onEditResubmit={onEditResubmit}
|
||||||
|
onMessageDeleted={onMessageDeleted}
|
||||||
/>
|
/>
|
||||||
{/*
|
{/*
|
||||||
Human messages carry their own inline copy/edit toolbar inside
|
Human messages carry their own inline copy/edit toolbar inside
|
||||||
@@ -191,6 +200,13 @@ export function MessageListItem({
|
|||||||
fileName="message"
|
fileName="message"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{canDelete && (
|
||||||
|
<DeleteMessageButton
|
||||||
|
threadId={threadId}
|
||||||
|
messageId={message.id!}
|
||||||
|
onDeleted={() => onMessageDeleted?.(message.id!)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</MessageToolbar>
|
</MessageToolbar>
|
||||||
)}
|
)}
|
||||||
@@ -214,6 +230,7 @@ function HumanMessageBlock({
|
|||||||
messageTime,
|
messageTime,
|
||||||
components,
|
components,
|
||||||
onEditResubmit,
|
onEditResubmit,
|
||||||
|
deleteButton,
|
||||||
}: {
|
}: {
|
||||||
text: string;
|
text: string;
|
||||||
pageContext?: EmbeddedPageContext | null;
|
pageContext?: EmbeddedPageContext | null;
|
||||||
@@ -221,6 +238,7 @@ function HumanMessageBlock({
|
|||||||
messageTime: { label: string; full: string } | null;
|
messageTime: { label: string; full: string } | null;
|
||||||
components: MarkdownContentProps["components"];
|
components: MarkdownContentProps["components"];
|
||||||
onEditResubmit?: (text: string) => void;
|
onEditResubmit?: (text: string) => void;
|
||||||
|
deleteButton?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
@@ -298,10 +316,10 @@ function HumanMessageBlock({
|
|||||||
)}
|
)}
|
||||||
<MessageTimeStamp time={messageTime} align="right" />
|
<MessageTimeStamp time={messageTime} align="right" />
|
||||||
{pageContext && <EmbeddedPageContextCard pageContext={pageContext} />}
|
{pageContext && <EmbeddedPageContextCard pageContext={pageContext} />}
|
||||||
{text && (
|
{(text || deleteButton) && (
|
||||||
<div className="flex items-center gap-0.5 opacity-0 transition-opacity duration-200 group-hover/conversation-message:opacity-100">
|
<div className="flex items-center gap-0.5 opacity-0 transition-opacity duration-200 group-hover/conversation-message:opacity-100">
|
||||||
<CopyButton clipboardData={text} className="size-6" />
|
{text && <CopyButton clipboardData={text} className="size-6" />}
|
||||||
{onEditResubmit && (
|
{text && onEditResubmit && (
|
||||||
<Tooltip content={t.inputBox.editQuestion}>
|
<Tooltip content={t.inputBox.editQuestion}>
|
||||||
<Button
|
<Button
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
@@ -314,6 +332,7 @@ function HumanMessageBlock({
|
|||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
{deleteButton}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -360,6 +379,7 @@ function MessageContent_({
|
|||||||
displaySources = [],
|
displaySources = [],
|
||||||
extraRehypePlugins,
|
extraRehypePlugins,
|
||||||
onEditResubmit,
|
onEditResubmit,
|
||||||
|
onMessageDeleted,
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
message: Message;
|
message: Message;
|
||||||
@@ -369,6 +389,7 @@ function MessageContent_({
|
|||||||
displaySources?: RuntimeDisplaySource[];
|
displaySources?: RuntimeDisplaySource[];
|
||||||
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||||
onEditResubmit?: (text: string) => void;
|
onEditResubmit?: (text: string) => void;
|
||||||
|
onMessageDeleted?: (messageId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
||||||
|
|
||||||
@@ -500,6 +521,8 @@ function MessageContent_({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isHuman) {
|
if (isHuman) {
|
||||||
|
const canDelete =
|
||||||
|
!!onMessageDeleted && !!message.id && !message.id.startsWith('opt-');
|
||||||
return (
|
return (
|
||||||
<HumanMessageBlock
|
<HumanMessageBlock
|
||||||
text={parsedHumanContent.content}
|
text={parsedHumanContent.content}
|
||||||
@@ -508,6 +531,16 @@ function MessageContent_({
|
|||||||
messageTime={messageTime}
|
messageTime={messageTime}
|
||||||
components={components}
|
components={components}
|
||||||
onEditResubmit={onEditResubmit}
|
onEditResubmit={onEditResubmit}
|
||||||
|
deleteButton={
|
||||||
|
canDelete ? (
|
||||||
|
<DeleteMessageButton
|
||||||
|
threadId={threadId}
|
||||||
|
messageId={message.id!}
|
||||||
|
onDeleted={() => onMessageDeleted?.(message.id!)}
|
||||||
|
className="size-6"
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Message } from "@langchain/langgraph-sdk";
|
import type { Message } from "@langchain/langgraph-sdk";
|
||||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||||
import { CheckIcon, SendIcon, XIcon } from "lucide-react";
|
import { CheckIcon, SendIcon, XIcon } from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Conversation,
|
Conversation,
|
||||||
@@ -37,6 +37,7 @@ import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
|
|||||||
import { ArtifactFileList } from "../artifacts/artifact-file-list";
|
import { ArtifactFileList } from "../artifacts/artifact-file-list";
|
||||||
import { StreamingIndicator } from "../streaming-indicator";
|
import { StreamingIndicator } from "../streaming-indicator";
|
||||||
|
|
||||||
|
import { ThreadContext } from "./context";
|
||||||
import { MarkdownContent } from "./markdown-content";
|
import { MarkdownContent } from "./markdown-content";
|
||||||
import { MessageGroup } from "./message-group";
|
import { MessageGroup } from "./message-group";
|
||||||
import { MessageListItem } from "./message-list-item";
|
import { MessageListItem } from "./message-list-item";
|
||||||
@@ -88,7 +89,37 @@ export function MessageList({
|
|||||||
);
|
);
|
||||||
const updateSubtask = useUpdateSubtask();
|
const updateSubtask = useUpdateSubtask();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const messages = thread.messages;
|
const threadContext = useContext(ThreadContext);
|
||||||
|
// Read-only contexts (public share, mock previews) must not offer deletion.
|
||||||
|
const allowDelete = !threadContext?.publicShareCode && !threadContext?.isMock;
|
||||||
|
|
||||||
|
// Locally hide messages the user just deleted. The server-side checkpoint is
|
||||||
|
// trimmed immediately, but the live `useStream` cache still holds the message
|
||||||
|
// until the next refetch — filtering by id here makes the removal instant and
|
||||||
|
// keeps it hidden for the rest of the session (a reload reads the trimmed
|
||||||
|
// state). Reset whenever the active thread changes.
|
||||||
|
const [hiddenMessageIds, setHiddenMessageIds] = useState<Set<string>>(
|
||||||
|
() => new Set(),
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
setHiddenMessageIds(new Set());
|
||||||
|
}, [threadId]);
|
||||||
|
const handleMessageDeleted = useCallback((messageId: string) => {
|
||||||
|
setHiddenMessageIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(messageId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const allMessages = thread.messages;
|
||||||
|
const messages = useMemo(
|
||||||
|
() =>
|
||||||
|
hiddenMessageIds.size === 0
|
||||||
|
? allMessages
|
||||||
|
: allMessages.filter((m) => !m.id || !hiddenMessageIds.has(m.id)),
|
||||||
|
[allMessages, hiddenMessageIds],
|
||||||
|
);
|
||||||
|
|
||||||
// Stamp a wall-clock time onto every question/answer the first time it is
|
// Stamp a wall-clock time onto every question/answer the first time it is
|
||||||
// seen, in a single batched commit. Questions sent this session already carry
|
// seen, in a single batched commit. Questions sent this session already carry
|
||||||
@@ -259,6 +290,9 @@ export function MessageList({
|
|||||||
displaySources={displaySources}
|
displaySources={displaySources}
|
||||||
extraRehypePlugins={extraRehypePlugins}
|
extraRehypePlugins={extraRehypePlugins}
|
||||||
onEditResubmit={onEditResubmit}
|
onEditResubmit={onEditResubmit}
|
||||||
|
onMessageDeleted={
|
||||||
|
allowDelete ? handleMessageDeleted : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -82,6 +82,9 @@ export const enUS: Translations = {
|
|||||||
inputBox: {
|
inputBox: {
|
||||||
placeholder: "How can I assist you today?",
|
placeholder: "How can I assist you today?",
|
||||||
editQuestion: "Edit question",
|
editQuestion: "Edit question",
|
||||||
|
deleteMessage: "Delete message",
|
||||||
|
deleteMessageConfirmTitle: "Delete this message?",
|
||||||
|
deleteMessageConfirmDescription: "This action cannot be undone.",
|
||||||
createSkillPrompt:
|
createSkillPrompt:
|
||||||
"We're going to build a new skill step by step with `skill-creator`. To start, what do you want this skill to do?",
|
"We're going to build a new skill step by step with `skill-creator`. To start, what do you want this skill to do?",
|
||||||
addAttachments: "Add attachments",
|
addAttachments: "Add attachments",
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ export interface Translations {
|
|||||||
inputBox: {
|
inputBox: {
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
editQuestion: string;
|
editQuestion: string;
|
||||||
|
deleteMessage: string;
|
||||||
|
deleteMessageConfirmTitle: string;
|
||||||
|
deleteMessageConfirmDescription: string;
|
||||||
createSkillPrompt: string;
|
createSkillPrompt: string;
|
||||||
addAttachments: string;
|
addAttachments: string;
|
||||||
mode: string;
|
mode: string;
|
||||||
|
|||||||
@@ -82,6 +82,9 @@ export const zhCN: Translations = {
|
|||||||
inputBox: {
|
inputBox: {
|
||||||
placeholder: "今天我能为你做些什么?",
|
placeholder: "今天我能为你做些什么?",
|
||||||
editQuestion: "编辑问题",
|
editQuestion: "编辑问题",
|
||||||
|
deleteMessage: "删除消息",
|
||||||
|
deleteMessageConfirmTitle: "删除这条消息?",
|
||||||
|
deleteMessageConfirmDescription: "删除后将无法恢复。",
|
||||||
createSkillPrompt:
|
createSkillPrompt:
|
||||||
"我们一起用 skill-creator 技能来创建一个技能吧。先问问我希望这个技能能做什么。",
|
"我们一起用 skill-creator 技能来创建一个技能吧。先问问我希望这个技能能做什么。",
|
||||||
addAttachments: "添加附件",
|
addAttachments: "添加附件",
|
||||||
|
|||||||
@@ -350,12 +350,12 @@ export function useThreadStream({
|
|||||||
// agent thread URL — otherwise fall back to the global new chat.
|
// agent thread URL — otherwise fall back to the global new chat.
|
||||||
const hash = typeof window !== "undefined" ? window.location.hash : "";
|
const hash = typeof window !== "undefined" ? window.location.hash : "";
|
||||||
const agentMatch = hash.match(/#\/page\/workspace\/agents\/([^/]+)\//);
|
const agentMatch = hash.match(/#\/page\/workspace\/agents\/([^/]+)\//);
|
||||||
const iframeMatch = hash.match(/#\/page\/workspace\/chats_iframe\//);
|
const iframeMatch = hash.match(/#\/page\/workspace\/chats_iframe\//);
|
||||||
const target = agentMatch
|
const target = agentMatch
|
||||||
? `/page/workspace/agents/${agentMatch[1]}/chats/new`
|
? `/page/workspace/agents/${agentMatch[1]}/chats/new`
|
||||||
: iframeMatch
|
: iframeMatch
|
||||||
? "/page/workspace/chats_iframe/new"
|
? "/page/workspace/chats_iframe/new"
|
||||||
: "/page/workspace/chats/new";
|
: "/page/workspace/chats/new";
|
||||||
// First navigate to a safe URL so the post-reload page lands on a
|
// First navigate to a safe URL so the post-reload page lands on a
|
||||||
// valid route. Then full-reload so TanStack Query cache, context
|
// valid route. Then full-reload so TanStack Query cache, context
|
||||||
// providers and any other state captured under the previous user's
|
// providers and any other state captured under the previous user's
|
||||||
@@ -804,6 +804,41 @@ export function useDeleteThread() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a single message (a user question or an assistant answer) from a
|
||||||
|
* thread. Hits the Gateway endpoint that filters the message out of the latest
|
||||||
|
* checkpoint. The live `useStream` cache is not auto-refreshed by this call —
|
||||||
|
* callers hide the deleted id locally (see `MessageList`) so the removal shows
|
||||||
|
* immediately; a reload then reads the persisted, already-trimmed state.
|
||||||
|
*/
|
||||||
|
export function useDeleteMessage() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
threadId,
|
||||||
|
messageId,
|
||||||
|
}: {
|
||||||
|
threadId: string;
|
||||||
|
messageId: string;
|
||||||
|
}) => {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(
|
||||||
|
threadId,
|
||||||
|
)}/messages/${encodeURIComponent(messageId)}`,
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response
|
||||||
|
.json()
|
||||||
|
.catch(() => ({ detail: "删除消息失败。" }));
|
||||||
|
throw new Error(error.detail ?? "删除消息失败。");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useRenameThread() {
|
export function useRenameThread() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const apiClient = getAPIClient();
|
const apiClient = getAPIClient();
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user