diff --git a/client/faq.go b/client/faq.go index 5757dbf4b..4fe01464a 100644 --- a/client/faq.go +++ b/client/faq.go @@ -3,6 +3,7 @@ package client import ( "context" "fmt" + "io" "net/http" "net/url" "strconv" @@ -171,6 +172,28 @@ func (c *Client) ListFAQEntries(ctx context.Context, return response.Data, nil } +// ExportFAQEntries exports all FAQ entries from a knowledge base as CSV data. +// The CSV format matches the import example format with 8 columns: +// 分类(必填), 问题(必填), 相似问题(选填-多个用##分隔), 反例问题(选填-多个用##分隔), +// 机器人回答(必填-多个用##分隔), 是否全部回复(选填-默认FALSE), 是否停用(选填-默认FALSE), +// 是否禁止被推荐(选填-默认False 可被推荐) +func (c *Client) ExportFAQEntries(ctx context.Context, knowledgeBaseID string) ([]byte, error) { + path := fmt.Sprintf("/api/v1/knowledge-bases/%s/faq/entries/export", knowledgeBaseID) + resp, err := c.doRequest(ctx, http.MethodGet, path, nil, nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + // Read the raw CSV data from response body + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read export response: %w", err) + } + + return data, nil +} + // UpsertFAQEntries imports or appends FAQ entries asynchronously and returns the task ID. func (c *Client) UpsertFAQEntries(ctx context.Context, knowledgeBaseID string, payload *FAQBatchUpsertPayload, @@ -208,6 +231,28 @@ func (c *Client) CreateFAQEntry(ctx context.Context, return response.Data, nil } +// ExportFAQEntries exports all FAQ entries from a knowledge base as CSV data. +// The CSV format matches the import example format with 8 columns: +// 分类(必填), 问题(必填), 相似问题(选填-多个用##分隔), 反例问题(选填-多个用##分隔), +// 机器人回答(必填-多个用##分隔), 是否全部回复(选填-默认FALSE), 是否停用(选填-默认FALSE), +// 是否禁止被推荐(选填-默认False 可被推荐) +func (c *Client) ExportFAQEntries(ctx context.Context, knowledgeBaseID string) ([]byte, error) { + path := fmt.Sprintf("/api/v1/knowledge-bases/%s/faq/entries/export", knowledgeBaseID) + resp, err := c.doRequest(ctx, http.MethodGet, path, nil, nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + // Read the raw CSV data from response body + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read export response: %w", err) + } + + return data, nil +} + // UpdateFAQEntry updates a single FAQ entry. func (c *Client) UpdateFAQEntry(ctx context.Context, knowledgeBaseID, entryID string, payload *FAQEntryPayload, @@ -285,3 +330,25 @@ func (c *Client) SearchFAQEntries(ctx context.Context, return response.Data, nil } + +// ExportFAQEntries exports all FAQ entries from a knowledge base as CSV data. +// The CSV format matches the import example format with 8 columns: +// 分类(必填), 问题(必填), 相似问题(选填-多个用##分隔), 反例问题(选填-多个用##分隔), +// 机器人回答(必填-多个用##分隔), 是否全部回复(选填-默认FALSE), 是否停用(选填-默认FALSE), +// 是否禁止被推荐(选填-默认False 可被推荐) +func (c *Client) ExportFAQEntries(ctx context.Context, knowledgeBaseID string) ([]byte, error) { + path := fmt.Sprintf("/api/v1/knowledge-bases/%s/faq/entries/export", knowledgeBaseID) + resp, err := c.doRequest(ctx, http.MethodGet, path, nil, nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + // Read the raw CSV data from response body + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read export response: %w", err) + } + + return data, nil +} diff --git a/frontend/src/api/knowledge-base/index.ts b/frontend/src/api/knowledge-base/index.ts index d81a32950..912373316 100644 --- a/frontend/src/api/knowledge-base/index.ts +++ b/frontend/src/api/knowledge-base/index.ts @@ -205,4 +205,10 @@ export function searchFAQEntries( } ) { return post(`/api/v1/knowledge-bases/${kbId}/faq/search`, data); +} + +// Export FAQ entries as CSV file +export async function exportFAQEntries(kbId: string): Promise { + const response = await getDown(`/api/v1/knowledge-bases/${kbId}/faq/entries/export`); + return response as unknown as Blob; } \ No newline at end of file diff --git a/frontend/src/components/menu.vue b/frontend/src/components/menu.vue index 92be3730e..c6e7ad242 100644 --- a/frontend/src/components/menu.vue +++ b/frontend/src/components/menu.vue @@ -99,6 +99,16 @@ {{ t('knowledgeEditor.faq.searchTest') }} + { })) } -const dispatchFaqMenuAction = (action: 'create' | 'import' | 'search' | 'batch' | 'batchTag' | 'batchEnable' | 'batchDisable' | 'batchDelete', kbId: string) => { +const dispatchFaqMenuAction = (action: 'create' | 'import' | 'search' | 'export' | 'batch' | 'batchTag' | 'batchEnable' | 'batchDisable' | 'batchDelete', kbId: string) => { window.dispatchEvent(new CustomEvent('faqMenuAction', { detail: { action, kbId } })) @@ -1165,6 +1175,15 @@ const handleFaqSearchTestFromMenu = async () => { dispatchFaqMenuAction('search', kbId) } +const handleFaqExportFromMenu = async () => { + const kbId = await getCurrentKbId() + if (!kbId) { + MessagePlugin.warning(t('knowledgeEditor.messages.missingId')) + return + } + dispatchFaqMenuAction('export', kbId) +} + const faqBatchActionOptions = computed(() => { if (selectedFaqCount.value === 0) { return [] diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts index f81930fc5..2b0af527d 100644 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -984,6 +984,11 @@ export default { downloadExampleCSV: 'Download CSV Example', downloadExampleExcel: 'Download Excel Example', }, + faqExport: { + exportButton: 'Export CSV', + exportSuccess: 'Export successful', + exportFailed: 'Export failed', + }, models: { title: 'Model Configuration', description: 'Select appropriate AI models for the knowledge base', diff --git a/frontend/src/i18n/locales/ru-RU.ts b/frontend/src/i18n/locales/ru-RU.ts index e571859c3..48113c0c6 100644 --- a/frontend/src/i18n/locales/ru-RU.ts +++ b/frontend/src/i18n/locales/ru-RU.ts @@ -1081,6 +1081,11 @@ export default { downloadExampleCSV: 'Скачать пример CSV', downloadExampleExcel: 'Скачать пример Excel', }, + faqExport: { + exportButton: 'Экспорт CSV', + exportSuccess: 'Экспорт успешен', + exportFailed: 'Ошибка экспорта', + }, models: { title: 'Конфигурация моделей', description: 'Выберите подходящие AI-модели для базы знаний', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 73ae01d26..0b326b334 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -1320,6 +1320,11 @@ export default { downloadExampleCSV: "下载 CSV 示例", downloadExampleExcel: "下载 Excel 示例", }, + faqExport: { + exportButton: "导出 CSV", + exportSuccess: "导出成功", + exportFailed: "导出失败", + }, models: { title: "模型配置", description: "为知识库选择合适的 AI 模型", diff --git a/frontend/src/views/chat/components/AgentStreamDisplay.vue b/frontend/src/views/chat/components/AgentStreamDisplay.vue index d148e68b7..51b427d02 100644 --- a/frontend/src/views/chat/components/AgentStreamDisplay.vue +++ b/frontend/src/views/chat/components/AgentStreamDisplay.vue @@ -1926,6 +1926,7 @@ const handleAddToKnowledge = (answerEvent: any) => { max-height: 300px; width: auto; height: auto; + min-height: 100px; /* 防止流式输出时图片高度塌陷导致抖动 */ border-radius: 8px; display: block; margin: 8px 0; @@ -1933,6 +1934,7 @@ const handleAddToKnowledge = (answerEvent: any) => { object-fit: contain; cursor: pointer; transition: transform 0.2s ease; + background-color: #f9fafb; /* 加载时的占位背景色 */ &:hover { transform: scale(1.02); @@ -2058,6 +2060,7 @@ const handleAddToKnowledge = (answerEvent: any) => { max-height: 300px; width: auto; height: auto; + min-height: 100px; /* 防止流式输出时图片高度塌陷导致抖动 */ border-radius: 8px; display: block; margin: 8px 0; @@ -2065,6 +2068,7 @@ const handleAddToKnowledge = (answerEvent: any) => { object-fit: contain; cursor: pointer; transition: transform 0.2s ease; + background-color: #f9fafb; /* 加载时的占位背景色 */ &:hover { transform: scale(1.02); diff --git a/frontend/src/views/knowledge/components/FAQEntryManager.vue b/frontend/src/views/knowledge/components/FAQEntryManager.vue index 889722f10..49bf84733 100644 --- a/frontend/src/views/knowledge/components/FAQEntryManager.vue +++ b/frontend/src/views/knowledge/components/FAQEntryManager.vue @@ -142,14 +142,18 @@
{{ $t('knowledgeBase.untagged') }}
- {{ untaggedFAQCount }} +
+ {{ untaggedFAQCount }} + +
+
@@ -1111,6 +1115,7 @@ import { updateFAQEntryFieldsBatch, deleteFAQEntries, searchFAQEntries, + exportFAQEntries, listKnowledgeTags, updateFAQEntryTagBatch, createKnowledgeBaseTag, @@ -1184,7 +1189,9 @@ type TagInputInstance = ComponentPublicInstance<{ focus: () => void; select: () const tagList = ref([]) const tagLoading = ref(false) const tagListRef = ref(null) -const selectedTagId = ref('') +// Special value to represent "untagged" filter - must match backend constant +const UNTAGGED_FILTER = '__untagged__' +const selectedTagId = ref(UNTAGGED_FILTER) const overallFAQTotal = ref(0) const tagSearchQuery = ref('') const TAG_PAGE_SIZE = 20 @@ -1328,87 +1335,6 @@ const searchForm = reactive({ matchCount: 10, }) -// Toolbar actions dropdown -const toolbarActionOptions = computed(() => { - const options = [ - { - content: t('knowledgeEditor.faqImport.importButton'), - value: 'import', - icon: 'upload', - disabled: importState.taskStatus?.status === 'running' // 导入过程中禁用导入按钮 - }, - { content: t('knowledgeEditor.faq.searchTest'), value: 'search', icon: 'search' }, - ] - - // 如果有选中的条目,添加批量操作选项 - if (selectedRowKeys.value.length > 0) { - options.push( - { - content: `${t('knowledgeEditor.faq.batchUpdateTag')} (${selectedRowKeys.value.length})`, - value: 'batchTag', - icon: 'folder', - }, - { - content: `${t('knowledgeEditor.faq.batchEnable')} (${selectedRowKeys.value.length})`, - value: 'batchEnable', - icon: 'check-circle', - }, - { - content: `${t('knowledgeEditor.faq.batchDisable')} (${selectedRowKeys.value.length})`, - value: 'batchDisable', - icon: 'close-circle', - }, - /* 暂时隐藏推荐批量操作 - { - content: `${t('knowledgeEditor.faq.batchEnableRecommended')} (${selectedRowKeys.value.length})`, - value: 'batchEnableRecommended', - icon: 'thumb-up', - }, - { - content: `${t('knowledgeEditor.faq.batchDisableRecommended')} (${selectedRowKeys.value.length})`, - value: 'batchDisableRecommended', - icon: 'thumb-down', - }, - */ - { - content: `${t('knowledgeEditor.faqImport.deleteSelected')} (${selectedRowKeys.value.length})`, - value: 'delete', - icon: 'delete', - } - ) - } - - return options -}) - -const handleToolbarAction = (data: { value: string }) => { - switch (data.value) { - case 'import': - openImportDialog() - break - case 'search': - searchDrawerVisible.value = true - break - case 'batchTag': - openBatchTagDialog() - break - case 'batchEnable': - handleBatchStatusChange(true) - break - case 'batchDisable': - handleBatchStatusChange(false) - break - case 'batchEnableRecommended': - handleBatchRecommendedChange(true) - break - case 'batchDisableRecommended': - handleBatchRecommendedChange(false) - break - case 'delete': - handleBatchDelete() - break - } -} // 标签列表滚动加载更多 const handleTagListScroll = () => { @@ -1507,8 +1433,8 @@ const handleUntaggedClick = () => { if (editingTagId.value) { cancelEditTag() } - if (selectedTagId.value === '') return - handleTagFilterChange('') + if (selectedTagId.value === UNTAGGED_FILTER) return + handleTagFilterChange(UNTAGGED_FILTER) } const startCreateTag = () => { @@ -1618,7 +1544,7 @@ const confirmDeleteTag = (tag: any) => { await deleteKnowledgeBaseTag(props.kbId, tag.id, { force: true }) MessagePlugin.success(t('knowledgeBase.tagDeleteSuccess')) if (selectedTagId.value === tag.id) { - handleTagFilterChange('') + handleTagFilterChange(UNTAGGED_FILTER) } await loadTags() await loadEntries() @@ -1682,6 +1608,8 @@ const handleFaqMenuAction = (event: Event) => { openImportDialog() } else if (detail.action === 'search') { searchDrawerVisible.value = true + } else if (detail.action === 'export') { + handleExportCSV() } else if (detail.action === 'batch') { // 批量操作通过左侧菜单的下拉菜单处理 if (selectedRowKeys.value.length === 0) { @@ -1788,6 +1716,16 @@ const loadEntries = async (append = false) => { } try { + // If overallFAQTotal is not initialized, fetch it first (without tag_id filter) + if (overallFAQTotal.value === 0 && !append) { + const totalRes = await listFAQEntries(props.kbId, { + page: 1, + page_size: 1, + }) + const totalData = (totalRes.data || {}) as { total: number } + overallFAQTotal.value = totalData.total || 0 + } + const res = await listFAQEntries(props.kbId, { page: currentPage, page_size: pageSize, @@ -1813,9 +1751,6 @@ const loadEntries = async (append = false) => { } else { entries.value = newEntries } - if (selectedTagId.value === '') { - overallFAQTotal.value = pageData.total || 0 - } // 判断是否还有更多数据 hasMore.value = entries.value.length < (pageData.total || 0) currentPage++ @@ -2328,8 +2263,9 @@ const startPolling = (taskId: string) => { if (status === 'success') { MessagePlugin.success(t('knowledgeEditor.faqImport.importSuccess')) // 清除筛选条件,确保用户能看到所有新导入的数据 - selectedTagId.value = '' + selectedTagId.value = UNTAGGED_FILTER entrySearchKeyword.value = '' + overallFAQTotal.value = 0 // Reset to trigger re-fetch await loadEntries() await loadTags() // 任务完成后,3秒后自动关闭进度条 @@ -2653,12 +2589,41 @@ const downloadExcelExample = () => { XLSX.writeFile(workbook, 'faq_example.xlsx') } +// 导出 FAQ 数据为 CSV +const exportLoading = ref(false) +const handleExportCSV = async () => { + if (!props.kbId) { + MessagePlugin.warning(t('knowledgeBase.selectKnowledgeBase') || '请先选择知识库') + return + } + + exportLoading.value = true + try { + const blob = await exportFAQEntries(props.kbId) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = `faq_export_${new Date().toISOString().slice(0, 10)}.csv` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) + MessagePlugin.success(t('knowledgeEditor.faqExport.exportSuccess') || '导出成功') + } catch (error: any) { + console.error('Export failed:', error) + MessagePlugin.error(t('knowledgeEditor.faqExport.exportFailed') || '导出失败') + } finally { + exportLoading.value = false + } +} + watch( () => props.kbId, async (newKbId) => { currentPage = 1 hasMore.value = true - selectedTagId.value = '' + selectedTagId.value = UNTAGGED_FILTER + overallFAQTotal.value = 0 // Reset to trigger re-fetch cancelCreateTag() cancelEditTag() tagSearchQuery.value = '' @@ -3375,6 +3340,12 @@ watch(() => entries.value.map(e => ({ display: flex; align-items: center; } + + .tag-more-placeholder { + width: 24px; // Same width as tag-more-btn + height: 24px; + flex-shrink: 0; + } } .tag-empty-state { diff --git a/internal/application/repository/chunk.go b/internal/application/repository/chunk.go index 3653f2b64..a6100c8ee 100644 --- a/internal/application/repository/chunk.go +++ b/internal/application/repository/chunk.go @@ -86,7 +86,10 @@ func (r *chunkRepository) ListPagedChunksByKnowledgeID( baseFilter := func(db *gorm.DB) *gorm.DB { db = db.Where("tenant_id = ? AND knowledge_id = ? AND chunk_type IN (?) AND status in (?)", tenantID, knowledgeID, chunkType, []int{int(types.ChunkStatusIndexed), int(types.ChunkStatusDefault)}) - if tagID != "" { + if tagID == "__untagged__" { + // Special value to filter entries without a tag + db = db.Where("tag_id = ''") + } else if tagID != "" { db = db.Where("tag_id = ?", tagID) } if keyword != "" { @@ -389,6 +392,48 @@ func (r *chunkRepository) ListAllFAQChunksWithMetadataByKnowledgeBaseID( return allChunks, nil } +// ListAllFAQChunksForExport lists all FAQ chunks for export with full metadata, tag_id, is_enabled, and flags. +// Uses batch query to handle large datasets. +func (r *chunkRepository) ListAllFAQChunksForExport( + ctx context.Context, + tenantID uint64, + knowledgeID string, +) ([]*types.Chunk, error) { + const batchSize = 1000 // 每批查询1000条 + var allChunks []*types.Chunk + offset := 0 + + for { + var batchChunks []*types.Chunk + if err := r.db.WithContext(ctx). + Select("id, metadata, tag_id, is_enabled, flags"). + Where("tenant_id = ? AND knowledge_id = ? AND chunk_type = ? AND status = ?", + tenantID, knowledgeID, types.ChunkTypeFAQ, types.ChunkStatusIndexed). + Order("created_at ASC"). + Offset(offset). + Limit(batchSize). + Find(&batchChunks).Error; err != nil { + return nil, err + } + + // 如果没有查询到数据,说明已经查询完毕 + if len(batchChunks) == 0 { + break + } + + allChunks = append(allChunks, batchChunks...) + + // 如果返回的数据少于批次大小,说明已经是最后一批 + if len(batchChunks) < batchSize { + break + } + + offset += batchSize + } + + return allChunks, nil +} + // UpdateChunkFlagsBatch updates flags for multiple chunks in batch using SQL CASE expressions. // This is more efficient than updating chunks one by one. // setFlags: map of chunk ID to flags to set (OR operation) diff --git a/internal/application/service/knowledge.go b/internal/application/service/knowledge.go index dbc5661fc..bdf4af12e 100644 --- a/internal/application/service/knowledge.go +++ b/internal/application/service/knowledge.go @@ -3895,6 +3895,128 @@ func (s *knowledgeService) DeleteFAQEntries(ctx context.Context, return nil } +// ExportFAQEntries exports all FAQ entries for a knowledge base as CSV data. +// The CSV format matches the import example format with 8 columns: +// 分类(必填), 问题(必填), 相似问题(选填-多个用##分隔), 反例问题(选填-多个用##分隔), +// 机器人回答(必填-多个用##分隔), 是否全部回复(选填-默认FALSE), 是否停用(选填-默认FALSE), +// 是否禁止被推荐(选填-默认False 可被推荐) +func (s *knowledgeService) ExportFAQEntries(ctx context.Context, kbID string) ([]byte, error) { + kb, err := s.validateFAQKnowledgeBase(ctx, kbID) + if err != nil { + return nil, err + } + + tenantID := ctx.Value(types.TenantIDContextKey).(uint64) + faqKnowledge, err := s.findFAQKnowledge(ctx, tenantID, kb.ID) + if err != nil { + return nil, err + } + if faqKnowledge == nil { + // Return empty CSV with headers only + return s.buildFAQCSV(nil, nil), nil + } + + // Get all FAQ chunks + chunks, err := s.chunkRepo.ListAllFAQChunksForExport(ctx, tenantID, faqKnowledge.ID) + if err != nil { + return nil, fmt.Errorf("failed to list FAQ chunks: %w", err) + } + + // Build tag map for tag_id -> tag_name conversion + tagMap, err := s.buildTagMap(ctx, tenantID, kbID) + if err != nil { + return nil, fmt.Errorf("failed to build tag map: %w", err) + } + + return s.buildFAQCSV(chunks, tagMap), nil +} + +// buildTagMap builds a map from tag_id to tag_name for the given knowledge base. +func (s *knowledgeService) buildTagMap(ctx context.Context, tenantID uint64, kbID string) (map[string]string, error) { + // Get all tags for this knowledge base (no pagination limit) + page := &types.Pagination{Page: 1, PageSize: 10000} + tags, _, err := s.tagRepo.ListByKB(ctx, tenantID, kbID, page, "") + if err != nil { + return nil, err + } + + tagMap := make(map[string]string, len(tags)) + for _, tag := range tags { + if tag != nil { + tagMap[tag.ID] = tag.Name + } + } + return tagMap, nil +} + +// buildFAQCSV builds CSV content from FAQ chunks. +func (s *knowledgeService) buildFAQCSV(chunks []*types.Chunk, tagMap map[string]string) []byte { + var buf strings.Builder + + // Write CSV header (matching import example format) + headers := []string{ + "分类(必填)", + "问题(必填)", + "相似问题(选填-多个用##分隔)", + "反例问题(选填-多个用##分隔)", + "机器人回答(必填-多个用##分隔)", + "是否全部回复(选填-默认FALSE)", + "是否停用(选填-默认FALSE)", + "是否禁止被推荐(选填-默认False 可被推荐)", + } + buf.WriteString(strings.Join(headers, ",")) + buf.WriteString("\n") + + // Write data rows + for _, chunk := range chunks { + meta, err := chunk.FAQMetadata() + if err != nil || meta == nil { + continue + } + + // Get tag name + tagName := "" + if chunk.TagID != "" && tagMap != nil { + if name, ok := tagMap[chunk.TagID]; ok { + tagName = name + } + } + + // Build row + row := []string{ + escapeCSVField(tagName), + escapeCSVField(meta.StandardQuestion), + escapeCSVField(strings.Join(meta.SimilarQuestions, "##")), + escapeCSVField(strings.Join(meta.NegativeQuestions, "##")), + escapeCSVField(strings.Join(meta.Answers, "##")), + boolToCSV(meta.AnswerStrategy == types.AnswerStrategyAll), + boolToCSV(!chunk.IsEnabled), // 是否停用:取反 + boolToCSV(!chunk.Flags.HasFlag(types.ChunkFlagRecommended)), // 是否禁止被推荐:取反 + } + buf.WriteString(strings.Join(row, ",")) + buf.WriteString("\n") + } + + return []byte(buf.String()) +} + +// escapeCSVField escapes a field for CSV format. +func escapeCSVField(field string) string { + // If field contains comma, newline, or quote, wrap in quotes and escape internal quotes + if strings.ContainsAny(field, ",\"\n\r") { + return "\"" + strings.ReplaceAll(field, "\"", "\"\"") + "\"" + } + return field +} + +// boolToCSV converts a boolean to CSV TRUE/FALSE string. +func boolToCSV(b bool) string { + if b { + return "TRUE" + } + return "FALSE" +} + func (s *knowledgeService) validateFAQKnowledgeBase(ctx context.Context, kbID string) (*types.KnowledgeBase, error) { if kbID == "" { return nil, werrors.NewBadRequestError("知识库 ID 不能为空") diff --git a/internal/handler/faq.go b/internal/handler/faq.go index 46a3e2ea9..a179854f6 100644 --- a/internal/handler/faq.go +++ b/internal/handler/faq.go @@ -221,3 +221,23 @@ func (h *FAQHandler) SearchFAQ(c *gin.Context) { "data": entries, }) } + +// ExportEntries exports all FAQ entries as a CSV file. +func (h *FAQHandler) ExportEntries(c *gin.Context) { + ctx := c.Request.Context() + kbID := secutils.SanitizeForLog(c.Param("id")) + + csvData, err := h.knowledgeService.ExportFAQEntries(ctx, kbID) + if err != nil { + logger.ErrorWithFields(ctx, err, nil) + c.Error(err) + return + } + + // Set response headers for CSV download + c.Header("Content-Type", "text/csv; charset=utf-8") + c.Header("Content-Disposition", "attachment; filename=faq_export.csv") + // Add BOM for Excel compatibility with UTF-8 + bom := []byte{0xEF, 0xBB, 0xBF} + c.Data(http.StatusOK, "text/csv; charset=utf-8", append(bom, csvData...)) +} diff --git a/internal/router/router.go b/internal/router/router.go index 78d743e87..4538fbb29 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -163,6 +163,7 @@ func RegisterFAQRoutes(r *gin.RouterGroup, handler *handler.FAQHandler) { faq := r.Group("/knowledge-bases/:id/faq") { faq.GET("/entries", handler.ListEntries) + faq.GET("/entries/export", handler.ExportEntries) faq.POST("/entries", handler.UpsertEntries) faq.POST("/entry", handler.CreateEntry) faq.PUT("/entries/:entry_id", handler.UpdateEntry) diff --git a/internal/types/interfaces/chunk.go b/internal/types/interfaces/chunk.go index 0a0c0f2c4..e6caaf8e4 100644 --- a/internal/types/interfaces/chunk.go +++ b/internal/types/interfaces/chunk.go @@ -52,6 +52,8 @@ type ChunkRepository interface { // ListAllFAQChunksWithMetadataByKnowledgeBaseID lists all FAQ chunks for a knowledge base ID // returns ID and Metadata fields for duplicate question checking ListAllFAQChunksWithMetadataByKnowledgeBaseID(ctx context.Context, tenantID uint64, kbID string) ([]*types.Chunk, error) + // ListAllFAQChunksForExport lists all FAQ chunks for export with full metadata, tag_id, is_enabled, and flags + ListAllFAQChunksForExport(ctx context.Context, tenantID uint64, knowledgeID string) ([]*types.Chunk, error) // UpdateChunkFlagsBatch updates flags for multiple chunks in batch using a single SQL statement. // setFlags: map of chunk ID to flags to set (OR operation) // clearFlags: map of chunk ID to flags to clear (AND NOT operation) diff --git a/internal/types/interfaces/knowledge.go b/internal/types/interfaces/knowledge.go index f51e132da..cf01b47b4 100644 --- a/internal/types/interfaces/knowledge.go +++ b/internal/types/interfaces/knowledge.go @@ -95,6 +95,8 @@ type KnowledgeService interface { DeleteFAQEntries(ctx context.Context, kbID string, entryIDs []string) error // SearchFAQEntries searches FAQ entries using hybrid search. SearchFAQEntries(ctx context.Context, kbID string, req *types.FAQSearchRequest) ([]*types.FAQEntry, error) + // ExportFAQEntries exports all FAQ entries for a knowledge base as CSV data. + ExportFAQEntries(ctx context.Context, kbID string) ([]byte, error) // UpdateKnowledgeTagBatch updates tag for document knowledge items in batch. UpdateKnowledgeTagBatch(ctx context.Context, updates map[string]*string) error // UpdateFAQEntryTagBatch updates tag for FAQ entries in batch.