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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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": "删除对话失败,请重试。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user