feat: add conversation batch deletion for webchat (#6160)

* feat: add conversation batch deletion for webchat

* fix: security issues in batch_delete_sessions and better handle batch select

* feat: enhance batch selection UI with animated checkbox visibility in ConversationSidebar

---------

Co-authored-by: Soulter <905617992@qq.com>
This commit is contained in:
Flartiny
2026-03-14 21:09:36 +08:00
committed by GitHub
parent 06fd2d2428
commit 044b361ac5
9 changed files with 445 additions and 20 deletions
+7
View File
@@ -647,6 +647,13 @@ class BaseDatabase(abc.ABC):
"""Get a Platform session by its ID."""
...
@abc.abstractmethod
async def get_platform_sessions_by_ids(
self, session_ids: list[str]
) -> list[PlatformSession]:
"""Get platform sessions by IDs."""
...
@abc.abstractmethod
async def get_platform_sessions_by_creator(
self,
+15
View File
@@ -1417,6 +1417,21 @@ class SQLiteDatabase(BaseDatabase):
result = await session.execute(query)
return result.scalar_one_or_none()
async def get_platform_sessions_by_ids(
self, session_ids: list[str]
) -> list[PlatformSession]:
"""Get platform sessions by IDs."""
if not session_ids:
return []
async with self.get_db() as session:
session: AsyncSession
query = select(PlatformSession).where(
col(PlatformSession.session_id).in_(session_ids)
)
result = await session.execute(query)
return list(result.scalars().all())
async def get_platform_sessions_by_creator(
self,
creator: str,
+66 -13
View File
@@ -51,6 +51,7 @@ class ChatRoute(Route):
"/chat/get_session": ("GET", self.get_session),
"/chat/stop": ("POST", self.stop_session),
"/chat/delete_session": ("GET", self.delete_webchat_session),
"/chat/batch_delete_sessions": ("POST", self.batch_delete_sessions),
"/chat/update_session_display_name": (
"POST",
self.update_session_display_name,
@@ -578,19 +579,9 @@ class ChatRoute(Route):
return Response().ok(data={"stopped_count": stopped_count}).__dict__
async def delete_webchat_session(self):
"""Delete a Platform session and all its related data."""
session_id = request.args.get("session_id")
if not session_id:
return Response().error("Missing key: session_id").__dict__
username = g.get("username", "guest")
# 验证会话是否存在且属于当前用户
session = await self.db.get_platform_session_by_id(session_id)
if not session:
return Response().error(f"Session {session_id} not found").__dict__
if session.creator != username:
return Response().error("Permission denied").__dict__
async def _delete_session_internal(self, session, username: str) -> None:
"""Delete a single session and all its related data."""
session_id = session.session_id
# 删除该会话下的所有对话
message_type = "GroupMessage" if session.is_group else "FriendMessage"
@@ -632,8 +623,70 @@ class ChatRoute(Route):
# 删除会话
await self.db.delete_platform_session(session_id)
async def delete_webchat_session(self):
"""Delete a Platform session and all its related data."""
session_id = request.args.get("session_id")
if not session_id:
return Response().error("Missing key: session_id").__dict__
username = g.get("username", "guest")
session = await self.db.get_platform_session_by_id(session_id)
if not session:
return Response().error(f"Session {session_id} not found").__dict__
if session.creator != username:
return Response().error("Permission denied").__dict__
await self._delete_session_internal(session, username)
return Response().ok().__dict__
async def batch_delete_sessions(self):
"""Batch delete multiple Platform sessions."""
post_data = await request.json
if post_data is None:
return Response().error("Missing JSON body").__dict__
if not isinstance(post_data, dict):
return Response().error("Invalid JSON body: expected object").__dict__
session_ids = post_data.get("session_ids")
if not session_ids or not isinstance(session_ids, list):
return Response().error("Missing or invalid key: session_ids").__dict__
username = g.get("username", "guest")
sessions = await self.db.get_platform_sessions_by_ids(session_ids)
sessions_by_id = {session.session_id: session for session in sessions}
deleted_count = 0
failed_items = []
for sid in session_ids:
session = sessions_by_id.get(sid)
if not session:
failed_items.append({"session_id": sid, "reason": "not found"})
continue
if session.creator != username:
failed_items.append({"session_id": sid, "reason": "permission denied"})
continue
try:
await self._delete_session_internal(session, username)
deleted_count += 1
sessions_by_id.pop(sid, None)
except Exception:
logger.warning("Failed to delete session %s", sid)
failed_items.append({"session_id": sid, "reason": "internal_error"})
return (
Response()
.ok(
data={
"deleted_count": deleted_count,
"failed_count": len(failed_items),
"failed_items": failed_items,
}
)
.__dict__
)
def _extract_attachment_ids(self, history_list) -> list[str]:
"""从消息历史中提取所有 attachment_id"""
attachment_ids = []
+31
View File
@@ -20,6 +20,7 @@
@selectConversation="handleSelectConversation"
@editTitle="showEditTitleDialog"
@deleteConversation="handleDeleteConversation"
@batchDeleteConversations="handleBatchDeleteConversations"
@closeMobileSidebar="closeMobileSidebar"
@toggleTheme="toggleTheme"
@toggleFullscreen="toggleFullscreen"
@@ -220,6 +221,7 @@ import { useMediaHandling } from '@/composables/useMediaHandling';
import { useProjects } from '@/composables/useProjects';
import type { Project } from '@/components/chat/ProjectList.vue';
import { useRecording } from '@/composables/useRecording';
import { useToast } from '@/utils/toast';
interface Props {
chatboxMode?: boolean;
@@ -233,6 +235,7 @@ const router = useRouter();
const route = useRoute();
const { t } = useI18n();
const { tm } = useModuleI18n('features/chat');
const { warning: toastWarning } = useToast();
const theme = useTheme();
const customizer = useCustomizerStore();
@@ -257,6 +260,7 @@ const {
getSessions,
newSession,
deleteSession: deleteSessionFn,
batchDeleteSessions,
showEditTitleDialog,
saveTitle,
updateSessionTitle,
@@ -510,6 +514,33 @@ async function handleDeleteConversation(sessionId: string) {
}
}
async function handleBatchDeleteConversations(sessionIds: string[]) {
try {
const result = await batchDeleteSessions(sessionIds);
// 仅在当前会话成功删除时清除信息
if (result.currentSessionDeleted) {
messages.value = [];
}
// 失败处理
if (result.failed_count > 0) {
toastWarning(
tm('batch.partialFailure', { failed: result.failed_count, total: sessionIds.length })
);
}
// 如果在项目视图中,刷新项目会话列表
if (selectedProjectId.value) {
const sessions = await getProjectSessions(selectedProjectId.value);
projectSessions.value = sessions;
}
} catch (err) {
console.error('Batch delete sessions failed:', err);
toastWarning(tm('batch.requestFailed'));
}
}
async function handleSelectProject(projectId: string) {
selectedProjectId.value = projectId;
const sessions = await getProjectSessions(projectId);
@@ -21,12 +21,31 @@
</div>
<div style="padding: 8px; opacity: 0.6;">
<v-btn block variant="text" class="new-chat-btn" @click="$emit('newChat')" :disabled="!currSessionId && !selectedProjectId"
v-if="!sidebarCollapsed || isMobile" prepend-icon="mdi-square-edit-outline">{{ tm('actions.newChat') }}</v-btn>
<v-btn icon="mdi-square-edit-outline" rounded="xl" @click="$emit('newChat')" :disabled="!currSessionId && !selectedProjectId"
<div class="new-chat-row" v-if="!sidebarCollapsed || isMobile">
<v-btn block variant="text" class="new-chat-btn" @click="$emit('newChat')" :disabled="!currSessionId && !selectedProjectId"
prepend-icon="mdi-square-edit-outline">{{ tm('actions.newChat') }}</v-btn>
<v-btn v-if="sessions.length > 0" icon size="small" variant="text" @click="toggleBatchMode"
:color="batchMode ? 'primary' : undefined">
<v-icon>mdi-checkbox-multiple-marked-outline</v-icon>
</v-btn>
</div>
<v-btn icon="mdi-square-edit-outline" rounded="xl" @click="$emit('newChat')" :disabled="!currSessionId && !selectedProjectId"
v-if="sidebarCollapsed && !isMobile" elevation="0"></v-btn>
</div>
<!-- Batch action bar -->
<div v-if="batchMode && (!sidebarCollapsed || isMobile)" class="batch-action-bar">
<v-btn size="x-small" variant="text" @click="toggleSelectAll">
{{ isAllSelected ? tm('batch.deselectAll') : tm('batch.selectAll') }}
</v-btn>
<span class="batch-selected-count">{{ tm('batch.selected', { count: batchSelected.length }) }}</span>
<v-spacer />
<v-btn size="x-small" variant="text" color="error" :disabled="batchSelected.length === 0"
@click="handleBatchDelete">
{{ tm('batch.delete') }}
</v-btn>
</div>
<!-- 项目列表组件 -->
<ProjectList
v-if="!sidebarCollapsed || isMobile"
@@ -41,10 +60,25 @@
v-if="!sidebarCollapsed || isMobile">
<v-card v-if="sessions.length > 0" flat style="background-color: transparent;">
<v-list density="compact" nav class="conversation-list"
style="background-color: transparent;" :selected="selectedSessions"
@update:selected="$emit('selectConversation', $event)">
style="background-color: transparent;" :selected="batchMode ? [] : selectedSessions"
@update:selected="handleListSelect">
<v-list-item v-for="item in sessions" :key="item.session_id" :value="item.session_id"
rounded="lg" class="conversation-item" active-color="secondary">
rounded="lg" class="conversation-item" active-color="secondary"
@click="batchMode ? toggleBatchItem(item.session_id) : undefined">
<template v-slot:prepend>
<div class="batch-checkbox-slot" :class="{ 'batch-checkbox-slot--active': batchMode }">
<v-checkbox-btn
:model-value="batchSelected.includes(item.session_id)"
@update:model-value="toggleBatchItem(item.session_id)"
@click.stop
density="compact"
hide-details
class="batch-checkbox"
/>
</div>
</template>
<v-list-item-title v-if="!sidebarCollapsed || isMobile" class="conversation-title"
:style="{ color: 'rgb(var(--v-theme-primaryText))' }">
{{ item.display_name || tm('conversation.newConversation') }}
@@ -53,7 +87,7 @@
{{ new Date(item.updated_at).toLocaleString() }}
</v-list-item-subtitle> -->
<template v-if="!sidebarCollapsed || isMobile" v-slot:append>
<template v-if="!batchMode && (!sidebarCollapsed || isMobile)" v-slot:append>
<div class="conversation-actions">
<v-btn icon="mdi-pencil" size="x-small" variant="text"
class="edit-title-btn"
@@ -254,6 +288,7 @@ const emit = defineEmits<{
selectConversation: [sessionIds: string[]];
editTitle: [sessionId: string, title: string];
deleteConversation: [sessionId: string];
batchDeleteConversations: [sessionIds: string[]];
closeMobileSidebar: [];
toggleTheme: [];
toggleFullscreen: [];
@@ -271,6 +306,53 @@ const confirmDialog = useConfirmDialog();
const sidebarCollapsed = ref(true);
const showProviderConfigDialog = ref(false);
// Batch mode state
const batchMode = ref(false);
const batchSelected = ref<string[]>([]);
const isAllSelected = computed(() =>
props.sessions.length > 0 && batchSelected.value.length === props.sessions.length
);
function toggleBatchMode() {
batchMode.value = !batchMode.value;
batchSelected.value = [];
}
function toggleBatchItem(sessionId: string) {
const idx = batchSelected.value.indexOf(sessionId);
if (idx >= 0) {
batchSelected.value.splice(idx, 1);
} else {
batchSelected.value.push(sessionId);
}
}
function toggleSelectAll() {
if (isAllSelected.value) {
batchSelected.value = [];
} else {
batchSelected.value = props.sessions.map(s => s.session_id);
}
}
async function handleBatchDelete() {
const count = batchSelected.value.length;
if (count === 0) return;
const message = tm('batch.confirmDelete', { count });
if (await askForConfirmation(message, confirmDialog)) {
emit('batchDeleteConversations', [...batchSelected.value]);
batchSelected.value = [];
batchMode.value = false;
}
}
function handleListSelect(sessionIds: string[]) {
if (!batchMode.value) {
emit('selectConversation', sessionIds);
}
}
const transportOptions = [
{ label: tm('transport.sse'), value: 'sse' as const },
{ label: tm('transport.websocket'), value: 'websocket' as const }
@@ -505,4 +587,50 @@ function handleTransportModeChange(mode: string | null) {
font-size: 16px;
margin-right: 8px;
}
.new-chat-row {
display: flex;
align-items: center;
gap: 4px;
}
.new-chat-row .new-chat-btn {
flex: 1;
min-width: 0;
}
.batch-action-bar {
display: flex;
align-items: center;
padding: 4px 12px;
gap: 4px;
flex-shrink: 0;
}
.batch-selected-count {
font-size: 12px;
opacity: 0.7;
white-space: nowrap;
}
.batch-checkbox {
flex: none;
transition: opacity 0.2s ease, transform 0.2s ease;
}
.batch-checkbox-slot {
width: 0;
opacity: 0;
overflow: hidden;
pointer-events: none;
transform: translateX(-8px);
transition: width 0.2s ease, opacity 0.2s ease, transform 0.2s ease;
}
.batch-checkbox-slot--active {
width: 28px;
opacity: 1;
pointer-events: auto;
transform: translateX(0);
}
</style>
+68
View File
@@ -109,6 +109,73 @@ export function useSessions(chatboxMode: boolean = false) {
}
}
interface BatchDeleteFailedItem {
session_id: string;
reason: string;
}
interface BatchDeleteResult {
deleted_count: number;
failed_count: number;
failed_items: BatchDeleteFailedItem[];
currentSessionDeleted: boolean;
}
function isBatchDeleteResponseData(data: unknown): data is {
deleted_count: number;
failed_count: number;
failed_items: BatchDeleteFailedItem[];
} {
if (!data || typeof data !== 'object') {
return false;
}
const payload = data as Record<string, unknown>;
return (
typeof payload.deleted_count === 'number' &&
typeof payload.failed_count === 'number' &&
Array.isArray(payload.failed_items)
);
}
async function batchDeleteSessions(sessionIds: string[]): Promise<BatchDeleteResult> {
try {
const currentSessionId = currSessionId.value;
const response = await axios.post('/api/chat/batch_delete_sessions', { session_ids: sessionIds });
if (response.data?.status !== 'ok') {
throw new Error(response.data?.message || 'Failed to batch delete sessions');
}
const data = response.data?.data;
if (!isBatchDeleteResponseData(data)) {
throw new Error('Invalid batch delete response payload');
}
const failedItems = data.failed_items;
const failedSessionIds = new Set(failedItems.map(item => item.session_id));
const currentSessionDeleted = Boolean(
currentSessionId &&
sessionIds.includes(currentSessionId) &&
!failedSessionIds.has(currentSessionId)
);
if (currentSessionDeleted) {
currSessionId.value = '';
selectedSessions.value = [];
}
await getSessions();
return {
deleted_count: data.deleted_count,
failed_count: data.failed_count,
failed_items: failedItems,
currentSessionDeleted,
};
} catch (err) {
console.error(err);
throw err;
}
}
function showEditTitleDialog(sessionId: string, title: string) {
editingSessionId.value = sessionId;
editingTitle.value = title || '';
@@ -167,6 +234,7 @@ export function useSessions(chatboxMode: boolean = false) {
getSessions,
newSession,
deleteSession,
batchDeleteSessions,
showEditTitleDialog,
saveTitle,
updateSessionTitle,
@@ -141,5 +141,15 @@
"errors": {
"sendMessageFailed": "Failed to send message, please try again",
"createSessionFailed": "Failed to create session, please refresh the page"
},
"batch": {
"selected": "{count} selected",
"confirmDelete": "Are you sure you want to delete {count} conversation(s)? This action cannot be undone.",
"selectAll": "Select All",
"deselectAll": "Deselect All",
"delete": "Delete",
"exit": "Exit",
"partialFailure": "{failed} of {total} conversations failed to delete",
"requestFailed": "Failed to delete conversations. Please try again."
}
}
@@ -141,5 +141,15 @@
"errors": {
"sendMessageFailed": "发送消息失败,请重试",
"createSessionFailed": "创建会话失败,请刷新页面重试"
},
"batch": {
"selected": "已选择 {count} 个",
"confirmDelete": "确定要删除 {count} 个对话吗?此操作无法撤销。",
"selectAll": "全选",
"deselectAll": "取消全选",
"delete": "删除",
"exit": "退出",
"partialFailure": "{total} 个对话中有 {failed} 个删除失败",
"requestFailed": "删除对话失败,请重试。"
}
}
+103
View File
@@ -106,6 +106,109 @@ async def test_get_stat(app: Quart, authenticated_header: dict):
assert data["status"] == "ok" and "platform" in data["data"]
@pytest.mark.asyncio
@pytest.mark.parametrize("payload", [[], "x"])
async def test_batch_delete_sessions_rejects_non_object_payload(
app: Quart, authenticated_header: dict, payload
):
test_client = app.test_client()
response = await test_client.post(
"/api/chat/batch_delete_sessions",
json=payload,
headers=authenticated_header,
)
assert response.status_code == 200
data = await response.get_json()
assert data["status"] == "error"
assert data["message"] == "Invalid JSON body: expected object"
@pytest.mark.asyncio
async def test_batch_delete_sessions_masks_internal_error(
app: Quart, authenticated_header: dict, monkeypatch
):
test_client = app.test_client()
create_session_response = await test_client.get(
"/api/chat/new_session", headers=authenticated_header
)
assert create_session_response.status_code == 200
create_session_data = await create_session_response.get_json()
session_id = create_session_data["data"]["session_id"]
async def _raise_error(*args, **kwargs):
raise RuntimeError("secret-internal-error")
monkeypatch.setattr(
"astrbot.dashboard.routes.chat.ChatRoute._delete_session_internal",
_raise_error,
)
response = await test_client.post(
"/api/chat/batch_delete_sessions",
json={"session_ids": [session_id]},
headers=authenticated_header,
)
assert response.status_code == 200
data = await response.get_json()
assert data["status"] == "ok"
assert data["data"]["deleted_count"] == 0
assert data["data"]["failed_count"] == 1
assert data["data"]["failed_items"][0]["session_id"] == session_id
assert data["data"]["failed_items"][0]["reason"] == "internal_error"
@pytest.mark.asyncio
async def test_batch_delete_sessions_uses_batch_lookup(
app: Quart,
authenticated_header: dict,
core_lifecycle_td: AstrBotCoreLifecycle,
monkeypatch,
):
test_client = app.test_client()
db = core_lifecycle_td.db
create_session_response = await test_client.get(
"/api/chat/new_session", headers=authenticated_header
)
assert create_session_response.status_code == 200
create_session_data = await create_session_response.get_json()
session_id = create_session_data["data"]["session_id"]
original_batch_lookup = db.get_platform_sessions_by_ids
called = {"batch_lookup_count": 0}
async def _wrapped_batch_lookup(session_ids: list[str]):
called["batch_lookup_count"] += 1
return await original_batch_lookup(session_ids)
# 不应单个查询
async def _should_not_call_single_lookup(session_id: str):
raise AssertionError(
f"single-session lookup should not be called: {session_id}"
)
monkeypatch.setattr(db, "get_platform_sessions_by_ids", _wrapped_batch_lookup)
monkeypatch.setattr(
db, "get_platform_session_by_id", _should_not_call_single_lookup
)
response = await test_client.post(
"/api/chat/batch_delete_sessions",
json={"session_ids": [session_id]},
headers=authenticated_header,
)
assert response.status_code == 200
data = await response.get_json()
assert data["status"] == "ok"
assert data["data"]["deleted_count"] == 1
assert data["data"]["failed_count"] == 0
assert called["batch_lookup_count"] == 1
@pytest.mark.asyncio
async def test_plugins(
app: Quart,