mirror of
https://github.com/Mintplex-Labs/anything-llm.git
synced 2024-11-11 01:10:11 +01:00
Feat/quick delete chat (#1302)
* feat:quick delete chat thread * update:pull request template * refactor bulk-deletion implementation * unset pull_request_changes * add border none for desktop support * unset marks when toggling bulk mode --------- Co-authored-by: timothycarambat <rambat1010@gmail.com>
This commit is contained in:
parent
79ea15e074
commit
ad778dd36d
@ -1,7 +1,13 @@
|
||||
import Workspace from "@/models/workspace";
|
||||
import paths from "@/utils/paths";
|
||||
import showToast from "@/utils/toast";
|
||||
import { DotsThree, PencilSimple, Trash } from "@phosphor-icons/react";
|
||||
import {
|
||||
ArrowCounterClockwise,
|
||||
DotsThree,
|
||||
PencilSimple,
|
||||
Trash,
|
||||
X,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import truncate from "truncate";
|
||||
@ -14,7 +20,9 @@ export default function ThreadItem({
|
||||
workspace,
|
||||
thread,
|
||||
onRemove,
|
||||
toggleMarkForDeletion,
|
||||
hasNext,
|
||||
ctrlPressed = false,
|
||||
}) {
|
||||
const { slug } = useParams();
|
||||
const optionsContainer = useRef(null);
|
||||
@ -57,14 +65,30 @@ export default function ThreadItem({
|
||||
/>
|
||||
<div className="flex w-full items-center justify-between pr-2 group relative">
|
||||
{thread.deleted ? (
|
||||
<a className="w-full">
|
||||
<p className={`text-left text-sm text-slate-400/50 italic`}>
|
||||
deleted thread
|
||||
</p>
|
||||
</a>
|
||||
<div className="w-full flex justify-between">
|
||||
<div className="w-full ">
|
||||
<p className={`text-left text-sm text-slate-400/50 italic`}>
|
||||
deleted thread
|
||||
</p>
|
||||
</div>
|
||||
{ctrlPressed && (
|
||||
<button
|
||||
type="button"
|
||||
className="border-none"
|
||||
onClick={() => toggleMarkForDeletion(thread.id)}
|
||||
>
|
||||
<ArrowCounterClockwise
|
||||
className="text-zinc-300 hover:text-white"
|
||||
size={18}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<a
|
||||
href={window.location.pathname === linkTo ? "#" : linkTo}
|
||||
href={
|
||||
window.location.pathname === linkTo || ctrlPressed ? "#" : linkTo
|
||||
}
|
||||
className="w-full"
|
||||
aria-current={isActive ? "page" : ""}
|
||||
>
|
||||
@ -79,15 +103,30 @@ export default function ThreadItem({
|
||||
)}
|
||||
{!!thread.slug && !thread.deleted && (
|
||||
<div ref={optionsContainer}>
|
||||
<div className="flex items-center w-fit group-hover:visible md:invisible gap-x-1">
|
||||
{ctrlPressed ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowOptions(!showOptions)}
|
||||
aria-label="Thread options"
|
||||
className="border-none"
|
||||
onClick={() => toggleMarkForDeletion(thread.id)}
|
||||
>
|
||||
<DotsThree className="text-slate-300" size={25} />
|
||||
<X
|
||||
className="text-zinc-300 hover:text-white"
|
||||
weight="bold"
|
||||
size={18}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center w-fit group-hover:visible md:invisible gap-x-1">
|
||||
<button
|
||||
type="button"
|
||||
className="border-none"
|
||||
onClick={() => setShowOptions(!showOptions)}
|
||||
aria-label="Thread options"
|
||||
>
|
||||
<DotsThree className="text-slate-300" size={25} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{showOptions && (
|
||||
<OptionsMenu
|
||||
containerRef={optionsContainer}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import Workspace from "@/models/workspace";
|
||||
import paths from "@/utils/paths";
|
||||
import showToast from "@/utils/toast";
|
||||
import { Plus, CircleNotch } from "@phosphor-icons/react";
|
||||
import { Plus, CircleNotch, Trash } from "@phosphor-icons/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import ThreadItem from "./ThreadItem";
|
||||
import { useParams } from "react-router-dom";
|
||||
@ -10,6 +10,7 @@ export default function ThreadContainer({ workspace }) {
|
||||
const { threadSlug = null } = useParams();
|
||||
const [threads, setThreads] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [ctrlPressed, setCtrlPressed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchThreads() {
|
||||
@ -21,6 +22,43 @@ export default function ThreadContainer({ workspace }) {
|
||||
fetchThreads();
|
||||
}, [workspace.slug]);
|
||||
|
||||
// Enable toggling of meta-key (ctrl on win and cmd/fn on others)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event) => {
|
||||
if (["Control", "Meta"].includes(event.key)) {
|
||||
setCtrlPressed((prev) => !prev);
|
||||
// when toggling, unset bulk progress so
|
||||
// previously marked threads that were never deleted
|
||||
// come back to life.
|
||||
setThreads((prev) =>
|
||||
prev.map((t) => {
|
||||
return { ...t, deleted: false };
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggleForDeletion = (id) => {
|
||||
setThreads((prev) =>
|
||||
prev.map((t) => {
|
||||
if (t.id !== id) return t;
|
||||
return { ...t, deleted: !t.deleted };
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleDeleteAll = async () => {
|
||||
const slugs = threads.filter((t) => t.deleted === true).map((t) => t.slug);
|
||||
await Workspace.threads.deleteBulk(workspace.slug, slugs);
|
||||
setThreads((prev) => prev.filter((t) => !t.deleted));
|
||||
setCtrlPressed(false);
|
||||
};
|
||||
|
||||
function removeThread(threadId) {
|
||||
setThreads((prev) =>
|
||||
prev.map((_t) => {
|
||||
@ -28,6 +66,12 @@ export default function ThreadContainer({ workspace }) {
|
||||
return { ..._t, deleted: true };
|
||||
})
|
||||
);
|
||||
|
||||
// Show thread was deleted, but then remove from threads entirely so it will
|
||||
// not appear in bulk-selection.
|
||||
setTimeout(() => {
|
||||
setThreads((prev) => prev.filter((t) => !t.deleted));
|
||||
}, 500);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
@ -58,6 +102,8 @@ export default function ThreadContainer({ workspace }) {
|
||||
<ThreadItem
|
||||
key={thread.slug}
|
||||
idx={i + 1}
|
||||
ctrlPressed={ctrlPressed}
|
||||
toggleMarkForDeletion={toggleForDeletion}
|
||||
activeIdx={activeThreadIdx}
|
||||
isActive={activeThreadIdx === i + 1}
|
||||
workspace={workspace}
|
||||
@ -66,6 +112,11 @@ export default function ThreadContainer({ workspace }) {
|
||||
hasNext={i !== threads.length - 1}
|
||||
/>
|
||||
))}
|
||||
<DeleteAllThreadButton
|
||||
ctrlPressed={ctrlPressed}
|
||||
threads={threads}
|
||||
onDelete={handleDeleteAll}
|
||||
/>
|
||||
<NewThreadButton workspace={workspace} />
|
||||
</div>
|
||||
);
|
||||
@ -113,3 +164,28 @@ function NewThreadButton({ workspace }) {
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteAllThreadButton({ ctrlPressed, threads, onDelete }) {
|
||||
if (!ctrlPressed || threads.filter((t) => t.deleted).length === 0)
|
||||
return null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
className="w-full relative flex h-[40px] items-center border-none hover:bg-red-400/20 rounded-lg group"
|
||||
>
|
||||
<div className="flex w-full gap-x-2 items-center pl-4">
|
||||
<div className="bg-zinc-600 p-2 rounded-lg h-[24px] w-[24px] flex items-center justify-center">
|
||||
<Trash
|
||||
weight="bold"
|
||||
size={14}
|
||||
className="shrink-0 text-slate-100 group-hover:text-red-400"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-white text-left text-sm group-hover:text-red-400">
|
||||
Delete Selected
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
@ -62,6 +62,18 @@ const WorkspaceThread = {
|
||||
.then((res) => res.ok)
|
||||
.catch(() => false);
|
||||
},
|
||||
deleteBulk: async function (workspaceSlug, threadSlugs = []) {
|
||||
return await fetch(
|
||||
`${API_BASE}/workspace/${workspaceSlug}/thread-bulk-delete`,
|
||||
{
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ slugs: threadSlugs }),
|
||||
headers: baseHeaders(),
|
||||
}
|
||||
)
|
||||
.then((res) => res.ok)
|
||||
.catch(() => false);
|
||||
},
|
||||
chatHistory: async function (workspaceSlug, threadSlug) {
|
||||
const history = await fetch(
|
||||
`${API_BASE}/workspace/${workspaceSlug}/thread/${threadSlug}/chats`,
|
||||
|
@ -92,6 +92,29 @@ function workspaceThreadEndpoints(app) {
|
||||
}
|
||||
);
|
||||
|
||||
app.delete(
|
||||
"/workspace/:slug/thread-bulk-delete",
|
||||
[validatedRequest, flexUserRoleValid([ROLES.all]), validWorkspaceSlug],
|
||||
async (request, response) => {
|
||||
try {
|
||||
const { slugs = [] } = reqBody(request);
|
||||
if (slugs.length === 0) return response.sendStatus(200).end();
|
||||
|
||||
const user = await userFromSession(request, response);
|
||||
const workspace = response.locals.workspace;
|
||||
await WorkspaceThread.delete({
|
||||
slug: { in: slugs },
|
||||
user_id: user?.id ?? null,
|
||||
workspace_id: workspace.id,
|
||||
});
|
||||
response.sendStatus(200).end();
|
||||
} catch (e) {
|
||||
console.log(e.message, e);
|
||||
response.sendStatus(500).end();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/workspace/:slug/thread/:threadSlug/chats",
|
||||
[
|
||||
|
@ -61,7 +61,7 @@ const WorkspaceThread = {
|
||||
|
||||
delete: async function (clause = {}) {
|
||||
try {
|
||||
await prisma.workspace_threads.delete({
|
||||
await prisma.workspace_threads.deleteMany({
|
||||
where: clause,
|
||||
});
|
||||
return true;
|
||||
|
Loading…
Reference in New Issue
Block a user