feat: 支持删除文档类型标签

This commit is contained in:
wizardchen
2026-01-15 19:38:16 +08:00
parent 57d6fea8bc
commit 0ea9acfc2f
15 changed files with 188 additions and 33 deletions
+1
View File
@@ -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',
+1
View File
@@ -38,6 +38,7 @@ export default {
tagEditSuccess: "태그 업데이트 성공",
tagDeleteTitle: "태그 삭제",
tagDeleteDesc: '"{name}" 태그를 삭제하시겠습니까? 해당 태그의 모든 FAQ 항목이 함께 삭제됩니다',
tagDeleteDescDoc: '"{name}" 태그를 삭제하시겠습니까? 해당 태그의 모든 문서가 함께 삭제됩니다',
tagDeleteSuccess: "태그가 삭제되었습니다",
tagEditAction: "이름 변경",
tagDeleteAction: "삭제",
+1
View File
@@ -38,6 +38,7 @@ export default {
tagEditSuccess: 'Тег обновлён',
tagDeleteTitle: 'Удаление тега',
tagDeleteDesc: 'Удалить тег «{name}»? Все записи FAQ под этим тегом также будут удалены.',
tagDeleteDescDoc: 'Удалить тег «{name}»? Все документы под этим тегом также будут удалены.',
tagDeleteSuccess: 'Тег удалён',
tagEditAction: 'Переименовать',
tagDeleteAction: 'Удалить',
+1
View File
@@ -39,6 +39,7 @@ export default {
tagEditSuccess: "标签更新成功",
tagDeleteTitle: "删除标签",
tagDeleteDesc: '确定删除标签"{name}"?该标签下的所有 FAQ 条目将被一并删除',
tagDeleteDescDoc: '确定删除标签"{name}"?该标签下的所有文档将被一并删除',
tagDeleteSuccess: "标签已删除",
tagEditAction: "重命名",
tagDeleteAction: "删除",
@@ -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'));
@@ -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
}
+31
View File
@@ -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
}
+59 -5
View File
@@ -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
+3
View File
@@ -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)
+16 -9
View File
@@ -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
+6
View File
@@ -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)
}
@@ -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
@@ -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 $$;
@@ -1,3 +1,3 @@
-- Remove last_faq_import_result column from knowledge table
ALTER TABLE knowledges DROP COLUMN last_faq_import_result;
ALTER TABLE knowledges DROP COLUMN IF EXISTS last_faq_import_result;
@@ -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;
ALTER TABLE knowledges ADD COLUMN IF NOT EXISTS last_faq_import_result JSON DEFAULT NULL;