diff --git a/backend/docs/docs.go b/backend/docs/docs.go index ffb0deaa..804d0ade 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -4160,7 +4160,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "删除当前用户项目分组。默认仅解除其下会话归属;delete_conversations=true 时同时软删除项目内会话。", + "description": "删除当前用户项目分组。默认仅解除其下会话归属;delete_conversations=true 时同时软删除项目内会话;delete_files=true 时同步删除不再被其他会话引用的文件。", "consumes": [ "application/json" ], @@ -4184,6 +4184,12 @@ const docTemplate = `{ "description": "是否同时删除项目内会话", "name": "delete_conversations", "in": "query" + }, + { + "type": "boolean", + "description": "是否同步删除不再被其他会话引用的会话文件", + "name": "delete_files", + "in": "query" } ], "responses": { @@ -4675,6 +4681,12 @@ const docTemplate = `{ "name": "id", "in": "path", "required": true + }, + { + "type": "boolean", + "description": "是否同步删除不再被其他会话引用的会话文件", + "name": "delete_files", + "in": "query" } ], "responses": { @@ -10168,6 +10180,12 @@ const docTemplate = `{ "properties": { "deleted": { "type": "boolean" + }, + "deletedFileCount": { + "type": "integer" + }, + "quota": { + "$ref": "#/definitions/internal_transport_http_conversation.StorageQuotaResponse" } } }, diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 4c6710e6..004d2b4e 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -4153,7 +4153,7 @@ "BearerAuth": [] } ], - "description": "删除当前用户项目分组。默认仅解除其下会话归属;delete_conversations=true 时同时软删除项目内会话。", + "description": "删除当前用户项目分组。默认仅解除其下会话归属;delete_conversations=true 时同时软删除项目内会话;delete_files=true 时同步删除不再被其他会话引用的文件。", "consumes": [ "application/json" ], @@ -4177,6 +4177,12 @@ "description": "是否同时删除项目内会话", "name": "delete_conversations", "in": "query" + }, + { + "type": "boolean", + "description": "是否同步删除不再被其他会话引用的会话文件", + "name": "delete_files", + "in": "query" } ], "responses": { @@ -4668,6 +4674,12 @@ "name": "id", "in": "path", "required": true + }, + { + "type": "boolean", + "description": "是否同步删除不再被其他会话引用的会话文件", + "name": "delete_files", + "in": "query" } ], "responses": { @@ -10161,6 +10173,12 @@ "properties": { "deleted": { "type": "boolean" + }, + "deletedFileCount": { + "type": "integer" + }, + "quota": { + "$ref": "#/definitions/internal_transport_http_conversation.StorageQuotaResponse" } } }, diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index d4edf25f..c4d3b351 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -2397,6 +2397,10 @@ definitions: properties: deleted: type: boolean + deletedFileCount: + type: integer + quota: + $ref: '#/definitions/internal_transport_http_conversation.StorageQuotaResponse' type: object internal_transport_http_conversation.ConversationDeleteResponseDoc: properties: @@ -5974,7 +5978,8 @@ paths: delete: consumes: - application/json - description: 删除当前用户项目分组。默认仅解除其下会话归属;delete_conversations=true 时同时软删除项目内会话。 + description: 删除当前用户项目分组。默认仅解除其下会话归属;delete_conversations=true 时同时软删除项目内会话;delete_files=true + 时同步删除不再被其他会话引用的文件。 parameters: - description: 项目 public_id in: path @@ -5985,6 +5990,10 @@ paths: in: query name: delete_conversations type: boolean + - description: 是否同步删除不再被其他会话引用的会话文件 + in: query + name: delete_files + type: boolean produces: - application/json responses: @@ -6227,6 +6236,10 @@ paths: name: id required: true type: string + - description: 是否同步删除不再被其他会话引用的会话文件 + in: query + name: delete_files + type: boolean produces: - application/json responses: diff --git a/backend/internal/application/conversation/service_conversation.go b/backend/internal/application/conversation/service_conversation.go index 42d65dc6..cff2a48a 100644 --- a/backend/internal/application/conversation/service_conversation.go +++ b/backend/internal/application/conversation/service_conversation.go @@ -15,6 +15,18 @@ const ( maxPageSize = 100 ) +// DeleteConversationOptions 定义会话删除选项。 +type DeleteConversationOptions struct { + DeleteFiles bool +} + +// DeleteConversationResult 返回会话删除结果。 +type DeleteConversationResult struct { + Deleted bool + DeletedFileCount int + Quota *model.StorageQuota +} + // CreateConversation 创建用户新会话。 func (s *Service) CreateConversation(ctx context.Context, userID uint, title string, modelName string, projectPublicID string) (*model.Conversation, error) { normalizedTitle := strings.TrimSpace(title) @@ -218,15 +230,20 @@ func (s *Service) SetConversationArchived(ctx context.Context, userID uint, publ return item, nil } -// DeleteConversation 删除会话(软删除)。 -func (s *Service) DeleteConversation(ctx context.Context, userID uint, publicID string) error { - if err := s.repo.DeleteConversationByPublicID(ctx, userID, publicID); err != nil { +// DeleteConversation 删除会话(软删除),并按需清理不再被其他会话引用的文件。 +func (s *Service) DeleteConversation(ctx context.Context, userID uint, publicID string, options DeleteConversationOptions) (*DeleteConversationResult, error) { + cleanupFileIDs, err := s.repo.DeleteConversationByPublicID(ctx, userID, publicID, options.DeleteFiles) + if err != nil { if errors.Is(err, repository.ErrNotFound) { - return ErrConversationNotFound + return nil, ErrConversationNotFound } - return err + return nil, err + } + result := &DeleteConversationResult{Deleted: true} + if options.DeleteFiles { + result.DeletedFileCount, result.Quota = s.deleteConversationFiles(ctx, userID, cleanupFileIDs) } - return nil + return result, nil } // GetConversation 查询用户会话元信息。 diff --git a/backend/internal/application/conversation/service_file_cleanup.go b/backend/internal/application/conversation/service_file_cleanup.go new file mode 100644 index 00000000..929a17a2 --- /dev/null +++ b/backend/internal/application/conversation/service_file_cleanup.go @@ -0,0 +1,42 @@ +package conversation + +import ( + "context" + "strings" + + model "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/conversation" + "go.uber.org/zap" +) + +// deleteConversationFiles 清理会话删除后不再被其他活跃会话引用的文件。 +func (s *Service) deleteConversationFiles(ctx context.Context, userID uint, fileIDs []string) (int, *model.StorageQuota) { + if len(fileIDs) == 0 || s.uploadSvc == nil { + return 0, nil + } + deletedCount := 0 + var latestQuota *model.StorageQuota + for _, rawFileID := range fileIDs { + fileID := strings.TrimSpace(rawFileID) + if fileID == "" { + continue + } + result, deleted, err := s.uploadSvc.DeleteFileIfUnreferenced(ctx, userID, fileID) + if err != nil { + if s.logger != nil { + s.logger.Warn("delete_conversation_file_failed", + zap.Uint("user_id", userID), + zap.String("file_id", fileID), + zap.Error(err), + ) + } + continue + } + if !deleted || result == nil { + continue + } + deletedCount++ + quota := result.Quota + latestQuota = "a + } + return deletedCount, latestQuota +} diff --git a/backend/internal/application/conversation/service_project.go b/backend/internal/application/conversation/service_project.go index 05aa9440..cd80136e 100644 --- a/backend/internal/application/conversation/service_project.go +++ b/backend/internal/application/conversation/service_project.go @@ -82,14 +82,31 @@ func (s *Service) UpdateConversationProject( } // DeleteConversationProject 删除当前用户项目分组。 -func (s *Service) DeleteConversationProject(ctx context.Context, userID uint, publicID string, deleteConversations bool) error { - if err := s.repo.DeleteConversationProjectByPublicID(ctx, userID, strings.TrimSpace(publicID), deleteConversations); err != nil { +func (s *Service) DeleteConversationProject( + ctx context.Context, + userID uint, + publicID string, + deleteConversations bool, + options DeleteConversationOptions, +) (*DeleteConversationResult, error) { + cleanupFileIDs, err := s.repo.DeleteConversationProjectByPublicID( + ctx, + userID, + strings.TrimSpace(publicID), + deleteConversations, + deleteConversations && options.DeleteFiles, + ) + if err != nil { if errors.Is(err, repository.ErrNotFound) { - return ErrConversationProjectNotFound + return nil, ErrConversationProjectNotFound } - return err + return nil, err } - return nil + result := &DeleteConversationResult{Deleted: true} + if deleteConversations && options.DeleteFiles { + result.DeletedFileCount, result.Quota = s.deleteConversationFiles(ctx, userID, cleanupFileIDs) + } + return result, nil } // ReorderConversationProjects 更新当前用户项目展示顺序。 diff --git a/backend/internal/application/upload/service.go b/backend/internal/application/upload/service.go index 12b0a756..fa9ccb04 100644 --- a/backend/internal/application/upload/service.go +++ b/backend/internal/application/upload/service.go @@ -348,7 +348,7 @@ func (s *Service) tryReuseExistingFile( zap.String("path", existingFile.StoragePath), ) } - deletedFile, _, shouldRemovePhysical, deleteErr := s.repo.DeleteFileObjectAndReleaseQuota(ctx, userID, existingFile.FileID, quotaBytes) + deletedFile, _, shouldRemovePhysical, deleteErr := s.repo.DeleteFileObjectAndReleaseQuota(ctx, userID, existingFile.FileID, quotaBytes, repository.DeleteFileObjectOptions{}) if deleteErr != nil { return nil, false, deleteErr } @@ -413,15 +413,28 @@ func objectMatchesContent(ctx context.Context, store objectstore.Store, path str // DeleteFile 删除文件并回收配额。 func (s *Service) DeleteFile(ctx context.Context, userID uint, fileID string) (*DeleteFileResult, error) { + result, _, err := s.deleteFile(ctx, userID, fileID, repository.DeleteFileObjectOptions{}) + return result, err +} + +// DeleteFileIfUnreferenced 仅在文件未被活跃会话引用时删除文件并回收配额。 +func (s *Service) DeleteFileIfUnreferenced(ctx context.Context, userID uint, fileID string) (*DeleteFileResult, bool, error) { + return s.deleteFile(ctx, userID, fileID, repository.DeleteFileObjectOptions{RequireUnreferenced: true}) +} + +func (s *Service) deleteFile(ctx context.Context, userID uint, fileID string, options repository.DeleteFileObjectOptions) (*DeleteFileResult, bool, error) { normalizedFileID := strings.TrimSpace(fileID) if normalizedFileID == "" { - return nil, s.errInvalidFileReference() + return nil, false, s.errInvalidFileReference() } cfg := s.snapshot() - deletedFile, quota, shouldRemovePhysical, err := s.repo.DeleteFileObjectAndReleaseQuota(ctx, userID, normalizedFileID, cfg.UserStorageQuotaBytes) + deletedFile, quota, shouldRemovePhysical, err := s.repo.DeleteFileObjectAndReleaseQuota(ctx, userID, normalizedFileID, cfg.UserStorageQuotaBytes, options) if err != nil { - return nil, err + if options.RequireUnreferenced && errors.Is(err, repository.ErrConflict) { + return nil, false, nil + } + return nil, false, err } if shouldRemovePhysical { store, storeErr := s.openObjectStore(ctx) @@ -442,7 +455,7 @@ func (s *Service) DeleteFile(ctx context.Context, userID uint, fileID string) (* Deleted: true, FileID: normalizedFileID, Quota: *quota, - }, nil + }, true, nil } // RenameFile 重命名当前用户文件。 diff --git a/backend/internal/application/upload/service_test.go b/backend/internal/application/upload/service_test.go index 161ab671..8bdbd5ef 100644 --- a/backend/internal/application/upload/service_test.go +++ b/backend/internal/application/upload/service_test.go @@ -85,6 +85,33 @@ func TestUploadFileAllowsReuploadAfterDelete(t *testing.T) { } } +func TestDeleteFileIfUnreferencedSkipsReferencedFile(t *testing.T) { + ctx := context.Background() + repo := newUploadTestRepo() + store := newUploadTestStore() + service := newUploadTestService(repo, store) + + uploaded, err := service.UploadFile(ctx, uploadTestInput("notes.md", "same content")) + if err != nil { + t.Fatalf("upload failed: %v", err) + } + repo.referencedFileIDs[uploaded.File.FileID] = true + + result, deleted, err := service.DeleteFileIfUnreferenced(ctx, 1, uploaded.File.FileID) + if err != nil { + t.Fatalf("delete if unreferenced failed: %v", err) + } + if deleted || result != nil { + t.Fatal("referenced file should be skipped without returning a delete result") + } + if status := repo.fileStatus(uploaded.File.FileID); status != "active" { + t.Fatalf("referenced file should remain active, got %q", status) + } + if got := store.objectCount(); got != 1 { + t.Fatalf("referenced file should keep physical object, got %d objects", got) + } +} + func TestUploadFileReplacesStaleDuplicatePointer(t *testing.T) { ctx := context.Background() repo := newUploadTestRepo() @@ -301,6 +328,7 @@ type uploadTestRepo struct { quota domainconversation.StorageQuota missNextDuplicateLookup bool failNextCreateDuplicate bool + referencedFileIDs map[string]bool } func newUploadTestRepo() *uploadTestRepo { @@ -310,6 +338,7 @@ func newUploadTestRepo() *uploadTestRepo { UserID: 1, QuotaBytes: 10 * 1024 * 1024, }, + referencedFileIDs: make(map[string]bool), } } @@ -392,7 +421,7 @@ func (r *uploadTestRepo) CreateFileObjectAndConsumeQuota(_ context.Context, item return cloneQuota(r.quota), nil } -func (r *uploadTestRepo) DeleteFileObjectAndReleaseQuota(_ context.Context, userID uint, fileID string, quotaLimit int64) (*domainconversation.FileObject, *domainconversation.StorageQuota, bool, error) { +func (r *uploadTestRepo) DeleteFileObjectAndReleaseQuota(_ context.Context, userID uint, fileID string, quotaLimit int64, options repository.DeleteFileObjectOptions) (*domainconversation.FileObject, *domainconversation.StorageQuota, bool, error) { if quotaLimit > 0 { r.quota.QuotaBytes = quotaLimit } @@ -400,6 +429,9 @@ func (r *uploadTestRepo) DeleteFileObjectAndReleaseQuota(_ context.Context, user if r.files[i].UserID != userID || r.files[i].FileID != fileID || r.files[i].Status != "active" { continue } + if options.RequireUnreferenced && r.referencedFileIDs[fileID] { + return nil, nil, false, repository.ErrConflict + } deleted := r.files[i] r.files[i].Status = "deleted" remainingRefs := 0 diff --git a/backend/internal/infra/persistence/postgres/conversation/repository.go b/backend/internal/infra/persistence/postgres/conversation/repository.go index 066255f7..29181a9b 100644 --- a/backend/internal/infra/persistence/postgres/conversation/repository.go +++ b/backend/internal/infra/persistence/postgres/conversation/repository.go @@ -544,20 +544,127 @@ func (r *Repo) UpdateConversationArchiveByPublicID( return r.GetConversationByPublicID(ctx, publicID, userID) } -// DeleteConversationByPublicID 删除会话(软删除)。 -func (r *Repo) DeleteConversationByPublicID(ctx context.Context, userID uint, publicID string) error { - result := r.db.WithContext(ctx). - Where("user_id = ? AND public_id = ?", userID, publicID). - Delete(&models.Conversation{}) - if result.Error != nil { - return translateError(result.Error) +// DeleteConversationByPublicID 删除会话(软删除),并可返回仅被该会话引用的文件 ID。 +func (r *Repo) DeleteConversationByPublicID(ctx context.Context, userID uint, publicID string, deleteFiles bool) ([]string, error) { + normalizedPublicID := strings.TrimSpace(publicID) + cleanupFileIDs := make([]string, 0) + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var item models.Conversation + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("user_id = ? AND public_id = ?", userID, normalizedPublicID). + First(&item).Error; err != nil { + return translateError(err) + } + if err := tx.Delete(&item).Error; err != nil { + return translateError(err) + } + if !deleteFiles { + return nil + } + // 候选文件必须在会话软删除后计算,避免仍被其他活跃会话引用的文件被误删。 + fileIDs, err := listConversationFileCleanupCandidates(tx, userID, []uint{item.ID}) + if err != nil { + return err + } + cleanupFileIDs = fileIDs + return nil + }) + if err != nil { + return nil, translateError(err) } - if result.RowsAffected == 0 { + return cleanupFileIDs, nil +} + +type conversationFileCleanupCandidate struct { + FileID string `gorm:"column:file_id"` +} + +// listConversationFileCleanupCandidates 返回仅被指定会话集合引用、且仍处于 active 状态的文件 ID。 +func listConversationFileCleanupCandidates(tx *gorm.DB, userID uint, conversationIDs []uint) ([]string, error) { + if len(conversationIDs) == 0 { + return nil, nil + } + activeReferenceQuery := tx. + Table("chat_attachments AS other_a"). + Select("1"). + Joins("JOIN chat_conversations AS other_c ON other_c.id = other_a.conversation_id AND other_c.user_id = other_a.user_id AND other_c.deleted_at IS NULL"). + Where("other_a.user_id = a.user_id AND other_a.file_id = a.file_id AND other_a.status <> ?", "deleted") + + rows := make([]conversationFileCleanupCandidate, 0) + if err := tx. + Table("chat_attachments AS a"). + Select("DISTINCT a.file_id AS file_id"). + Joins("JOIN file_objects AS fo ON fo.user_id = a.user_id AND fo.file_id = a.file_id AND fo.status = ? AND fo.deleted_at IS NULL", "active"). + Where("a.user_id = ? AND a.conversation_id IN ? AND a.status <> ? AND a.file_id <> ''", userID, conversationIDs, "deleted"). + Where("NOT EXISTS (?)", activeReferenceQuery). + Order("a.file_id ASC"). + Scan(&rows).Error; err != nil { + return nil, translateError(err) + } + fileIDs := make([]string, 0, len(rows)) + for _, row := range rows { + if strings.TrimSpace(row.FileID) != "" { + fileIDs = append(fileIDs, row.FileID) + } + } + return fileIDs, nil +} + +func lockActiveFileObjectsForAttachments(tx *gorm.DB, userID uint, attachments []domainconversation.Attachment) error { + fileIDs := make([]string, 0, len(attachments)) + seen := make(map[string]struct{}, len(attachments)) + for i := range attachments { + fileID := strings.TrimSpace(attachments[i].FileID) + if fileID == "" { + continue + } + attachmentUserID := attachments[i].UserID + if userID == 0 { + userID = attachmentUserID + } + if attachmentUserID != 0 && attachmentUserID != userID { + return repository.ErrInvalidInput + } + if _, exists := seen[fileID]; exists { + continue + } + seen[fileID] = struct{}{} + fileIDs = append(fileIDs, fileID) + } + if len(fileIDs) == 0 { + return nil + } + if userID == 0 { + return repository.ErrInvalidInput + } + + lockedIDs := make([]uint, 0, len(fileIDs)) + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Model(&models.FileObject{}). + Where("user_id = ? AND status = ? AND file_id IN ?", userID, "active", fileIDs). + Pluck("id", &lockedIDs).Error; err != nil { + return translateError(err) + } + if len(lockedIDs) != len(fileIDs) { return repository.ErrNotFound } return nil } +func ensureFileObjectUnreferencedByActiveConversations(tx *gorm.DB, userID uint, fileID string) error { + var activeReferences int64 + if err := tx.Table("chat_attachments AS a"). + Joins("JOIN chat_conversations AS c ON c.id = a.conversation_id AND c.user_id = a.user_id AND c.deleted_at IS NULL"). + Where("a.user_id = ? AND a.file_id = ? AND a.status <> ?", userID, fileID, "deleted"). + Count(&activeReferences).Error; err != nil { + return translateError(err) + } + if activeReferences > 0 { + return repository.ErrConflict + } + return nil +} + // GetUserByID 按 ID 查询用户。 func (r *Repo) GetUserByID(ctx context.Context, userID uint) (*domainuser.User, error) { var item models.User @@ -658,6 +765,9 @@ func (r *Repo) CreateMessagePairWithUserAttachments( userMessage.Attachments = userAttachmentSnapshot if len(userAttachments) > 0 { + if err := lockActiveFileObjectsForAttachments(tx, userMessage.UserID, userAttachments); err != nil { + return err + } entities := make([]models.Attachment, 0, len(userAttachments)) for i := range userAttachments { item := userAttachments[i] @@ -847,6 +957,9 @@ func (r *Repo) CompleteAssistantMessageWithAttachments( ) error { return translateError(r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if len(assistantAttachments) > 0 { + if err := lockActiveFileObjectsForAttachments(tx, 0, assistantAttachments); err != nil { + return err + } entities := make([]models.Attachment, 0, len(assistantAttachments)) for i := range assistantAttachments { item := assistantAttachments[i] @@ -1790,12 +1903,13 @@ func (r *Repo) CreateFileObjectAndConsumeQuota( return &result, nil } -// DeleteFileObjectAndReleaseQuota 删除文件对象并释放配额。 +// DeleteFileObjectAndReleaseQuota 删除文件对象并释放配额,可按需要求文件未被活跃会话引用。 func (r *Repo) DeleteFileObjectAndReleaseQuota( ctx context.Context, userID uint, fileID string, defaultQuotaBytes int64, + options repository.DeleteFileObjectOptions, ) (*domainconversation.FileObject, *domainconversation.StorageQuota, bool, error) { var deletedFile models.FileObject var updatedQuota models.UserStorageQuota @@ -1811,6 +1925,12 @@ func (r *Repo) DeleteFileObjectAndReleaseQuota( return translateError(err) } + if options.RequireUnreferenced { + if err := ensureFileObjectUnreferencedByActiveConversations(tx, userID, fileID); err != nil { + return err + } + } + quota, err := getOrInitQuotaForUpdate(tx, userID, defaultQuotaBytes) if err != nil { return translateError(err) diff --git a/backend/internal/infra/persistence/postgres/conversation/repository_project.go b/backend/internal/infra/persistence/postgres/conversation/repository_project.go index b36759b8..296dc4d1 100644 --- a/backend/internal/infra/persistence/postgres/conversation/repository_project.go +++ b/backend/internal/infra/persistence/postgres/conversation/repository_project.go @@ -93,21 +93,43 @@ func (r *Repo) UpdateConversationProjectMetadataByPublicID( return r.GetConversationProjectByPublicID(ctx, userID, publicID) } -// DeleteConversationProjectByPublicID 删除项目分组,可选择一并软删除其下会话。 -func (r *Repo) DeleteConversationProjectByPublicID(ctx context.Context, userID uint, publicID string, deleteConversations bool) error { +// DeleteConversationProjectByPublicID 删除项目分组,可选择一并软删除其下会话并返回可清理文件 ID。 +func (r *Repo) DeleteConversationProjectByPublicID( + ctx context.Context, + userID uint, + publicID string, + deleteConversations bool, + deleteFiles bool, +) ([]string, error) { normalizedPublicID := strings.TrimSpace(publicID) - return translateError(r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + cleanupFileIDs := make([]string, 0) + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var project models.ConversationProject if err := tx.Where("user_id = ? AND public_id = ?", userID, normalizedPublicID).First(&project).Error; err != nil { return translateError(err) } // 项目删除与会话归属处理必须保持原子性,避免项目删除后留下不可见的项目引用。 if deleteConversations { + conversationIDs := make([]uint, 0) + if deleteFiles { + if err := tx.Model(&models.Conversation{}). + Where("user_id = ? AND project_id = ?", userID, project.ID). + Pluck("id", &conversationIDs).Error; err != nil { + return translateError(err) + } + } if err := tx. Where("user_id = ? AND project_id = ?", userID, project.ID). Delete(&models.Conversation{}).Error; err != nil { return translateError(err) } + if deleteFiles { + fileIDs, err := listConversationFileCleanupCandidates(tx, userID, conversationIDs) + if err != nil { + return err + } + cleanupFileIDs = fileIDs + } } else { if err := tx.Model(&models.Conversation{}). Where("user_id = ? AND project_id = ?", userID, project.ID). @@ -119,7 +141,11 @@ func (r *Repo) DeleteConversationProjectByPublicID(ctx context.Context, userID u return translateError(err) } return nil - })) + }) + if err != nil { + return nil, translateError(err) + } + return cleanupFileIDs, nil } // ReorderConversationProjects 更新项目展示顺序。 diff --git a/backend/internal/repository/conversation_core.go b/backend/internal/repository/conversation_core.go index 4ccbc933..caccf9a1 100644 --- a/backend/internal/repository/conversation_core.go +++ b/backend/internal/repository/conversation_core.go @@ -38,7 +38,7 @@ type ConversationMetadataRepository interface { ListConversationProjects(ctx context.Context, userID uint, statusFilter string) ([]domainconversation.ConversationProject, error) GetConversationProjectByPublicID(ctx context.Context, userID uint, publicID string) (*domainconversation.ConversationProject, error) UpdateConversationProjectMetadataByPublicID(ctx context.Context, userID uint, publicID string, patch domainconversation.ConversationProjectPatch) (*domainconversation.ConversationProject, error) - DeleteConversationProjectByPublicID(ctx context.Context, userID uint, publicID string, deleteConversations bool) error + DeleteConversationProjectByPublicID(ctx context.Context, userID uint, publicID string, deleteConversations bool, deleteFiles bool) ([]string, error) ReorderConversationProjects(ctx context.Context, userID uint, publicIDs []string) error UpdateConversationProjectAssignmentByPublicID(ctx context.Context, userID uint, conversationPublicID string, projectID *uint) (*domainconversation.Conversation, error) BatchUpdateConversationProjectByPublicIDs(ctx context.Context, userID uint, conversationPublicIDs []string, projectID *uint) (int64, error) @@ -53,7 +53,7 @@ type ConversationMetadataRepository interface { UpdateConversationMetadata(ctx context.Context, conversationID uint, title string, labelsJSON string) (*domainconversation.Conversation, error) UpdateConversationStarByPublicID(ctx context.Context, userID uint, publicID string, starred bool) (*domainconversation.Conversation, error) UpdateConversationArchiveByPublicID(ctx context.Context, userID uint, publicID string, archived bool) (*domainconversation.Conversation, error) - DeleteConversationByPublicID(ctx context.Context, userID uint, publicID string) error + DeleteConversationByPublicID(ctx context.Context, userID uint, publicID string, deleteFiles bool) ([]string, error) GetUserByID(ctx context.Context, userID uint) (*domainuser.User, error) IncrementMessageCount(ctx context.Context, conversationID uint, delta int) error UpdateConversationLastResponseID(ctx context.Context, conversationID uint, responseID string) error diff --git a/backend/internal/repository/conversation_file.go b/backend/internal/repository/conversation_file.go index 67170348..e8be534a 100644 --- a/backend/internal/repository/conversation_file.go +++ b/backend/internal/repository/conversation_file.go @@ -27,6 +27,11 @@ type FileBatchRepository interface { GetActiveFileObjectsByIDs(ctx context.Context, userID uint, fileIDs []string) ([]domainconversation.FileObject, error) } +// DeleteFileObjectOptions 定义文件对象删除的仓储约束。 +type DeleteFileObjectOptions struct { + RequireUnreferenced bool +} + // UploadRepository 封装上传、去重和配额能力。 type UploadRepository interface { FileListingRepository @@ -34,7 +39,7 @@ type UploadRepository interface { GetUserByID(ctx context.Context, userID uint) (*domainuser.User, error) GetLatestActiveFileObjectBySHA(ctx context.Context, userID uint, sha256 string, sizeBytes int64) (*domainconversation.FileObject, error) CreateFileObjectAndConsumeQuota(ctx context.Context, item *domainconversation.FileObject, quotaLimit int64) (*domainconversation.StorageQuota, error) - DeleteFileObjectAndReleaseQuota(ctx context.Context, userID uint, fileID string, quotaLimit int64) (*domainconversation.FileObject, *domainconversation.StorageQuota, bool, error) + DeleteFileObjectAndReleaseQuota(ctx context.Context, userID uint, fileID string, quotaLimit int64, options DeleteFileObjectOptions) (*domainconversation.FileObject, *domainconversation.StorageQuota, bool, error) GetOrInitUserStorageQuota(ctx context.Context, userID uint, quotaLimit int64) (*domainconversation.StorageQuota, error) } diff --git a/backend/internal/transport/http/conversation/dto_response.go b/backend/internal/transport/http/conversation/dto_response.go index 0f7e771a..5a7bffad 100644 --- a/backend/internal/transport/http/conversation/dto_response.go +++ b/backend/internal/transport/http/conversation/dto_response.go @@ -242,7 +242,24 @@ func toPublicSharedConversationResponse(item *appconversation.PublicSharedConver // ConversationDeleteResponse 删除会话响应 DTO。 type ConversationDeleteResponse struct { - Deleted bool `json:"deleted"` + Deleted bool `json:"deleted"` + DeletedFileCount int `json:"deletedFileCount,omitempty"` + Quota *StorageQuotaResponse `json:"quota,omitempty"` +} + +func toConversationDeleteResponse(result *appconversation.DeleteConversationResult) ConversationDeleteResponse { + if result == nil { + return ConversationDeleteResponse{Deleted: true} + } + response := ConversationDeleteResponse{ + Deleted: result.Deleted, + DeletedFileCount: result.DeletedFileCount, + } + if result.Quota != nil { + quota := toStorageQuotaResponse(*result.Quota) + response.Quota = "a + } + return response } // ---------- File Object ---------- diff --git a/backend/internal/transport/http/conversation/handler_conversation.go b/backend/internal/transport/http/conversation/handler_conversation.go index 0478fb00..d906f86d 100644 --- a/backend/internal/transport/http/conversation/handler_conversation.go +++ b/backend/internal/transport/http/conversation/handler_conversation.go @@ -277,6 +277,7 @@ func (h *Handler) SetConversationArchive(c *gin.Context) { // @Produce json // @Security BearerAuth // @Param id path string true "会话 public_id" +// @Param delete_files query bool false "是否同步删除不再被其他会话引用的会话文件" // @Success 200 {object} ConversationDeleteResponseDoc // @Failure 400 {object} ErrorDoc // @Failure 404 {object} ErrorDoc @@ -289,8 +290,12 @@ func (h *Handler) DeleteConversation(c *gin.Context) { response.Error(c, http.StatusBadRequest, "invalid conversation id") return } + deleteFiles := c.Query("delete_files") == "true" - if err = h.service.DeleteConversation(c.Request.Context(), userID, publicID); err != nil { + result, err := h.service.DeleteConversation(c.Request.Context(), userID, publicID, appconversation.DeleteConversationOptions{ + DeleteFiles: deleteFiles, + }) + if err != nil { if errors.Is(err, appconversation.ErrConversationNotFound) { response.Error(c, http.StatusNotFound, "conversation not found") return @@ -302,8 +307,12 @@ func (h *Handler) DeleteConversation(c *gin.Context) { h.recordAudit(c, "delete_conversation", "conversation", publicID, - map[string]bool{"deleted": true}, + map[string]interface{}{ + "deleted": true, + "delete_files": deleteFiles, + "deleted_file_count": result.DeletedFileCount, + }, ) - response.Success(c, ConversationDeleteResponse{Deleted: true}) + response.Success(c, toConversationDeleteResponse(result)) } diff --git a/backend/internal/transport/http/conversation/handler_project.go b/backend/internal/transport/http/conversation/handler_project.go index 9c9785f1..0b0e5644 100644 --- a/backend/internal/transport/http/conversation/handler_project.go +++ b/backend/internal/transport/http/conversation/handler_project.go @@ -124,13 +124,14 @@ func (h *Handler) UpdateConversationProject(c *gin.Context) { // DeleteConversationProject godoc // @Summary 删除会话项目 -// @Description 删除当前用户项目分组。默认仅解除其下会话归属;delete_conversations=true 时同时软删除项目内会话。 +// @Description 删除当前用户项目分组。默认仅解除其下会话归属;delete_conversations=true 时同时软删除项目内会话;delete_files=true 时同步删除不再被其他会话引用的文件。 // @Tags chat // @Accept json // @Produce json // @Security BearerAuth // @Param id path string true "项目 public_id" // @Param delete_conversations query bool false "是否同时删除项目内会话" +// @Param delete_files query bool false "是否同步删除不再被其他会话引用的会话文件" // @Success 200 {object} ConversationDeleteResponseDoc // @Failure 400 {object} ErrorDoc // @Failure 404 {object} ErrorDoc @@ -144,7 +145,15 @@ func (h *Handler) DeleteConversationProject(c *gin.Context) { return } deleteConversations := c.Query("delete_conversations") == "true" - if err = h.service.DeleteConversationProject(c.Request.Context(), userID, publicID, deleteConversations); err != nil { + deleteFiles := c.Query("delete_files") == "true" + result, err := h.service.DeleteConversationProject( + c.Request.Context(), + userID, + publicID, + deleteConversations, + appconversation.DeleteConversationOptions{DeleteFiles: deleteFiles}, + ) + if err != nil { if errors.Is(err, appconversation.ErrConversationProjectNotFound) { response.Error(c, http.StatusNotFound, "conversation project not found") return @@ -152,11 +161,13 @@ func (h *Handler) DeleteConversationProject(c *gin.Context) { response.Error(c, http.StatusInternalServerError, "delete conversation project failed") return } - h.recordAudit(c, "delete_conversation_project", "conversation_project", publicID, map[string]bool{ + h.recordAudit(c, "delete_conversation_project", "conversation_project", publicID, map[string]interface{}{ "deleted": true, "delete_conversations": deleteConversations, + "delete_files": deleteFiles, + "deleted_file_count": result.DeletedFileCount, }) - response.Success(c, ConversationDeleteResponse{Deleted: true}) + response.Success(c, toConversationDeleteResponse(result)) } // ReorderConversationProjects godoc diff --git a/frontend/features/chat/components/app-chat-area.tsx b/frontend/features/chat/components/app-chat-area.tsx index 18a8d5ec..f72a82ae 100644 --- a/frontend/features/chat/components/app-chat-area.tsx +++ b/frontend/features/chat/components/app-chat-area.tsx @@ -20,6 +20,17 @@ import { ConversationShareDialog, sharePatchFromDTO, } from "@/features/chat/components/sections/conversation-share-dialog"; +import { DeleteFilesOption } from "@/features/recent/components/delete-files-option"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { cloneConversationOptions, isConversationOptionsObject, @@ -80,6 +91,7 @@ function removeCachedModelOptions(platformModelName: string): void { export function AppChatArea() { const t = useTranslations("chat"); + const tRecent = useTranslations("recent"); const router = useRouter(); const searchParams = useSearchParams(); const routeConversationID = searchParams.get("conversation_id")?.trim() || null; @@ -146,6 +158,9 @@ export function AppChatArea() { const { greetingTitle } = useChatViewerProfile(); const [manualConversationTitle, setManualConversationTitle] = React.useState(""); const [shareDialogOpen, setShareDialogOpen] = React.useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false); + const [deleteFiles, setDeleteFiles] = React.useState(false); + const deleteFilesID = React.useId(); const activeConversation = React.useMemo(() => { if (!conversationID) { return null; @@ -445,15 +460,24 @@ export function AppChatArea() { [actionConversationID, canOperateConversation, renameByPublicID], ); - const onDeleteActiveConversation = React.useCallback(async () => { + const onRequestDeleteActiveConversation = React.useCallback(() => { + if (!canOperateConversation) { + return; + } + setDeleteDialogOpen(true); + }, [canOperateConversation]); + + const onConfirmDeleteActiveConversation = React.useCallback(async () => { if (!canOperateConversation) { return; } - const ok = await deleteByPublicID(actionConversationID); + const ok = await deleteByPublicID(actionConversationID, { deleteFiles }); if (ok) { + setDeleteDialogOpen(false); + setDeleteFiles(false); router.push("/chat"); } - }, [actionConversationID, canOperateConversation, deleteByPublicID, router]); + }, [actionConversationID, canOperateConversation, deleteByPublicID, deleteFiles, router]); const onSetActiveConversationProject = React.useCallback( async (projectID?: string) => { @@ -589,7 +613,7 @@ export function AppChatArea() { }} onShare={onShareActiveConversation} shareActive={activeConversationShared} - onDelete={onDeleteActiveConversation} + onDelete={onRequestDeleteActiveConversation} markdownRender={markdownRender} showModelInfo={showModelInfo} showLatency={showLatency} @@ -610,16 +634,50 @@ export function AppChatArea() { ) : null} {canOperateConversation ? ( - { - touchByPublicID(actionConversationID, sharePatchFromDTO(share)); - }} - /> + <> + { + touchByPublicID(actionConversationID, sharePatchFromDTO(share)); + }} + /> + + { + setDeleteDialogOpen(open); + if (!open) { + setDeleteFiles(false); + } + }} + > + + + {tRecent("dialogs.deleteTitle")} + + {tRecent("dialogs.deleteDescription", { + label: tRecent("deleteConversationLabel", { title: activeConversationTitle }), + })} + + + + + {tRecent("dialogs.cancel")} + void onConfirmDeleteActiveConversation()}> + {tRecent("dialogs.delete")} + + + + + ) : null} ); diff --git a/frontend/features/chat/components/sections/chat-label.tsx b/frontend/features/chat/components/sections/chat-label.tsx index ced32e8f..c65a03f2 100644 --- a/frontend/features/chat/components/sections/chat-label.tsx +++ b/frontend/features/chat/components/sections/chat-label.tsx @@ -183,6 +183,7 @@ export function ChatLabel({ if (!onDelete) { return; } + setMenuOpen(false); void onDelete(); }} > diff --git a/frontend/features/files/hooks/use-file-invalidation.ts b/frontend/features/files/hooks/use-file-invalidation.ts new file mode 100644 index 00000000..0223d693 --- /dev/null +++ b/frontend/features/files/hooks/use-file-invalidation.ts @@ -0,0 +1,24 @@ +"use client"; + +import * as React from "react"; + +import { + subscribeFileLibraryInvalidated, + type FileLibraryInvalidatedDetail, +} from "@/shared/events/file-library-events"; + +export function useFileInvalidation( + onInvalidated: (detail: FileLibraryInvalidatedDetail) => void, +) { + const handlerRef = React.useRef(onInvalidated); + + React.useEffect(() => { + handlerRef.current = onInvalidated; + }, [onInvalidated]); + + React.useEffect(() => { + return subscribeFileLibraryInvalidated((detail) => { + handlerRef.current(detail); + }); + }, []); +} diff --git a/frontend/features/files/hooks/use-files-page.ts b/frontend/features/files/hooks/use-files-page.ts index d8b8562e..7be96a3b 100644 --- a/frontend/features/files/hooks/use-files-page.ts +++ b/frontend/features/files/hooks/use-files-page.ts @@ -7,6 +7,7 @@ import { toast } from "sonner"; import { useLocalizedErrorMessage } from "@/i18n/use-localized-error"; import { useFileExtract } from "@/features/files/hooks/use-file-extract"; +import { useFileInvalidation } from "@/features/files/hooks/use-file-invalidation"; import { useFilePreview } from "@/features/files/hooks/use-file-preview"; import type { FileFilterValue, FileSortKey } from "@/features/files/types/files"; import { resolveFileFilter } from "@/features/files/utils/file-display"; @@ -186,8 +187,10 @@ export function useFilesPage(): UseFilesPageResult { if (options.append) { setLoadingMore(true); - } else if (options.silent && !options.background) { - setSyncing(true); + } else if (options.silent) { + if (!options.background) { + setSyncing(true); + } } else { setLoading(true); } @@ -241,8 +244,10 @@ export function useFilesPage(): UseFilesPageResult { if (!isMountedRef.current || !isLatestRequest()) { return; } - const description = resolveErrorMessage(error, t("toasts.listLoadFailed")); - toast.error(t("toasts.listLoadFailed"), { id: "files-list-load-error", description }); + if (!options.background) { + const description = resolveErrorMessage(error, t("toasts.listLoadFailed")); + toast.error(t("toasts.listLoadFailed"), { id: "files-list-load-error", description }); + } } finally { if (!isMountedRef.current || !isLatestRequest()) { return; @@ -287,6 +292,15 @@ export function useFilesPage(): UseFilesPageResult { return () => window.clearInterval(timer); }, [files, loadFiles, loading, loadingMore, selectedFileID, uploading]); + useFileInvalidation( + React.useCallback((detail) => { + if (detail.quota) { + setQuota(detail.quota); + } + void loadFiles({ preferredFileID: selectedFileID, silent: true, background: true }); + }, [loadFiles, selectedFileID]), + ); + const selectedFile = React.useMemo( () => files.find((item) => item.fileID === selectedFileID) ?? null, [files, selectedFileID], diff --git a/frontend/features/layouts/components/navigation/nav-projects.tsx b/frontend/features/layouts/components/navigation/nav-projects.tsx index c6c7d5d7..88949b3f 100644 --- a/frontend/features/layouts/components/navigation/nav-projects.tsx +++ b/frontend/features/layouts/components/navigation/nav-projects.tsx @@ -53,6 +53,7 @@ import { ConversationShareDialog, sharePatchFromDTO, } from "@/features/chat/components/sections/conversation-share-dialog" +import { DeleteFilesOption } from "@/features/recent/components/delete-files-option" import { useActiveSidebarConversation } from "@/features/layouts/hooks/use-active-sidebar-conversation" import { SidebarConversationItem } from "@/features/layouts/components/navigation/sidebar-conversation-item" import type { @@ -218,8 +219,10 @@ export function NavProjects() { const [draft, setDraft] = React.useState(null) const [deleteTarget, setDeleteTarget] = React.useState(null) const [deleteProjectConversations, setDeleteProjectConversations] = React.useState(false) + const [deleteProjectFiles, setDeleteProjectFiles] = React.useState(false) const [conversationRenameTarget, setConversationRenameTarget] = React.useState(null) const [conversationDeleteTarget, setConversationDeleteTarget] = React.useState(null) + const [deleteConversationFiles, setDeleteConversationFiles] = React.useState(false) const [shareTarget, setShareTarget] = React.useState<{ publicID: string; title: string } | null>(null) const [renameValue, setRenameValue] = React.useState("") const [expandedProjectIDs, setExpandedProjectIDs] = React.useState>(() => new Set()) @@ -229,6 +232,8 @@ export function NavProjects() { const [hoveredProjectRowID, setHoveredProjectRowID] = React.useState(null) const projectConversationStateRef = React.useRef(projectConversationState) const deleteProjectConversationsID = React.useId() + const deleteProjectFilesID = React.useId() + const deleteConversationFilesID = React.useId() React.useEffect(() => { projectConversationStateRef.current = projectConversationState @@ -279,12 +284,13 @@ export function NavProjects() { if (!conversationDeleteTarget) { return } - const ok = await deleteByPublicID(conversationDeleteTarget.publicID) + const ok = await deleteByPublicID(conversationDeleteTarget.publicID, { deleteFiles: deleteConversationFiles }) if (ok && activeConversationID === conversationDeleteTarget.publicID) { router.push("/chat") } setConversationDeleteTarget(null) - }, [activeConversationID, conversationDeleteTarget, deleteByPublicID, router]) + setDeleteConversationFiles(false) + }, [activeConversationID, conversationDeleteTarget, deleteByPublicID, deleteConversationFiles, router]) const loadProjectConversations = React.useCallback(async (projectID: string, force = false) => { const current = projectConversationStateRef.current[projectID] @@ -444,7 +450,10 @@ export function NavProjects() { projectConversationState[deletingProjectID]?.items.some((item) => item.publicID === activeConversationID) || items.some((item) => item.projectID === deletingProjectID && item.publicID === activeConversationID) ) - const deleted = await deleteProject(deletingProjectID, deleteProjectConversations) + const deleted = await deleteProject(deletingProjectID, { + deleteConversations: deleteProjectConversations, + deleteFiles: deleteProjectConversations && deleteProjectFiles, + }) if (deleted && pathname === "/recent" && activeProjectID === deleteTarget.publicID) { router.replace("/recent") } @@ -464,11 +473,13 @@ export function NavProjects() { } setDeleteTarget(null) setDeleteProjectConversations(false) + setDeleteProjectFiles(false) }, [ activeConversationID, activeProjectID, deleteProject, deleteProjectConversations, + deleteProjectFiles, deleteTarget, items, pathname, @@ -479,9 +490,22 @@ export function NavProjects() { React.useEffect(() => { if (!deleteTarget) { setDeleteProjectConversations(false) + setDeleteProjectFiles(false) } }, [deleteTarget]) + React.useEffect(() => { + if (!deleteProjectConversations) { + setDeleteProjectFiles(false) + } + }, [deleteProjectConversations]) + + React.useEffect(() => { + if (!conversationDeleteTarget) { + setDeleteConversationFiles(false) + } + }, [conversationDeleteTarget]) + if (projects.length === 0) { return ( <> @@ -660,7 +684,16 @@ export function NavProjects() { !open && closeDraft()} onSubmit={commitDraft} /> - !open && setDeleteTarget(null)}> + { + if (!open) { + setDeleteTarget(null) + setDeleteProjectConversations(false) + setDeleteProjectFiles(false) + } + }} + > {t("deleteTitle")} @@ -679,6 +712,13 @@ export function NavProjects() { {t("deleteConversationsDescription")} + {deleteProjectConversations ? ( + + ) : null} {t("cancel")} @@ -689,7 +729,15 @@ export function NavProjects() { - !open && setConversationDeleteTarget(null)}> + { + if (!open) { + setConversationDeleteTarget(null) + setDeleteConversationFiles(false) + } + }} + > {tRecent("dialogs.deleteTitle")} @@ -698,6 +746,11 @@ export function NavProjects() { label: tRecent("deleteConversationLabel", { title: conversationDeleteTarget?.title || tRecent("untitled") }), })} + {tRecent("dialogs.cancel")} diff --git a/frontend/features/layouts/components/navigation/nav-recents.tsx b/frontend/features/layouts/components/navigation/nav-recents.tsx index 33e58f90..d021d00c 100644 --- a/frontend/features/layouts/components/navigation/nav-recents.tsx +++ b/frontend/features/layouts/components/navigation/nav-recents.tsx @@ -29,6 +29,7 @@ import { ConversationShareDialog, sharePatchFromDTO, } from "@/features/chat/components/sections/conversation-share-dialog" +import { DeleteFilesOption } from "@/features/recent/components/delete-files-option" import { useActiveSidebarConversation } from "@/features/layouts/hooks/use-active-sidebar-conversation" import { useSidebarListFlip } from "@/features/layouts/hooks/use-sidebar-list-flip" import type { @@ -66,11 +67,13 @@ export function NavRecents() { } = useSidebarRecents() const [deleteTarget, setDeleteTarget] = React.useState(null) + const [deleteFiles, setDeleteFiles] = React.useState(false) const [renameTarget, setRenameTarget] = React.useState(null) const [shareTarget, setShareTarget] = React.useState<{ publicID: string; title: string } | null>(null) const [renameValue, setRenameValue] = React.useState("") const loadMoreRef = React.useRef(null) const listContainerRef = React.useRef(null) + const deleteFilesID = React.useId() useLoadMoreSentinel({ enabled: hasMore && !loadingInitial && !loadMoreFailed, @@ -130,12 +133,13 @@ export function NavRecents() { if (!deleteTarget) { return } - const ok = await deleteByPublicID(deleteTarget.publicID) + const ok = await deleteByPublicID(deleteTarget.publicID, { deleteFiles }) if (ok && activeConversationID === deleteTarget.publicID) { router.push("/chat") } setDeleteTarget(null) - }, [activeConversationID, deleteByPublicID, deleteTarget, router]) + setDeleteFiles(false) + }, [activeConversationID, deleteByPublicID, deleteFiles, deleteTarget, router]) const visibleItemsSignature = React.useMemo( () => recentItems.filter((item) => !item.projectID).map((item) => item.publicID).join("|"), @@ -244,13 +248,26 @@ export function NavRecents() { - !open && setDeleteTarget(null)}> + { + if (!open) { + setDeleteTarget(null) + setDeleteFiles(false) + } + }} + > {t("dialogs.deleteTitle")} {t("dialogs.deleteDescription", { label: t("deleteConversationLabel", { title: deleteTarget?.title || t("untitled") }) })} + {t("dialogs.cancel")} diff --git a/frontend/features/layouts/components/navigation/nav-starred.tsx b/frontend/features/layouts/components/navigation/nav-starred.tsx index bfea5bb4..82033684 100644 --- a/frontend/features/layouts/components/navigation/nav-starred.tsx +++ b/frontend/features/layouts/components/navigation/nav-starred.tsx @@ -32,6 +32,7 @@ import { ConversationShareDialog, sharePatchFromDTO, } from "@/features/chat/components/sections/conversation-share-dialog" +import { DeleteFilesOption } from "@/features/recent/components/delete-files-option" import { useActiveSidebarConversation } from "@/features/layouts/hooks/use-active-sidebar-conversation" import { useSidebarListFlip } from "@/features/layouts/hooks/use-sidebar-list-flip" import { SIDEBAR_OVERFLOW_ROW_TRANSITION } from "@/features/layouts/model/sidebar-motion" @@ -83,10 +84,12 @@ export function NavStarred() { const [dialogLoading, setDialogLoading] = React.useState(false) const [searchQuery, setSearchQuery] = React.useState("") const [deleteTarget, setDeleteTarget] = React.useState(null) + const [deleteFiles, setDeleteFiles] = React.useState(false) const [renameTarget, setRenameTarget] = React.useState(null) const [shareTarget, setShareTarget] = React.useState<{ publicID: string; title: string } | null>(null) const [renameValue, setRenameValue] = React.useState("") const listContainerRef = React.useRef(null) + const deleteFilesID = React.useId() const starredConversationItems = React.useMemo( () => starredItems.map((item) => toSidebarConversationItem(item, t("untitled"))), @@ -198,12 +201,13 @@ export function NavStarred() { if (!deleteTarget) { return } - const ok = await deleteByPublicID(deleteTarget.publicID) + const ok = await deleteByPublicID(deleteTarget.publicID, { deleteFiles }) if (ok && activeConversationID === deleteTarget.publicID) { router.push("/chat") } setDeleteTarget(null) - }, [activeConversationID, deleteByPublicID, deleteTarget, router]) + setDeleteFiles(false) + }, [activeConversationID, deleteByPublicID, deleteFiles, deleteTarget, router]) const onSelectSearchResult = React.useCallback((href: string) => { setShowAllStarredDialog(false) @@ -325,13 +329,26 @@ export function NavStarred() { onSelect={onSelectSearchResult} /> - !open && setDeleteTarget(null)}> + { + if (!open) { + setDeleteTarget(null) + setDeleteFiles(false) + } + }} + > {t("dialogs.deleteTitle")} {t("dialogs.deleteDescription", { label: t("deleteConversationLabel", { title: deleteTarget?.title || t("untitled") }) })} + {t("dialogs.cancel")} diff --git a/frontend/features/recent/components/app-recent.tsx b/frontend/features/recent/components/app-recent.tsx index ed864e84..a0ebd547 100644 --- a/frontend/features/recent/components/app-recent.tsx +++ b/frontend/features/recent/components/app-recent.tsx @@ -71,10 +71,12 @@ export function AppRecent() { renameTarget={controller.renameTarget} renameValue={controller.renameValue} deleteTarget={controller.deleteTarget} + deleteFiles={controller.deleteFiles} shareTarget={controller.shareTarget} onRenameValueChange={controller.setRenameValue} onRenameCommit={controller.onRenameCommit} onCloseRenameDialog={controller.closeRenameDialog} + onDeleteFilesChange={controller.setDeleteFiles} onConfirmDelete={controller.confirmDelete} onCloseDeleteDialog={controller.closeDeleteDialog} onCloseShareDialog={controller.closeShareDialog} diff --git a/frontend/features/recent/components/delete-files-option.tsx b/frontend/features/recent/components/delete-files-option.tsx new file mode 100644 index 00000000..24d30221 --- /dev/null +++ b/frontend/features/recent/components/delete-files-option.tsx @@ -0,0 +1,42 @@ +"use client"; + +import * as React from "react"; +import { useTranslations } from "next-intl"; + +import { Checkbox } from "@/components/ui/checkbox"; +import { cn } from "@/lib/utils"; + +type DeleteFilesOptionProps = { + id: string; + checked: boolean; + disabled?: boolean; + onCheckedChange: (checked: boolean) => void; +}; + +export function DeleteFilesOption({ + id, + checked, + disabled = false, + onCheckedChange, +}: DeleteFilesOptionProps) { + const t = useTranslations("recent.dialogs"); + + return ( +
+ onCheckedChange(value === true)} + /> + +
+ ); +} diff --git a/frontend/features/recent/components/sections/recent-dialogs.tsx b/frontend/features/recent/components/sections/recent-dialogs.tsx index 0d219c2d..cce1c963 100644 --- a/frontend/features/recent/components/sections/recent-dialogs.tsx +++ b/frontend/features/recent/components/sections/recent-dialogs.tsx @@ -7,6 +7,7 @@ import type { RecentDeleteTarget } from "@/features/recent/types/recent"; import { ConversationShareDialog, } from "@/features/chat/components/sections/conversation-share-dialog"; +import { DeleteFilesOption } from "@/features/recent/components/delete-files-option"; import type { ConversationDTO, ConversationShareDTO } from "@/shared/api/conversation.types"; import { AlertDialog, @@ -33,10 +34,12 @@ type RecentDialogsProps = { renameTarget: ConversationDTO | null; renameValue: string; deleteTarget: RecentDeleteTarget; + deleteFiles: boolean; shareTarget: ConversationDTO | null; onRenameValueChange: (value: string) => void; onRenameCommit: () => void | Promise; onCloseRenameDialog: () => void; + onDeleteFilesChange: (checked: boolean) => void; onConfirmDelete: () => void | Promise; onCloseDeleteDialog: () => void; onCloseShareDialog: () => void; @@ -47,16 +50,19 @@ export function RecentDialogs({ renameTarget, renameValue, deleteTarget, + deleteFiles, shareTarget, onRenameValueChange, onRenameCommit, onCloseRenameDialog, + onDeleteFilesChange, onConfirmDelete, onCloseDeleteDialog, onCloseShareDialog, onShareChange, }: RecentDialogsProps) { const t = useTranslations("recent.dialogs"); + const deleteFilesID = React.useId(); return ( <> !open && onCloseRenameDialog()}> @@ -95,6 +101,11 @@ export function RecentDialogs({ {t("deleteDescription", { label: deleteTarget?.label || t("thisConversation") })} + {t("cancel")} diff --git a/frontend/features/recent/hooks/use-recent-page.ts b/frontend/features/recent/hooks/use-recent-page.ts index b7e98170..90f058e8 100644 --- a/frontend/features/recent/hooks/use-recent-page.ts +++ b/frontend/features/recent/hooks/use-recent-page.ts @@ -123,6 +123,7 @@ export function useRecentPage() { const [renameTarget, setRenameTarget] = React.useState(null); const [renameValue, setRenameValue] = React.useState(""); const [deleteTarget, setDeleteTarget] = React.useState(null); + const [deleteFiles, setDeleteFiles] = React.useState(false); const [shareTarget, setShareTarget] = React.useState(null); const loadMoreRef = React.useRef(null); const pageRef = React.useRef(1); @@ -462,14 +463,26 @@ export function useRecentPage() { return; } - await Promise.all(deleteTarget.ids.map((id) => deleteByPublicID(id))); + if (deleteFiles) { + for (const id of deleteTarget.ids) { + await deleteByPublicID(id, { deleteFiles: true }); + } + } else { + await Promise.all(deleteTarget.ids.map((id) => deleteByPublicID(id))); + } setItems((current) => current.filter((item) => !deleteTarget.ids.includes(item.publicID))); setSelectedConversationIDs((current) => current.filter((item) => !deleteTarget.ids.includes(item))); if (deleteTarget.ids.length > 1) { setSelectionMode(false); } setDeleteTarget(null); - }, [deleteByPublicID, deleteTarget]); + setDeleteFiles(false); + }, [deleteByPublicID, deleteFiles, deleteTarget]); + + const closeDeleteDialog = React.useCallback(() => { + setDeleteTarget(null); + setDeleteFiles(false); + }, []); const exitSelectionMode = React.useCallback(() => { setSelectionMode(false); @@ -635,6 +648,7 @@ export function useRecentPage() { renameTarget, renameValue, deleteTarget, + deleteFiles, shareTarget, rowStates, allSelectedArchived, @@ -663,7 +677,8 @@ export function useRecentPage() { setRenameValue(""); }, confirmDelete, - closeDeleteDialog: () => setDeleteTarget(null), + closeDeleteDialog, + setDeleteFiles, closeShareDialog, onShareChange, toggleSelectionMode, diff --git a/frontend/features/recent/hooks/use-sidebar-recents.ts b/frontend/features/recent/hooks/use-sidebar-recents.ts index b9b2f703..26ccbb65 100644 --- a/frontend/features/recent/hooks/use-sidebar-recents.ts +++ b/frontend/features/recent/hooks/use-sidebar-recents.ts @@ -5,6 +5,7 @@ import { useTranslations } from "next-intl"; import { resolveAccessToken } from "@/shared/auth/resolve-access-token"; import { readAccessToken } from "@/shared/auth/session"; +import { dispatchFileLibraryInvalidated } from "@/shared/events/file-library-events"; import { batchSetConversationProject, createConversation, @@ -28,6 +29,8 @@ import type { } from "@/shared/api/conversation.types"; import type { + DeleteConversationOptions, + DeleteConversationProjectOptions, SidebarConversationChange, SidebarRecentsControllerValue, } from "@/features/recent/types/sidebar-recents"; @@ -481,11 +484,12 @@ export function useSidebarRecentsController(): SidebarRecentsControllerValue { ); const deleteProject = React.useCallback( - async (projectID: string, deleteConversations = false): Promise => { + async (projectID: string, options: DeleteConversationProjectOptions = {}): Promise => { const token = await resolveAccessToken(); if (!token) { return false; } + const deleteConversations = options.deleteConversations === true; const affectedItems = new Map(); for (const item of [...recentItemsRef.current, ...starredItemsRef.current]) { if (item.projectID === projectID) { @@ -493,7 +497,16 @@ export function useSidebarRecentsController(): SidebarRecentsControllerValue { } } - await deleteConversationProject(token, projectID, { deleteConversations }); + const result = await deleteConversationProject(token, projectID, { + deleteConversations, + deleteFiles: deleteConversations && options.deleteFiles === true, + }); + dispatchFileLibraryInvalidated({ + reason: "conversation_project_deleted", + deletedFileCount: result.deletedFileCount ?? 0, + quota: result.quota, + sourceID: projectID, + }); setProjects((current) => current.filter((item) => item.publicID !== projectID)); if (deleteConversations) { setRecentItems((current) => current.filter((item) => item.projectID !== projectID)); @@ -723,13 +736,19 @@ export function useSidebarRecentsController(): SidebarRecentsControllerValue { [currentConversationSnapshot, publishChange, refreshStarredWindow], ); - const deleteByPublicID = React.useCallback(async (publicID: string): Promise => { + const deleteByPublicID = React.useCallback(async (publicID: string, options: DeleteConversationOptions = {}): Promise => { const token = await resolveAccessToken(); if (!token) { return false; } - await deleteConversation(token, publicID); + const result = await deleteConversation(token, publicID, { deleteFiles: options.deleteFiles === true }); + dispatchFileLibraryInvalidated({ + reason: "conversation_deleted", + deletedFileCount: result.deletedFileCount ?? 0, + quota: result.quota, + sourceID: publicID, + }); const removedStarred = starredItemsRef.current.some((item) => item.publicID === publicID); setRecentItems((prev) => removeByPublicID(prev, publicID)); setStarredItems((prev) => removeByPublicID(prev, publicID)); diff --git a/frontend/features/recent/types/sidebar-recents.ts b/frontend/features/recent/types/sidebar-recents.ts index d8b2627c..5a0396b9 100644 --- a/frontend/features/recent/types/sidebar-recents.ts +++ b/frontend/features/recent/types/sidebar-recents.ts @@ -13,6 +13,15 @@ export type SidebarConversationChange = { patch?: Partial; }; +export type DeleteConversationOptions = { + deleteFiles?: boolean; +}; + +export type DeleteConversationProjectOptions = { + deleteConversations?: boolean; + deleteFiles?: boolean; +}; + export type SidebarRecentsControllerValue = { items: ConversationDTO[]; recentItems: ConversationDTO[]; @@ -32,12 +41,12 @@ export type SidebarRecentsControllerValue = { renameByPublicID: (publicID: string, title: string) => Promise; createProject: (payload: CreateConversationProjectRequest) => Promise; updateProject: (projectID: string, payload: UpdateConversationProjectRequest) => Promise; - deleteProject: (projectID: string, deleteConversations?: boolean) => Promise; + deleteProject: (projectID: string, options?: DeleteConversationProjectOptions) => Promise; reorderProjects: (projectIDs: string[]) => Promise; setProjectByPublicID: (publicID: string, projectID?: string) => Promise; batchSetProjectByPublicIDs: (publicIDs: string[], projectID?: string) => Promise; setStarByPublicID: (publicID: string, starred: boolean) => Promise; loadAllStarred: () => Promise; archiveByPublicID: (publicID: string, archived: boolean) => Promise; - deleteByPublicID: (publicID: string) => Promise; + deleteByPublicID: (publicID: string, options?: DeleteConversationOptions) => Promise; }; diff --git a/frontend/i18n/messages/en-US/recent.json b/frontend/i18n/messages/en-US/recent.json index c9cc4f39..586fd425 100644 --- a/frontend/i18n/messages/en-US/recent.json +++ b/frontend/i18n/messages/en-US/recent.json @@ -83,6 +83,8 @@ "renamePlaceholder": "Enter a new conversation name", "deleteTitle": "Delete conversation", "deleteDescription": "Delete {label}? This cannot be undone.", + "deleteFilesLabel": "Also delete files in the conversation", + "deleteFilesDescription": "Only files not referenced by other conversations are deleted. Files still used elsewhere stay available.", "thisConversation": "this conversation", "untitled": "New chat", "cancel": "Cancel", diff --git a/frontend/i18n/messages/zh-CN/recent.json b/frontend/i18n/messages/zh-CN/recent.json index aac7b99f..bc7a7ce0 100644 --- a/frontend/i18n/messages/zh-CN/recent.json +++ b/frontend/i18n/messages/zh-CN/recent.json @@ -83,6 +83,8 @@ "renamePlaceholder": "请输入新的会话名称", "deleteTitle": "删除会话", "deleteDescription": "确认删除{label}吗?此操作不可撤销。", + "deleteFilesLabel": "同时删除会话中的文件", + "deleteFilesDescription": "仅删除未被其他会话引用的文件;仍被其他会话使用的文件会保留。", "thisConversation": "该会话", "untitled": "新会话", "cancel": "取消", diff --git a/frontend/shared/api/conversation.ts b/frontend/shared/api/conversation.ts index 37bed91f..ed77bb9a 100644 --- a/frontend/shared/api/conversation.ts +++ b/frontend/shared/api/conversation.ts @@ -311,6 +311,11 @@ type ListConversationProjectsOptions = { type DeleteConversationProjectOptions = { deleteConversations?: boolean; + deleteFiles?: boolean; +}; + +type DeleteConversationOptions = { + deleteFiles?: boolean; }; type ListConversationRunsOptions = { @@ -404,6 +409,9 @@ export async function deleteConversationProject( if (options.deleteConversations) { params.set("delete_conversations", "true"); } + if (options.deleteFiles) { + params.set("delete_files", "true"); + } const query = params.toString(); return authedRequest( `/api/v1/conversation-projects/${pathParam(projectPublicID)}${query ? `?${query}` : ""}`, @@ -540,9 +548,15 @@ export async function setConversationArchive( export async function deleteConversation( accessToken: string, conversationPublicID: string, + options: DeleteConversationOptions = {}, ): Promise { + const params = new URLSearchParams(); + if (options.deleteFiles) { + params.set("delete_files", "true"); + } + const query = params.toString(); return authedRequest( - `/api/v1/conversations/${pathParam(conversationPublicID)}`, + `/api/v1/conversations/${pathParam(conversationPublicID)}${query ? `?${query}` : ""}`, { method: "DELETE", accessToken, diff --git a/frontend/shared/api/conversation.types.ts b/frontend/shared/api/conversation.types.ts index f0c8cec8..a8b70f36 100644 --- a/frontend/shared/api/conversation.types.ts +++ b/frontend/shared/api/conversation.types.ts @@ -1,3 +1,5 @@ +import type { UserStorageQuotaDTO } from "@/shared/api/file.types"; + export type ConversationDTO = { userID: number; publicID: string; @@ -284,6 +286,8 @@ export type SetConversationArchiveRequest = { export type DeleteConversationData = { deleted: boolean; + deletedFileCount?: number; + quota?: UserStorageQuotaDTO; }; export type CreateConversationShareRequest = { diff --git a/frontend/shared/events/file-library-events.ts b/frontend/shared/events/file-library-events.ts new file mode 100644 index 00000000..da24f833 --- /dev/null +++ b/frontend/shared/events/file-library-events.ts @@ -0,0 +1,148 @@ +"use client"; + +import type { UserStorageQuotaDTO } from "@/shared/api/file.types"; + +export type FileLibraryInvalidationReason = + | "conversation_deleted" + | "conversation_project_deleted"; + +export type FileLibraryInvalidatedDetail = { + reason: FileLibraryInvalidationReason; + deletedFileCount: number; + quota?: UserStorageQuotaDTO; + sourceID?: string; + timestamp: number; + nonce: string; +}; + +const FILE_LIBRARY_INVALIDATED_EVENT = "deeix-chat:file-library-invalidated"; +const FILE_LIBRARY_INVALIDATED_STORAGE_KEY = "deeix-chat:file-library-invalidated:payload"; +const FILE_LIBRARY_BROADCAST_CHANNEL = "deeix-chat:file-library"; + +type FileLibraryInvalidatedInput = { + reason: FileLibraryInvalidationReason; + deletedFileCount?: number; + quota?: UserStorageQuotaDTO; + sourceID?: string; +}; + +type FileLibraryInvalidatedHandler = (detail: FileLibraryInvalidatedDetail) => void; + +function createInvalidatedDetail(input: FileLibraryInvalidatedInput): FileLibraryInvalidatedDetail { + return { + reason: input.reason, + deletedFileCount: Math.max(0, input.deletedFileCount ?? 0), + quota: input.quota, + sourceID: input.sourceID?.trim() || undefined, + timestamp: Date.now(), + nonce: `${Date.now()}-${Math.random().toString(36).slice(2)}`, + }; +} + +function isFileLibraryInvalidatedDetail(value: unknown): value is FileLibraryInvalidatedDetail { + if (!value || typeof value !== "object") { + return false; + } + const detail = value as Partial; + return ( + (detail.reason === "conversation_deleted" || detail.reason === "conversation_project_deleted") && + typeof detail.deletedFileCount === "number" && + typeof detail.timestamp === "number" && + typeof detail.nonce === "string" + ); +} + +function notifyCurrentTab(detail: FileLibraryInvalidatedDetail): void { + window.dispatchEvent(new CustomEvent(FILE_LIBRARY_INVALIDATED_EVENT, { detail })); +} + +function notifyOtherTabs(detail: FileLibraryInvalidatedDetail): void { + try { + const channel = new BroadcastChannel(FILE_LIBRARY_BROADCAST_CHANNEL); + channel.postMessage(detail); + channel.close(); + } catch { + // BroadcastChannel is not available in every browser context. + } + + try { + window.localStorage.setItem(FILE_LIBRARY_INVALIDATED_STORAGE_KEY, JSON.stringify(detail)); + } catch { + // localStorage can be unavailable in private browsing or strict environments. + } +} + +export function dispatchFileLibraryInvalidated(input: FileLibraryInvalidatedInput): void { + if (typeof window === "undefined") { + return; + } + const detail = createInvalidatedDetail(input); + if (detail.deletedFileCount <= 0) { + return; + } + notifyCurrentTab(detail); + notifyOtherTabs(detail); +} + +export function subscribeFileLibraryInvalidated(handler: FileLibraryInvalidatedHandler): () => void { + if (typeof window === "undefined") { + return () => {}; + } + + const seenNonces = new Set(); + const emitOnce = (detail: FileLibraryInvalidatedDetail) => { + if (seenNonces.has(detail.nonce)) { + return; + } + seenNonces.add(detail.nonce); + if (seenNonces.size > 50) { + seenNonces.clear(); + seenNonces.add(detail.nonce); + } + handler(detail); + }; + + const handleWindowEvent = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (isFileLibraryInvalidatedDetail(detail)) { + emitOnce(detail); + } + }; + + const handleStorageEvent = (event: StorageEvent) => { + if (event.key !== FILE_LIBRARY_INVALIDATED_STORAGE_KEY || !event.newValue) { + return; + } + try { + const parsed = JSON.parse(event.newValue) as unknown; + if (isFileLibraryInvalidatedDetail(parsed)) { + emitOnce(parsed); + } + } catch { + // Ignore malformed storage payloads from external scripts or older builds. + } + }; + + let channel: BroadcastChannel | null = null; + try { + channel = new BroadcastChannel(FILE_LIBRARY_BROADCAST_CHANNEL); + channel.onmessage = (event: MessageEvent) => { + if (isFileLibraryInvalidatedDetail(event.data)) { + emitOnce(event.data); + } + }; + } catch { + channel = null; + } + + window.addEventListener(FILE_LIBRARY_INVALIDATED_EVENT, handleWindowEvent); + window.addEventListener("storage", handleStorageEvent); + + return () => { + window.removeEventListener(FILE_LIBRARY_INVALIDATED_EVENT, handleWindowEvent); + window.removeEventListener("storage", handleStorageEvent); + if (channel) { + channel.close(); + } + }; +}