feat(chat): add batch management for conversations

This commit is contained in:
AndyYang
2026-02-25 11:37:36 +08:00
committed by lyingbug
parent b536e86cd0
commit 172bf797ee
13 changed files with 610 additions and 1 deletions
+26
View File
@@ -9,6 +9,7 @@
| GET | `/sessions` | 获取租户的会话列表 |
| PUT | `/sessions/:id` | 更新会话 |
| DELETE | `/sessions/:id` | 删除会话 |
| DELETE | `/sessions/batch` | 批量删除会话 |
| POST | `/sessions/:session_id/generate_title` | 生成会话标题 |
| POST | `/sessions/:session_id/stop` | 停止会话 |
| GET | `/sessions/continue-stream/:session_id` | 继续未完成的会话 |
@@ -315,6 +316,31 @@ curl --location --request DELETE 'http://localhost:8080/api/v1/sessions/411d6b70
}
```
## DELETE `/sessions/batch` - 批量删除会话
**请求**:
```curl
curl --location --request DELETE 'http://localhost:8080/api/v1/sessions/batch' \
--header 'X-API-Key: sk-vQHV2NZI_LK5W7wHQvH3yGYExX8YnhaHwZipUYbiZKCYJbBQ' \
--header 'Content-Type: application/json' \
--data '{
"ids": [
"411d6b70-9a85-4d03-bb74-aab0fd8bd12f",
"ceb9babb-1e30-41d7-817d-fd584954304b"
]
}'
```
**响应**:
```json
{
"message": "Sessions deleted successfully",
"success": true
}
```
## POST `/sessions/:session_id/generate_title` - 生成会话标题
**请求**:
+4
View File
@@ -44,6 +44,10 @@ export async function delSession(session_id: string) {
return del(`/api/v1/sessions/${session_id}`);
}
export async function batchDelSessions(ids: string[]) {
return del(`/api/v1/sessions/batch`, { ids });
}
export async function getSession(session_id: string) {
return get(`/api/v1/sessions/${session_id}`);
}
@@ -0,0 +1,390 @@
<template>
<Teleport to="body">
<Transition name="modal">
<div v-if="visible" class="batch-overlay" @click.self="handleClose">
<div class="batch-modal">
<!-- 顶部标题栏 -->
<div class="batch-header">
<h2 class="batch-title">{{ t('batchManage.title') }}</h2>
<button class="close-btn" @click="handleClose">
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<path d="M15 5L5 15M5 5L15 15" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
</div>
<!-- 会话列表 -->
<div class="batch-body">
<div class="session-list" v-if="sessions.length > 0">
<div
class="session-item"
v-for="item in sessions"
:key="item.id"
>
<t-checkbox
:checked="selectedIds.includes(item.id)"
@change="toggleSelect(item.id)"
/>
<div class="session-info" @click="toggleSelect(item.id)">
<div class="session-title">{{ item.title || t('menu.newSession') }}</div>
<div class="session-time">{{ formatTime(item.updated_at || item.created_at) }}</div>
</div>
<button class="delete-single-btn" @click="handleDeleteSingle(item)">
<t-icon name="delete" />
</button>
</div>
</div>
<div v-else class="empty-state">
<p>{{ t('menu.newSession') }}</p>
</div>
</div>
<!-- 底部操作栏 -->
<div class="batch-footer">
<div class="footer-left">
<t-checkbox
:checked="isAllSelected"
:indeterminate="isIndeterminate"
@change="toggleSelectAll"
>
{{ t('batchManage.selectAll') }}
</t-checkbox>
</div>
<div class="footer-right">
<t-button theme="default" variant="outline" @click="handleClose">
{{ t('batchManage.cancel') }}
</t-button>
<t-button
theme="danger"
:disabled="selectedIds.length === 0"
:loading="deleting"
@click="handleBatchDelete"
>
{{ t('batchManage.delete') }}
</t-button>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
import { batchDelSessions, delSession } from '@/api/chat/index'
const { t } = useI18n()
const props = defineProps<{
visible: boolean
sessions: Array<{
id: string
title: string
created_at?: string
updated_at?: string
}>
}>()
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
(e: 'deleted', ids: string[]): void
}>()
const selectedIds = ref<string[]>([])
const deleting = ref(false)
const isAllSelected = computed(() =>
props.sessions.length > 0 && selectedIds.value.length === props.sessions.length
)
const isIndeterminate = computed(() =>
selectedIds.value.length > 0 && selectedIds.value.length < props.sessions.length
)
watch(() => props.visible, (val) => {
if (val) selectedIds.value = []
})
const toggleSelect = (id: string) => {
const idx = selectedIds.value.indexOf(id)
if (idx > -1) {
selectedIds.value.splice(idx, 1)
} else {
selectedIds.value.push(id)
}
}
const toggleSelectAll = (checked: boolean) => {
selectedIds.value = checked ? props.sessions.map(s => s.id) : []
}
const formatTime = (dateStr?: string) => {
if (!dateStr) return ''
const d = new Date(dateStr)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
const handleDeleteSingle = (item: { id: string; title: string }) => {
const confirmDialog = DialogPlugin.confirm({
header: t('batchManage.deleteConfirmTitle'),
body: t('batchManage.deleteConfirmBody', { count: 1 }),
confirmBtn: { content: t('common.delete'), theme: 'danger' },
cancelBtn: t('common.cancel'),
theme: 'warning',
onConfirm: async () => {
try {
const res: any = await delSession(item.id)
if (res && res.success === true) {
emit('deleted', [item.id])
MessagePlugin.success(t('batchManage.deleteSuccess'))
} else {
MessagePlugin.error(t('batchManage.deleteFailed'))
}
} catch {
MessagePlugin.error(t('batchManage.deleteFailed'))
}
confirmDialog.destroy()
},
})
}
const handleBatchDelete = () => {
if (selectedIds.value.length === 0) {
MessagePlugin.warning(t('batchManage.noSelection'))
return
}
const confirmDialog = DialogPlugin.confirm({
header: t('batchManage.deleteConfirmTitle'),
body: t('batchManage.deleteConfirmBody', { count: selectedIds.value.length }),
confirmBtn: { content: t('common.delete'), theme: 'danger' },
cancelBtn: t('common.cancel'),
theme: 'warning',
onConfirm: async () => {
deleting.value = true
try {
const ids = [...selectedIds.value]
const res: any = await batchDelSessions(ids)
if (res && res.success === true) {
emit('deleted', ids)
selectedIds.value = []
MessagePlugin.success(t('batchManage.deleteSuccess'))
} else {
MessagePlugin.error(t('batchManage.deleteFailed'))
}
} catch {
MessagePlugin.error(t('batchManage.deleteFailed'))
}
deleting.value = false
confirmDialog.destroy()
},
})
}
const handleClose = () => {
emit('update:visible', false)
}
</script>
<style lang="less" scoped>
/* 遮罩层 - 与 Settings.vue 保持一致 */
.batch-overlay {
position: fixed;
inset: 0;
z-index: 1100;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
backdrop-filter: blur(4px);
}
/* 弹窗容器 */
.batch-modal {
position: relative;
width: 100%;
max-width: 600px;
max-height: 70vh;
background: #ffffff;
border-radius: 12px;
box-shadow: 0 6px 28px rgba(15, 23, 42, 0.08);
display: flex;
flex-direction: column;
overflow: hidden;
}
/* 顶部标题栏 */
.batch-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px 16px;
border-bottom: 1px solid #e5e7eb;
flex-shrink: 0;
}
.batch-title {
font-size: 18px;
font-weight: 600;
color: #333333;
margin: 0;
}
.close-btn {
width: 32px;
height: 32px;
border: none;
background: transparent;
color: #666666;
cursor: pointer;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
&:hover {
background: #f5f5f5;
color: #333333;
}
}
/* 会话列表区域 */
.batch-body {
flex: 1;
overflow-y: auto;
min-height: 0;
}
/* 滚动条样式 - 与 Settings 一致 */
.batch-body::-webkit-scrollbar {
width: 6px;
}
.batch-body::-webkit-scrollbar-track {
background: #ffffff;
}
.batch-body::-webkit-scrollbar-thumb {
background: #d0d0d0;
border-radius: 3px;
}
.batch-body::-webkit-scrollbar-thumb:hover {
background: #b0b0b0;
}
.session-list {
padding: 4px 0;
}
.session-item {
display: flex;
align-items: center;
padding: 14px 24px;
border-bottom: 1px solid #f0f0f0;
gap: 14px;
transition: background 0.2s ease;
&:last-child {
border-bottom: none;
}
&:hover {
background: #f8f9fa;
.delete-single-btn {
opacity: 1;
}
}
}
.session-info {
flex: 1;
min-width: 0;
cursor: pointer;
}
.session-title {
font-size: 14px;
font-weight: 400;
color: #333333;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
line-height: 22px;
}
.session-time {
font-size: 12px;
color: #999999;
margin-top: 4px;
line-height: 18px;
}
.delete-single-btn {
opacity: 0;
transition: all 0.2s ease;
width: 32px;
height: 32px;
border: none;
background: transparent;
color: #999999;
cursor: pointer;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
&:hover {
color: #e34d59;
background: rgba(227, 77, 89, 0.06);
}
}
.empty-state {
padding: 48px 24px;
text-align: center;
color: #999999;
font-size: 14px;
}
/* 底部操作栏 */
.batch-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
border-top: 1px solid #e5e7eb;
flex-shrink: 0;
background: #ffffff;
}
.footer-right {
display: flex;
gap: 8px;
}
/* 弹窗动画 */
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-active .batch-modal,
.modal-leave-active .batch-modal {
transition: transform 0.2s ease, opacity 0.2s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-from .batch-modal,
.modal-leave-to .batch-modal {
transform: scale(0.95);
opacity: 0;
}
</style>
+44 -1
View File
@@ -34,7 +34,7 @@
{{ subitem.title }}
</span>
<t-dropdown
:options="[{ content: t('upload.deleteRecord'), value: 'delete' }]"
:options="[{ content: t('upload.deleteRecord'), value: 'delete' }, { content: t('menu.batchManage'), value: 'batchManage' }]"
@click="handleSessionMenuClick($event, subitem.originalIndex, subitem)"
placement="bottom-right"
trigger="click">
@@ -54,6 +54,13 @@
<div class="menu_bottom">
<UserMenu />
</div>
<!-- 批量管理对话框 -->
<BatchManageDialog
v-model:visible="batchManageVisible"
:sessions="allSessions"
@deleted="handleBatchDeleted"
/>
</div>
</template>
@@ -72,6 +79,7 @@ import { useUIStore } from '@/stores/ui';
import { MessagePlugin } from "tdesign-vue-next";
import UserMenu from '@/components/UserMenu.vue';
import TenantSelector from '@/components/TenantSelector.vue';
import BatchManageDialog from '@/components/BatchManageDialog.vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
@@ -94,6 +102,21 @@ type MenuItem = { title: string; icon: string; path: string; childrenPath?: stri
const { menuArr } = storeToRefs(usemenuStore);
let activeSubmenu = ref<string>('');
// 批量管理状态
const batchManageVisible = ref(false);
// 所有会话列表(用于批量管理对话框)
const allSessions = computed(() => {
const chatMenu = (menuArr.value as unknown as MenuItem[]).find((item: MenuItem) => item.path === 'creatChat');
if (!chatMenu || !chatMenu.children) return [];
return chatMenu.children.map((s: any) => ({
id: s.id,
title: s.title,
created_at: s.created_at,
updated_at: s.updated_at,
}));
});
// 是否可以访问所有租户
const canAccessAllTenants = computed(() => authStore.canAccessAllTenants);
@@ -256,6 +279,8 @@ const mouseleaveBotDown = () => {
const handleSessionMenuClick = (data: { value: string }, index: number, item: any) => {
if (data?.value === 'delete') {
delCard(index, item);
} else if (data?.value === 'batchManage') {
batchManageVisible.value = true;
}
};
@@ -288,6 +313,24 @@ const delCard = (index: number, item: any) => {
}
})
}
const handleBatchDeleted = (ids: string[]) => {
const chatMenuItem = (menuArr.value as any[]).find((m: any) => m.path === 'creatChat');
if (chatMenuItem && chatMenuItem.children) {
const children = chatMenuItem.children;
for (const id of ids) {
const idx = children.findIndex((s: any) => s.id === id);
if (idx !== -1) children.splice(idx, 1);
}
}
total.value = Math.max(0, total.value - ids.length);
// 如果当前会话被删除,跳转到创建页
const currentChatId = route.params.chatid as string;
if (currentChatId && ids.includes(currentChatId)) {
router.push('/platform/creatChat');
}
}
const debounce = (fn: (...args: any[]) => void, delay: number) => {
let timer: ReturnType<typeof setTimeout>
return (...args: any[]) => {
+12
View File
@@ -10,10 +10,22 @@ export default {
logout: 'Logout',
uploadKnowledge: 'Upload Knowledge',
deleteRecord: 'Delete Record',
batchManage: 'Batch Manage',
newSession: 'New Chat',
confirmLogout: 'Are you sure you want to logout?',
systemInfo: 'System Information'
},
batchManage: {
title: 'Manage Conversations',
selectAll: 'Select All',
cancel: 'Cancel',
delete: 'Delete Conversations',
deleteConfirmTitle: 'Delete Conversations',
deleteConfirmBody: 'Are you sure you want to delete the selected {count} conversation(s)? This action cannot be undone.',
deleteSuccess: 'Deleted successfully',
deleteFailed: 'Delete failed, please try again later',
noSelection: 'Please select at least one conversation',
},
listSpaceSidebar: {
title: 'Filter',
all: 'All',
+12
View File
@@ -8,10 +8,22 @@ export default {
logout: "로그아웃",
uploadKnowledge: "지식 업로드",
deleteRecord: "기록 삭제",
batchManage: "일괄 관리",
newSession: "새 세션",
confirmLogout: "정말 로그아웃 하시겠습니까?",
systemInfo: "시스템 정보",
},
batchManage: {
title: "대화 관리",
selectAll: "전체 선택",
cancel: "취소",
delete: "대화 삭제",
deleteConfirmTitle: "대화 삭제",
deleteConfirmBody: "선택한 {count}개의 대화를 삭제하시겠습니까? 삭제 후 복구할 수 없습니다.",
deleteSuccess: "삭제 성공",
deleteFailed: "삭제 실패, 나중에 다시 시도해 주세요",
noSelection: "최소 하나의 대화를 선택해 주세요",
},
knowledgeBase: {
title: "지식베이스",
list: "지식베이스 목록",
+12
View File
@@ -8,10 +8,22 @@ export default {
logout: 'Выход',
uploadKnowledge: 'Загрузить знания',
deleteRecord: 'Удалить запись',
batchManage: 'Пакетное управление',
newSession: 'Новый диалог',
confirmLogout: 'Вы уверены, что хотите выйти?',
systemInfo: 'Информация о системе'
},
batchManage: {
title: 'Управление диалогами',
selectAll: 'Выбрать все',
cancel: 'Отмена',
delete: 'Удалить диалоги',
deleteConfirmTitle: 'Удалить диалоги',
deleteConfirmBody: 'Вы уверены, что хотите удалить выбранные {count} диалог(ов)? Это действие необратимо.',
deleteSuccess: 'Успешно удалено',
deleteFailed: 'Ошибка удаления, попробуйте позже',
noSelection: 'Выберите хотя бы один диалог',
},
knowledgeBase: {
title: 'База знаний',
list: 'Список баз знаний',
+12
View File
@@ -10,10 +10,22 @@ export default {
logout: "退出登录",
uploadKnowledge: "上传知识",
deleteRecord: "删除记录",
batchManage: "批量管理",
newSession: "新会话",
confirmLogout: "确定要退出登录吗?",
systemInfo: "系统信息",
},
batchManage: {
title: "管理对话记录",
selectAll: "全选",
cancel: "取消",
delete: "删除对话",
deleteConfirmTitle: "删除对话",
deleteConfirmBody: "确定要删除选中的 {count} 条对话吗?删除后无法恢复。",
deleteSuccess: "删除成功",
deleteFailed: "删除失败,请稍后再试",
noSelection: "请至少选择一条对话",
},
listSpaceSidebar: {
title: "筛选",
all: "全部",
@@ -87,3 +87,11 @@ func (r *sessionRepository) Update(ctx context.Context, session *types.Session)
func (r *sessionRepository) Delete(ctx context.Context, tenantID uint64, id string) error {
return r.db.WithContext(ctx).Where("tenant_id = ?", tenantID).Delete(&types.Session{}, "id = ?", id).Error
}
// BatchDelete deletes multiple sessions by IDs
func (r *sessionRepository) BatchDelete(ctx context.Context, tenantID uint64, ids []string) error {
if len(ids) == 0 {
return nil
}
return r.db.WithContext(ctx).Where("tenant_id = ? AND id IN ?", tenantID, ids).Delete(&types.Session{}).Error
}
+32
View File
@@ -225,6 +225,38 @@ func (s *sessionService) DeleteSession(ctx context.Context, id string) error {
return nil
}
// BatchDeleteSessions deletes multiple sessions by IDs
func (s *sessionService) BatchDeleteSessions(ctx context.Context, ids []string) error {
if len(ids) == 0 {
logger.Error(ctx, "Failed to batch delete sessions: IDs list is empty")
return errors.New("session ids are required")
}
// Get tenant ID from context
tenantID := ctx.Value(types.TenantIDContextKey).(uint64)
// Cleanup associated resources for each session
for _, id := range ids {
if err := s.webSearchStateRepo.DeleteWebSearchTempKBState(ctx, id); err != nil {
logger.Warnf(ctx, "Failed to cleanup temporary KB for session %s: %v", id, err)
}
if err := s.sessionStorage.Delete(ctx, id); err != nil {
logger.Warnf(ctx, "Failed to cleanup conversation context for session %s: %v", id, err)
}
}
// Batch delete sessions from repository
if err := s.sessionRepo.BatchDelete(ctx, tenantID, ids); err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"session_ids": ids,
"tenant_id": tenantID,
})
return err
}
return nil
}
// GenerateTitle generates a title for the current conversation content
// modelID: optional model ID to use for title generation (if empty, uses first available KnowledgeQA model)
func (s *sessionService) GenerateTitle(ctx context.Context,
+53
View File
@@ -303,3 +303,56 @@ func (h *Handler) DeleteSession(c *gin.Context) {
"message": "Session deleted successfully",
})
}
// batchDeleteRequest represents the request body for batch deleting sessions
type batchDeleteRequest struct {
IDs []string `json:"ids" binding:"required,min=1"`
}
// BatchDeleteSessions godoc
// @Summary 批量删除会话
// @Description 根据ID列表批量删除对话会话
// @Tags 会话
// @Accept json
// @Produce json
// @Param request body batchDeleteRequest true "批量删除请求"
// @Success 200 {object} map[string]interface{} "删除结果"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /sessions/batch [delete]
func (h *Handler) BatchDeleteSessions(c *gin.Context) {
ctx := c.Request.Context()
var req batchDeleteRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.Errorf(ctx, "Invalid batch delete request: %v", err)
c.Error(errors.NewBadRequestError("invalid request: ids are required"))
return
}
// Sanitize all IDs
sanitizedIDs := make([]string, 0, len(req.IDs))
for _, id := range req.IDs {
sanitized := secutils.SanitizeForLog(id)
if sanitized != "" {
sanitizedIDs = append(sanitizedIDs, sanitized)
}
}
if len(sanitizedIDs) == 0 {
c.Error(errors.NewBadRequestError("no valid session IDs provided"))
return
}
if err := h.sessionService.BatchDeleteSessions(ctx, sanitizedIDs); err != nil {
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError(err.Error()))
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Sessions deleted successfully",
})
}
+1
View File
@@ -266,6 +266,7 @@ func RegisterSessionRoutes(r *gin.RouterGroup, handler *session.Handler) {
sessions := r.Group("/sessions")
{
sessions.POST("", handler.CreateSession)
sessions.DELETE("/batch", handler.BatchDeleteSessions)
sessions.GET("/:id", handler.GetSession)
sessions.GET("", handler.GetSessionsByTenant)
sessions.PUT("/:id", handler.UpdateSession)
+4
View File
@@ -21,6 +21,8 @@ type SessionService interface {
UpdateSession(ctx context.Context, session *types.Session) error
// DeleteSession deletes a session
DeleteSession(ctx context.Context, id string) error
// BatchDeleteSessions deletes multiple sessions by IDs
BatchDeleteSessions(ctx context.Context, ids []string) error
// GenerateTitle generates a title for the current conversation
// modelID: optional model ID to use for title generation (if empty, uses first available KnowledgeQA model)
GenerateTitle(ctx context.Context, session *types.Session, messages []types.Message, modelID string) (string, error)
@@ -79,4 +81,6 @@ type SessionRepository interface {
Update(ctx context.Context, session *types.Session) error
// Delete deletes a session
Delete(ctx context.Context, tenantID uint64, id string) error
// BatchDelete deletes multiple sessions by IDs
BatchDelete(ctx context.Context, tenantID uint64, ids []string) error
}