feat: add batch delete for conversations in chat(web) (#12584)

Resolves #12572

## What problem does this PR solve?
The conversation list in chat sessions previously only supported
deleting conversations one by one. This was inefficient when users
needed to clean up multiple conversations. This PR adds batch delete
functionality to improve user experience.

## Type of change
 - [x] New Feature (non-breaking change which adds functionality)

## Specific changes
  - Add selection mode with checkboxes for conversation list
  - Add batch delete functionality with custom icons
  - Add internationalization support (en/zh)
  - Use existing removeConversation API which supports batch deletion

## UI modification status
  - Default: Show [+] and [batch delete icon]
  - Selection mode: Show checkboxes, keep [+] and [select all icon]
  - Items selected: Show [return icon] and [red trash icon]"

### Repair Comparison
**1.Before Repair**
<img width="982" height="1221" alt="image"
src="https://github.com/user-attachments/assets/8a80f7c0-7da6-41ec-9d1a-ac887ede96ba"
/>


**2.After Repair**
<img width="1273" height="919" alt="新增批量删除效果图"
src="https://github.com/user-attachments/assets/e179bdf3-3779-4bd5-84b6-8e24780a22ea"
/>

---
Co-authored-by: Gongzi

---------

Co-authored-by: Liu An <asiro@qq.com>
This commit is contained in:
LGRY
2026-01-20 19:13:53 +08:00
committed by GitHub
parent 7787085664
commit bc7935d627
5 changed files with 164 additions and 21 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
web/public/return2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

@ -427,7 +427,8 @@ Example: A 1 KB message with 1024-dim embedding uses ~9 KB. The 5 MB default lim
paddleocrOptions: 'PaddleOCR Options',
paddleocrApiUrl: 'PaddleOCR API URL',
paddleocrApiUrlTip: 'The API endpoint URL for PaddleOCR service',
paddleocrApiUrlPlaceholder: 'e.g. https://paddleocr-server.com/layout-parsing',
paddleocrApiUrlPlaceholder:
'e.g. https://paddleocr-server.com/layout-parsing',
paddleocrAccessToken: 'AI Studio Access Token',
paddleocrAccessTokenTip: 'Access token for PaddleOCR API (optional)',
paddleocrAccessTokenPlaceholder: 'Your AI Studio token (optional)',
@ -866,6 +867,8 @@ This auto-tagging feature enhances retrieval by adding another layer of domain-s
chatSetting: 'Chat setting',
tocEnhance: 'TOC enhance',
tocEnhanceTip: ` During the parsing of the document, table of contents information was generated (see the 'Enable Table of Contents Extraction' option in the General method). This allows the large model to return table of contents items relevant to the user's query, thereby using these items to retrieve related chunks and apply weighting to these chunks during the sorting process. This approach is derived from mimicking the behavioral logic of how humans search for knowledge in books.`,
batchDeleteSessions: 'Batch delete',
deleteSelectedConfirm: 'Delete the selected {count} session(s)?',
},
setting: {
deleteModel: 'Delete model',
@ -1107,14 +1110,15 @@ Example: Virtual Hosted Style`,
baseUrlNameMessage: 'Please input your base url!',
paddleocr: {
apiUrl: 'PaddleOCR API URL',
apiUrlPlaceholder: 'For example: https://paddleocr-server.com/layout-parsing',
apiUrlPlaceholder:
'For example: https://paddleocr-server.com/layout-parsing',
accessToken: 'AI Studio Access Token',
accessTokenPlaceholder: 'Your AI Studio token (optional)',
algorithm: 'PaddleOCR Algorithm',
selectAlgorithm: 'Select Algorithm',
modelNamePlaceholder: 'For example: paddleocr-from-env-1',
modelNameRequired: 'Model name is required',
apiUrlRequired: 'PaddleOCR API URL is required'
apiUrlRequired: 'PaddleOCR API URL is required',
},
vision: 'Does it support Vision?',
ollamaLink: 'How to integrate {{name}}',

View File

@ -826,7 +826,9 @@ General实体和关系提取提示来自 GitHub - microsoft/graphrag基于
avatarHidden: '隐藏头像',
locale: '地区',
tocEnhance: '目录增强',
tocEnhanceTip: `解析文档时生成了目录信息见General方法的启用目录抽取让大模型返回和用户问题相关的目录项从而利用目录项拿到相关chunk对这些chunk在排序中进行加权。这种方法来源于模仿人类查询书本中知识的行为逻辑`,
tocEnhanceTip: `解析文档时生成了目录信息见General方法的'启用目录抽取'让大模型返回和用户问题相关的目录项从而利用目录项拿到相关chunk对这些chunk在排序中进行加权。这种方法来源于模仿人类查询书本中知识的行为逻辑`,
batchDeleteSessions: '批量删除',
deleteSelectedConfirm: '删除选中的 {count} 个会话?',
},
setting: {
deleteModel: '删除模型',

View File

@ -1,16 +1,25 @@
import { ConfirmDeleteDialog } from '@/components/confirm-delete-dialog';
import { MoreButton } from '@/components/more-button';
import { RAGFlowAvatar } from '@/components/ragflow-avatar';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { SearchInput } from '@/components/ui/input';
import { useSetModalState } from '@/hooks/common-hooks';
import {
useFetchDialog,
useGetChatSearchParams,
useRemoveConversation,
} from '@/hooks/use-chat-request';
import { cn } from '@/lib/utils';
import { PanelLeftClose, PanelRightClose, Plus } from 'lucide-react';
import { useCallback } from 'react';
import {
Check,
PanelLeftClose,
PanelRightClose,
Plus,
Trash2,
} from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useHandleClickConversationCard } from '../hooks/use-click-card';
import { useSelectDerivedConversationList } from '../hooks/use-select-conversation-list';
@ -35,12 +44,71 @@ export function Sessions({
} = useSelectDerivedConversationList();
const { data } = useFetchDialog();
const { visible, switchVisible } = useSetModalState(true);
const { removeConversation } = useRemoveConversation();
// Selection mode state
const [selectionMode, setSelectionMode] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
// Toggle selection mode (click batch delete icon)
const toggleSelectionMode = useCallback(() => {
setSelectionMode(true);
setSelectedIds(new Set());
}, []);
// Exit selection mode (click return icon)
const exitSelectionMode = useCallback(() => {
setSelectionMode(false);
setSelectedIds(new Set());
}, []);
// Toggle single item selection
const toggleSelection = useCallback((id: string) => {
setSelectedIds((prev) => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
}, []);
// Toggle select all
const toggleSelectAll = useCallback(() => {
setSelectedIds((prev) => {
if (prev.size === conversationList.length) {
return new Set();
}
return new Set(conversationList.map((x) => x.id));
});
}, [conversationList]);
// Batch delete
const handleBatchDelete = useCallback(async () => {
if (selectedIds.size > 0) {
await removeConversation(Array.from(selectedIds));
exitSelectionMode();
}
}, [selectedIds, removeConversation, exitSelectionMode]);
const selectedCount = useMemo(() => selectedIds.size, [selectedIds]);
const allSelected = useMemo(
() =>
selectedCount === conversationList.length && conversationList.length > 0,
[selectedCount, conversationList.length],
);
const handleCardClick = useCallback(
(conversationId: string, isNew: boolean) => () => {
handleConversationCardClick(conversationId, isNew);
if (selectionMode) {
toggleSelection(conversationId);
} else {
handleConversationCardClick(conversationId, isNew);
}
},
[handleConversationCardClick],
[handleConversationCardClick, selectionMode, toggleSelection],
);
const { conversationId } = useGetChatSearchParams();
@ -55,7 +123,7 @@ export function Sessions({
}
return (
<section className="p-6 w-[296px] flex flex-col">
<section className="p-6 w-[296px] flex flex-col">
<section className="flex items-center text-base justify-between gap-2">
<div className="flex gap-3 items-center min-w-0">
<RAGFlowAvatar
@ -77,9 +145,62 @@ export function Sessions({
{conversationList.length}
</span>
</div>
<Button variant={'ghost'} onClick={addTemporaryConversation}>
<Plus></Plus>
</Button>
{selectionMode && selectedCount > 0 ? (
// Selection mode with items selected: show return and delete
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="size-6"
onClick={exitSelectionMode}
>
<img src="/return2.png" alt="返回" className="h-4 w-4" />
</Button>
<ConfirmDeleteDialog
onOk={handleBatchDelete}
title={t('chat.batchDeleteSessions')}
content={t('chat.deleteSelectedConfirm', {
count: selectedCount,
})}
>
<Button
variant="ghost"
size="icon"
className="size-6 text-state-error"
>
<Trash2 className="h-4 w-4" />
</Button>
</ConfirmDeleteDialog>
</div>
) : (
// Default or selection mode without selection: show plus and batch delete
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="size-6"
onClick={addTemporaryConversation}
>
<Plus className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-6"
onClick={selectionMode ? toggleSelectAll : toggleSelectionMode}
>
{selectionMode && allSelected ? (
<Check className="h-4 w-4" />
) : (
<img
src="/batch_delete2.png"
alt="批量删除"
className="h-4 w-4"
/>
)}
</Button>
</div>
)}
</div>
<div className="pb-4">
<SearchInput
@ -92,18 +213,34 @@ export function Sessions({
<Card
key={x.id}
onClick={handleCardClick(x.id, x.is_new)}
className={cn('cursor-pointer bg-transparent', {
'bg-bg-card': conversationId === x.id,
className={cn('cursor-pointer bg-transparent relative', {
'bg-bg-card': conversationId === x.id && !selectionMode,
})}
>
<CardContent className="px-3 py-2 flex justify-between items-center group gap-1">
<div className="truncate">{x.name}</div>
<ConversationDropdown
conversation={x}
removeTemporaryConversation={removeTemporaryConversation}
>
<MoreButton></MoreButton>
</ConversationDropdown>
<div className="flex items-center gap-2 flex-1 min-w-0">
{selectionMode && (
<span
className="flex-shrink-0"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<Checkbox
checked={selectedIds.has(x.id)}
onCheckedChange={() => toggleSelection(x.id)}
/>
</span>
)}
<div className="truncate">{x.name}</div>
</div>
{!selectionMode && (
<ConversationDropdown
conversation={x}
removeTemporaryConversation={removeTemporaryConversation}
>
<MoreButton></MoreButton>
</ConversationDropdown>
)}
</CardContent>
</Card>
))}