diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts index fbd6f916..ea47e6d9 100755 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -39,6 +39,7 @@ export default { tagEditSuccess: 'Tag updated', tagDeleteTitle: 'Delete tag', tagDeleteDesc: 'Delete tag "{name}"? All FAQ entries under this tag will also be deleted.', + tagDeleteDescDoc: 'Delete tag "{name}"? All documents under this tag will also be deleted.', tagDeleteSuccess: 'Tag deleted', tagEditAction: 'Rename', tagDeleteAction: 'Delete', diff --git a/frontend/src/i18n/locales/ko-KR.ts b/frontend/src/i18n/locales/ko-KR.ts index 64d7a5c3..f4008c39 100755 --- a/frontend/src/i18n/locales/ko-KR.ts +++ b/frontend/src/i18n/locales/ko-KR.ts @@ -38,6 +38,7 @@ export default { tagEditSuccess: "태그 업데이트 성공", tagDeleteTitle: "태그 삭제", tagDeleteDesc: '"{name}" 태그를 삭제하시겠습니까? 해당 태그의 모든 FAQ 항목이 함께 삭제됩니다', + tagDeleteDescDoc: '"{name}" 태그를 삭제하시겠습니까? 해당 태그의 모든 문서가 함께 삭제됩니다', tagDeleteSuccess: "태그가 삭제되었습니다", tagEditAction: "이름 변경", tagDeleteAction: "삭제", diff --git a/frontend/src/i18n/locales/ru-RU.ts b/frontend/src/i18n/locales/ru-RU.ts index f7c48eb8..286b6824 100755 --- a/frontend/src/i18n/locales/ru-RU.ts +++ b/frontend/src/i18n/locales/ru-RU.ts @@ -38,6 +38,7 @@ export default { tagEditSuccess: 'Тег обновлён', tagDeleteTitle: 'Удаление тега', tagDeleteDesc: 'Удалить тег «{name}»? Все записи FAQ под этим тегом также будут удалены.', + tagDeleteDescDoc: 'Удалить тег «{name}»? Все документы под этим тегом также будут удалены.', tagDeleteSuccess: 'Тег удалён', tagEditAction: 'Переименовать', tagDeleteAction: 'Удалить', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 09d42019..712e6aeb 100755 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -39,6 +39,7 @@ export default { tagEditSuccess: "标签更新成功", tagDeleteTitle: "删除标签", tagDeleteDesc: '确定删除标签"{name}"?该标签下的所有 FAQ 条目将被一并删除', + tagDeleteDescDoc: '确定删除标签"{name}"?该标签下的所有文档将被一并删除', tagDeleteSuccess: "标签已删除", tagEditAction: "重命名", tagDeleteAction: "删除", diff --git a/frontend/src/views/knowledge/KnowledgeBase.vue b/frontend/src/views/knowledge/KnowledgeBase.vue index 7eb8bbfa..b8046170 100644 --- a/frontend/src/views/knowledge/KnowledgeBase.vue +++ b/frontend/src/views/knowledge/KnowledgeBase.vue @@ -330,8 +330,9 @@ const confirmDeleteTag = (tag: any) => { if (editingTagId.value) { cancelEditTag(); } + const deleteDescKey = isFAQ.value ? 'knowledgeBase.tagDeleteDesc' : 'knowledgeBase.tagDeleteDescDoc'; const confirm = window.confirm( - t('knowledgeBase.tagDeleteDesc', { name: tag.name }) as string, + t(deleteDescKey, { name: tag.name }) as string, ); if (!confirm) return; deleteKnowledgeBaseTag(kbId.value, tag.seq_id, { force: true }) @@ -343,7 +344,10 @@ const confirmDeleteTag = (tag: any) => { handleTagFilterChange(''); } loadTags(kbId.value); - loadKnowledgeFiles(kbId.value); + // 由于后端是异步删除文档,延迟刷新以确保看到最新数据 + setTimeout(() => { + loadKnowledgeFiles(kbId.value); + }, 500); }) .catch((error: any) => { MessagePlugin.error(error?.message || t('common.operationFailed')); diff --git a/internal/application/repository/knowledge.go b/internal/application/repository/knowledge.go index f5cff609..479821c2 100644 --- a/internal/application/repository/knowledge.go +++ b/internal/application/repository/knowledge.go @@ -401,3 +401,16 @@ func (r *knowledgeRepository) SearchKnowledge( } return knowledges, hasMore, nil } + +// ListIDsByTagID returns all knowledge IDs that have the specified tag ID +func (r *knowledgeRepository) ListIDsByTagID( + ctx context.Context, + tenantID uint64, + kbID, tagID string, +) ([]string, error) { + var ids []string + err := r.db.WithContext(ctx).Model(&types.Knowledge{}). + Where("tenant_id = ? AND knowledge_base_id = ? AND tag_id = ?", tenantID, kbID, tagID). + Pluck("id", &ids).Error + return ids, err +} diff --git a/internal/application/service/knowledge.go b/internal/application/service/knowledge.go index adfe98a1..a0ad7bdc 100644 --- a/internal/application/service/knowledge.go +++ b/internal/application/service/knowledge.go @@ -7300,3 +7300,34 @@ func (s *knowledgeService) SearchKnowledge(ctx context.Context, keyword string, } return s.repo.SearchKnowledge(ctx, tenantID, keyword, offset, limit, fileTypes) } + +// ProcessKnowledgeListDelete handles Asynq knowledge list delete tasks +func (s *knowledgeService) ProcessKnowledgeListDelete(ctx context.Context, t *asynq.Task) error { + var payload types.KnowledgeListDeletePayload + if err := json.Unmarshal(t.Payload(), &payload); err != nil { + logger.Errorf(ctx, "Failed to unmarshal knowledge list delete payload: %v", err) + return err + } + + logger.Infof(ctx, "Processing knowledge list delete task for %d knowledge items", len(payload.KnowledgeIDs)) + + // Get tenant info + tenant, err := s.tenantRepo.GetTenantByID(ctx, payload.TenantID) + if err != nil { + logger.Errorf(ctx, "Failed to get tenant %d: %v", payload.TenantID, err) + return err + } + + // Set context values + ctx = context.WithValue(ctx, types.TenantIDContextKey, payload.TenantID) + ctx = context.WithValue(ctx, types.TenantInfoContextKey, tenant) + + // Delete knowledge list + if err := s.DeleteKnowledgeList(ctx, payload.KnowledgeIDs); err != nil { + logger.Errorf(ctx, "Failed to delete knowledge list: %v", err) + return err + } + + logger.Infof(ctx, "Successfully deleted %d knowledge items", len(payload.KnowledgeIDs)) + return nil +} diff --git a/internal/application/service/tag.go b/internal/application/service/tag.go index e7fcfdbe..04dbe1c2 100644 --- a/internal/application/service/tag.go +++ b/internal/application/service/tag.go @@ -21,6 +21,7 @@ import ( type knowledgeTagService struct { kbService interfaces.KnowledgeBaseService repo interfaces.KnowledgeTagRepository + knowledgeRepo interfaces.KnowledgeRepository chunkRepo interfaces.ChunkRepository retrieveEngine interfaces.RetrieveEngineRegistry modelService interfaces.ModelService @@ -31,6 +32,7 @@ type knowledgeTagService struct { func NewKnowledgeTagService( kbService interfaces.KnowledgeBaseService, repo interfaces.KnowledgeTagRepository, + knowledgeRepo interfaces.KnowledgeRepository, chunkRepo interfaces.ChunkRepository, retrieveEngine interfaces.RetrieveEngineRegistry, modelService interfaces.ModelService, @@ -39,6 +41,7 @@ func NewKnowledgeTagService( return &knowledgeTagService{ kbService: kbService, repo: repo, + knowledgeRepo: knowledgeRepo, chunkRepo: chunkRepo, retrieveEngine: retrieveEngine, modelService: modelService, @@ -181,6 +184,7 @@ func (s *knowledgeTagService) UpdateTag( } // DeleteTag deletes a tag. When force=true, also deletes all chunks under this tag. +// For document-type knowledge bases, also deletes all knowledge files under this tag. // When contentOnly=true, only deletes the content under the tag but keeps the tag itself. func (s *knowledgeTagService) DeleteTag(ctx context.Context, id string, force bool, contentOnly bool, excludeIDs []string) error { if id == "" { @@ -224,9 +228,49 @@ func (s *knowledgeTagService) DeleteTag(ctx context.Context, id string, force bo return nil } + // Helper function to enqueue knowledge list delete task for document-type knowledge bases + enqueueKnowledgeDeleteTask := func() error { + if kb.Type != types.KnowledgeBaseTypeDocument { + return nil + } + // Get all knowledge IDs under this tag + knowledgeIDs, err := s.knowledgeRepo.ListIDsByTagID(ctx, tenantID, kb.ID, tag.ID) + if err != nil { + logger.Errorf(ctx, "Failed to list knowledge IDs by tag ID %s: %v", tag.ID, err) + return werrors.NewInternalServerError("获取标签下的文档失败") + } + if len(knowledgeIDs) == 0 { + return nil + } + // Enqueue async task to delete knowledge files + payload := types.KnowledgeListDeletePayload{ + TenantID: tenantID, + KnowledgeIDs: knowledgeIDs, + } + payloadBytes, err := json.Marshal(payload) + if err != nil { + logger.Errorf(ctx, "Failed to marshal knowledge list delete payload: %v", err) + return werrors.NewInternalServerError("删除标签下的文档失败") + } + task := asynq.NewTask(types.TypeKnowledgeListDelete, payloadBytes, asynq.Queue("low"), asynq.MaxRetry(3)) + info, err := s.task.Enqueue(task) + if err != nil { + logger.Errorf(ctx, "Failed to enqueue knowledge list delete task: %v", err) + return werrors.NewInternalServerError("删除标签下的文档失败") + } + logger.Infof(ctx, "Enqueued knowledge list delete task %s for %d knowledge files under tag %s", info.ID, len(knowledgeIDs), tag.ID) + return nil + } + // contentOnly mode: only delete content, keep the tag if contentOnly { - if cCount > 0 { + // For document-type KB, delete knowledge files first (which will also delete chunks) + if kb.Type == types.KnowledgeBaseTypeDocument && kCount > 0 { + if err := enqueueKnowledgeDeleteTask(); err != nil { + return err + } + } else if cCount > 0 { + // For FAQ-type KB, only delete chunks if err := deleteChunksAndEnqueueIndexDelete(); err != nil { return err } @@ -237,12 +281,22 @@ func (s *knowledgeTagService) DeleteTag(ctx context.Context, id string, force bo if !force && (kCount > 0 || cCount > 0) { return werrors.NewBadRequestError("标签仍有知识或FAQ条目引用,无法删除") } - // When force=true, delete all chunks under this tag first - if force && cCount > 0 { - if err := deleteChunksAndEnqueueIndexDelete(); err != nil { - return err + + // When force=true, delete all content under this tag first + if force { + // For document-type KB, delete knowledge files first (which will also delete chunks) + if kb.Type == types.KnowledgeBaseTypeDocument && kCount > 0 { + if err := enqueueKnowledgeDeleteTask(); err != nil { + return err + } + } else if cCount > 0 { + // For FAQ-type KB, only delete chunks + if err := deleteChunksAndEnqueueIndexDelete(); err != nil { + return err + } } } + // If there are excludeIDs, we cannot delete the tag itself as it still has content if len(excludeIDs) > 0 { return nil diff --git a/internal/router/task.go b/internal/router/task.go index 0ac22452..63c45eea 100644 --- a/internal/router/task.go +++ b/internal/router/task.go @@ -88,6 +88,9 @@ func RunAsynqServer(params AsynqTaskParams) *asynq.ServeMux { // Register KB clone handler mux.HandleFunc(types.TypeKBClone, params.KnowledgeService.ProcessKBClone) + // Register knowledge list delete handler + mux.HandleFunc(types.TypeKnowledgeListDelete, params.KnowledgeService.ProcessKnowledgeListDelete) + // Register index delete handler mux.HandleFunc(types.TypeIndexDelete, params.TagService.ProcessIndexDelete) diff --git a/internal/types/extract_graph.go b/internal/types/extract_graph.go index a1f084ad..cc168358 100644 --- a/internal/types/extract_graph.go +++ b/internal/types/extract_graph.go @@ -1,15 +1,16 @@ package types const ( - TypeChunkExtract = "chunk:extract" - TypeDocumentProcess = "document:process" // 文档处理任务 - TypeFAQImport = "faq:import" // FAQ导入任务(包含dry run模式) - TypeQuestionGeneration = "question:generation" // 问题生成任务 - TypeSummaryGeneration = "summary:generation" // 摘要生成任务 - TypeKBClone = "kb:clone" // 知识库复制任务 - TypeIndexDelete = "index:delete" // 索引删除任务 - TypeKBDelete = "kb:delete" // 知识库删除任务 - TypeDataTableSummary = "datatable:summary" // 表格摘要任务 + TypeChunkExtract = "chunk:extract" + TypeDocumentProcess = "document:process" // 文档处理任务 + TypeFAQImport = "faq:import" // FAQ导入任务(包含dry run模式) + TypeQuestionGeneration = "question:generation" // 问题生成任务 + TypeSummaryGeneration = "summary:generation" // 摘要生成任务 + TypeKBClone = "kb:clone" // 知识库复制任务 + TypeIndexDelete = "index:delete" // 索引删除任务 + TypeKBDelete = "kb:delete" // 知识库删除任务 + TypeKnowledgeListDelete = "knowledge:list_delete" // 批量删除知识任务 + TypeDataTableSummary = "datatable:summary" // 表格摘要任务 ) // ExtractChunkPayload represents the extract chunk task payload @@ -86,6 +87,12 @@ type KBDeletePayload struct { EffectiveEngines []RetrieverEngineParams `json:"effective_engines"` } +// KnowledgeListDeletePayload represents the batch knowledge delete task payload +type KnowledgeListDeletePayload struct { + TenantID uint64 `json:"tenant_id"` + KnowledgeIDs []string `json:"knowledge_ids"` +} + // KBCloneTaskStatus represents the status of a knowledge base clone task type KBCloneTaskStatus string diff --git a/internal/types/interfaces/knowledge.go b/internal/types/interfaces/knowledge.go index e9f06f25..dc1aaaf1 100644 --- a/internal/types/interfaces/knowledge.go +++ b/internal/types/interfaces/knowledge.go @@ -62,6 +62,8 @@ type KnowledgeService interface { ) (*types.PageResult, error) // DeleteKnowledge deletes knowledge by ID. DeleteKnowledge(ctx context.Context, id string) error + // DeleteKnowledgeList deletes multiple knowledge entries by IDs. + DeleteKnowledgeList(ctx context.Context, ids []string) error // GetKnowledgeFile retrieves the file associated with the knowledge. GetKnowledgeFile(ctx context.Context, id string) (io.ReadCloser, string, error) // UpdateKnowledge updates knowledge information. @@ -127,6 +129,8 @@ type KnowledgeService interface { ProcessSummaryGeneration(ctx context.Context, t *asynq.Task) error // ProcessKBClone handles Asynq knowledge base clone tasks ProcessKBClone(ctx context.Context, t *asynq.Task) error + // ProcessKnowledgeListDelete handles Asynq knowledge list delete tasks + ProcessKnowledgeListDelete(ctx context.Context, t *asynq.Task) error // GetKBCloneProgress retrieves the progress of a knowledge base clone task GetKBCloneProgress(ctx context.Context, taskID string) (*types.KBCloneProgress, error) // SaveKBCloneProgress saves the progress of a knowledge base clone task @@ -178,4 +182,6 @@ type KnowledgeRepository interface { // SearchKnowledge searches knowledge items by keyword across the tenant. // fileTypes: optional list of file extensions to filter by (e.g., ["csv", "xlsx"]) SearchKnowledge(ctx context.Context, tenantID uint64, keyword string, offset, limit int, fileTypes []string) ([]*types.Knowledge, bool, error) + // ListIDsByTagID returns all knowledge IDs that have the specified tag ID. + ListIDsByTagID(ctx context.Context, tenantID uint64, kbID, tagID string) ([]string, error) } diff --git a/migrations/versioned/000008_migrate_untagged_faq.down.sql b/migrations/versioned/000008_migrate_untagged_faq.down.sql index da049ed9..1fb92f8f 100644 --- a/migrations/versioned/000008_migrate_untagged_faq.down.sql +++ b/migrations/versioned/000008_migrate_untagged_faq.down.sql @@ -3,15 +3,16 @@ -- a "未分类" tag, but this may affect entries that were intentionally tagged. -- WARNING: This rollback is destructive and should only be used if absolutely necessary. --- It will set tag_id to empty string for all FAQ entries that reference "未分类" tags. +-- It will set tag_id to empty string for all chunks, knowledges, and embeddings that reference "未分类" tags. DO $$ DECLARE kb_record RECORD; untagged_tag_id VARCHAR(36); updated_chunks INT; + updated_knowledges INT; BEGIN - RAISE NOTICE '[Migration 000008 Rollback] WARNING: This rollback will clear tag_id for all FAQ entries referencing "未分类" tags'; + RAISE NOTICE '[Migration 000008 Rollback] WARNING: This rollback will clear tag_id for all entries referencing "未分类" tags'; -- Find all "未分类" tags FOR kb_record IN @@ -21,16 +22,25 @@ BEGIN LOOP untagged_tag_id := kb_record.id; - -- Clear tag_id for chunks referencing this tag + -- Clear tag_id for chunks referencing this tag (both faq and document types) UPDATE chunks SET tag_id = '', updated_at = NOW() WHERE tag_id = untagged_tag_id - AND chunk_type = 'faq'; + AND chunk_type IN ('faq', 'document'); GET DIAGNOSTICS updated_chunks = ROW_COUNT; RAISE NOTICE '[Migration 000008 Rollback] Cleared tag_id for % chunks referencing tag %', updated_chunks, untagged_tag_id; + -- Clear tag_id for knowledges referencing this tag + UPDATE knowledges + SET tag_id = '', updated_at = NOW() + WHERE tag_id = untagged_tag_id; + + GET DIAGNOSTICS updated_knowledges = ROW_COUNT; + RAISE NOTICE '[Migration 000008 Rollback] Cleared tag_id for % knowledges referencing tag %', + updated_knowledges, untagged_tag_id; + -- Clear tag_id in embeddings if column exists IF EXISTS ( SELECT 1 FROM information_schema.columns diff --git a/migrations/versioned/000008_migrate_untagged_faq.up.sql b/migrations/versioned/000008_migrate_untagged_faq.up.sql index 5e5f3e26..675ed48d 100644 --- a/migrations/versioned/000008_migrate_untagged_faq.up.sql +++ b/migrations/versioned/000008_migrate_untagged_faq.up.sql @@ -1,19 +1,30 @@ --- Migration: Create "未分类" tag for each knowledge base that has untagged FAQ entries --- and update those entries to reference the new tag +-- Migration: Create "未分类" tag for each knowledge base that has untagged entries +-- and update chunks, knowledges, and embeddings to reference the new tag DO $$ DECLARE kb_record RECORD; new_tag_id VARCHAR(36); updated_chunks INT; + updated_knowledges INT; updated_embeddings INT; BEGIN - -- Find all knowledge bases that have FAQ chunks with empty or NULL tag_id + -- Find all knowledge bases that have untagged chunks or knowledges FOR kb_record IN - SELECT DISTINCT c.tenant_id, c.knowledge_base_id - FROM chunks c - WHERE c.chunk_type = 'faq' - AND (c.tag_id = '' OR c.tag_id IS NULL) + SELECT DISTINCT tenant_id, knowledge_base_id + FROM ( + -- Untagged chunks (FAQ or document) + SELECT c.tenant_id, c.knowledge_base_id + FROM chunks c + WHERE c.chunk_type IN ('faq', 'document') + AND (c.tag_id = '' OR c.tag_id IS NULL) + UNION + -- Untagged knowledges (documents) + SELECT k.tenant_id, k.knowledge_base_id + FROM knowledges k + WHERE k.deleted_at IS NULL + AND (k.tag_id = '' OR k.tag_id IS NULL) + ) AS untagged LOOP -- Check if "未分类" tag already exists for this knowledge base SELECT id INTO new_tag_id @@ -35,18 +46,30 @@ BEGIN new_tag_id, kb_record.tenant_id, kb_record.knowledge_base_id; END IF; - -- Update chunks with empty tag_id to use the new tag + -- Update chunks with empty tag_id (both faq and document types) UPDATE chunks SET tag_id = new_tag_id, updated_at = NOW() WHERE tenant_id = kb_record.tenant_id AND knowledge_base_id = kb_record.knowledge_base_id - AND chunk_type = 'faq' + AND chunk_type IN ('faq', 'document') AND (tag_id = '' OR tag_id IS NULL); GET DIAGNOSTICS updated_chunks = ROW_COUNT; RAISE NOTICE '[Migration 000008] Updated % chunks for tenant_id: %, kb_id: %', updated_chunks, kb_record.tenant_id, kb_record.knowledge_base_id; + -- Update knowledges with empty tag_id (documents) + UPDATE knowledges + SET tag_id = new_tag_id, updated_at = NOW() + WHERE tenant_id = kb_record.tenant_id + AND knowledge_base_id = kb_record.knowledge_base_id + AND deleted_at IS NULL + AND (tag_id = '' OR tag_id IS NULL); + + GET DIAGNOSTICS updated_knowledges = ROW_COUNT; + RAISE NOTICE '[Migration 000008] Updated % knowledges for tenant_id: %, kb_id: %', + updated_knowledges, kb_record.tenant_id, kb_record.knowledge_base_id; + -- Update embeddings with empty tag_id (if embeddings table exists and has tag_id column) IF EXISTS ( SELECT 1 FROM information_schema.columns @@ -60,7 +83,7 @@ BEGIN SELECT id FROM chunks WHERE tenant_id = kb_record.tenant_id AND knowledge_base_id = kb_record.knowledge_base_id - AND chunk_type = 'faq' + AND chunk_type IN ('faq', 'document') ); GET DIAGNOSTICS updated_embeddings = ROW_COUNT; @@ -69,5 +92,5 @@ BEGIN END IF; END LOOP; - RAISE NOTICE '[Migration 000008] Completed migration of untagged FAQ entries'; + RAISE NOTICE '[Migration 000008] Completed migration of untagged entries'; END $$; diff --git a/migrations/versioned/000009_add_last_faq_import_result.down.sql b/migrations/versioned/000009_add_last_faq_import_result.down.sql index 2b616d0e..fd67ab87 100644 --- a/migrations/versioned/000009_add_last_faq_import_result.down.sql +++ b/migrations/versioned/000009_add_last_faq_import_result.down.sql @@ -1,3 +1,3 @@ -- Remove last_faq_import_result column from knowledge table -ALTER TABLE knowledges DROP COLUMN last_faq_import_result; \ No newline at end of file +ALTER TABLE knowledges DROP COLUMN IF EXISTS last_faq_import_result; \ No newline at end of file diff --git a/migrations/versioned/000009_add_last_faq_import_result.up.sql b/migrations/versioned/000009_add_last_faq_import_result.up.sql index 72ebaeda..3d9176c0 100644 --- a/migrations/versioned/000009_add_last_faq_import_result.up.sql +++ b/migrations/versioned/000009_add_last_faq_import_result.up.sql @@ -1,4 +1,4 @@ -- Add last_faq_import_result column to knowledge table -- This field stores the latest FAQ import result for FAQ type knowledge -ALTER TABLE knowledges ADD COLUMN last_faq_import_result JSON DEFAULT NULL; \ No newline at end of file +ALTER TABLE knowledges ADD COLUMN IF NOT EXISTS last_faq_import_result JSON DEFAULT NULL; \ No newline at end of file