From 4860d03c385dc588ae58c9b75df91af31b108d71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?George=C2=B7Dong?= <98630204+GeorgeDong32@users.noreply.github.com> Date: Fri, 8 Aug 2025 10:10:29 +0800 Subject: [PATCH] feat(knowledge): add save topic to knowledge (#8731) --- .../Popups/SaveToKnowledgePopup.tsx | 184 ++++++++++++------ src/renderer/src/i18n/locales/en-us.json | 27 ++- src/renderer/src/i18n/locales/ja-jp.json | 27 ++- src/renderer/src/i18n/locales/ru-ru.json | 27 ++- src/renderer/src/i18n/locales/zh-cn.json | 27 ++- src/renderer/src/i18n/locales/zh-tw.json | 27 ++- src/renderer/src/i18n/translate/el-gr.json | 27 ++- src/renderer/src/i18n/translate/es-es.json | 27 ++- src/renderer/src/i18n/translate/fr-fr.json | 27 ++- src/renderer/src/i18n/translate/pt-pt.json | 27 ++- .../pages/home/Messages/MessageMenubar.tsx | 2 +- .../src/pages/home/Tabs/TopicsTab.tsx | 23 +++ .../utils/__tests__/topicKnowledge.test.ts | 74 +++++++ src/renderer/src/utils/knowledge.ts | 103 +++++++++- 14 files changed, 560 insertions(+), 69 deletions(-) create mode 100644 src/renderer/src/utils/__tests__/topicKnowledge.test.ts diff --git a/src/renderer/src/components/Popups/SaveToKnowledgePopup.tsx b/src/renderer/src/components/Popups/SaveToKnowledgePopup.tsx index 1b92b97828..ae4792a36c 100644 --- a/src/renderer/src/components/Popups/SaveToKnowledgePopup.tsx +++ b/src/renderer/src/components/Popups/SaveToKnowledgePopup.tsx @@ -2,13 +2,17 @@ import { loggerService } from '@logger' import CustomTag from '@renderer/components/Tags/CustomTag' import { TopView } from '@renderer/components/TopView' import { useKnowledge, useKnowledgeBases } from '@renderer/hooks/useKnowledge' +import { Topic } from '@renderer/types' import { Message } from '@renderer/types/newMessage' import { analyzeMessageContent, + analyzeTopicContent, CONTENT_TYPES, ContentType, MessageContentStats, - processMessageContent + processMessageContent, + processTopicContent, + TopicContentStats } from '@renderer/utils/knowledge' import { Flex, Form, Modal, Select, Tooltip, Typography } from 'antd' import { Check, CircleHelp } from 'lucide-react' @@ -20,11 +24,12 @@ const logger = loggerService.withContext('SaveToKnowledgePopup') const { Text } = Typography -// 内容类型配置 +// Base Content Type Config const CONTENT_TYPE_CONFIG = { [CONTENT_TYPES.TEXT]: { label: 'chat.save.knowledge.content.maintext.title', - description: 'chat.save.knowledge.content.maintext.description' + description: 'chat.save.knowledge.content.maintext.description', + topicDescription: 'chat.save.topic.knowledge.content.maintext.description' }, [CONTENT_TYPES.CODE]: { label: 'chat.save.knowledge.content.code.title', @@ -62,16 +67,20 @@ const TAG_COLORS = { UNSELECTED: '#8c8c8c' } as const +type ContentStats = MessageContentStats | TopicContentStats + interface ContentTypeOption { type: ContentType - label: string count: number enabled: boolean - description?: string + label: string + description: string } +type ContentSource = { type: 'message'; data: Message } | { type: 'topic'; data: Topic } + interface ShowParams { - message: Message + source: ContentSource title?: string } @@ -84,35 +93,73 @@ interface Props extends ShowParams { resolve: (data: SaveResult | null) => void } -const PopupContainer: React.FC = ({ message, title, resolve }) => { +const PopupContainer: React.FC = ({ source, title, resolve }) => { const [open, setOpen] = useState(true) const [loading, setLoading] = useState(false) + const [analysisLoading, setAnalysisLoading] = useState(true) const [selectedBaseId, setSelectedBaseId] = useState() const [selectedTypes, setSelectedTypes] = useState([]) const [hasInitialized, setHasInitialized] = useState(false) + const [contentStats, setContentStats] = useState(null) const { bases } = useKnowledgeBases() const { addNote, addFiles } = useKnowledge(selectedBaseId || '') const { t } = useTranslation() - // 分析消息内容统计 - const contentStats = useMemo(() => analyzeMessageContent(message), [message]) + const isTopicMode = source?.type === 'topic' - // 生成内容类型选项(只显示有内容的类型) + // 异步分析内容统计 + useEffect(() => { + const analyze = async () => { + setAnalysisLoading(true) + setContentStats(null) + try { + const stats = isTopicMode + ? await analyzeTopicContent(source?.data as Topic) + : analyzeMessageContent(source?.data as Message) + setContentStats(stats) + } catch (error) { + logger.error('analyze content failed:', error as Error) + setContentStats({ + text: 0, + code: 0, + thinking: 0, + images: 0, + files: 0, + tools: 0, + citations: 0, + translations: 0, + errors: 0, + ...(isTopicMode && { messages: 0 }) + }) + } finally { + setAnalysisLoading(false) + } + } + analyze() + }, [source, isTopicMode]) + + // 生成内容类型选项 const contentTypeOptions: ContentTypeOption[] = useMemo(() => { + if (!contentStats) return [] + return Object.entries(CONTENT_TYPE_CONFIG) .map(([type, config]) => { const contentType = type as ContentType - const count = contentStats[contentType as keyof MessageContentStats] || 0 + const count = contentStats[contentType as keyof ContentStats] || 0 + const descriptionKey = + isTopicMode && 'topicDescription' in config && config.topicDescription + ? config.topicDescription + : config.description return { type: contentType, count, enabled: count > 0, label: t(config.label), - description: t(config.description) + description: t(descriptionKey) } }) - .filter((option) => option.enabled) // 只显示有内容的类型 - }, [contentStats, t]) + .filter((option) => option.enabled) + }, [contentStats, t, isTopicMode]) // 知识库选项 const knowledgeBaseOptions = useMemo( @@ -120,12 +167,12 @@ const PopupContainer: React.FC = ({ message, title, resolve }) => { bases.map((base) => ({ label: base.name, value: base.id, - disabled: !base.version // 如果知识库没有配置好就禁用 + disabled: !base.version })), [bases] ) - // 合并状态计算 + // 表单状态 const formState = useMemo(() => { const hasValidBase = selectedBaseId && bases.find((base) => base.id === selectedBaseId)?.version const hasContent = contentTypeOptions.length > 0 @@ -142,7 +189,7 @@ const PopupContainer: React.FC = ({ message, title, resolve }) => { } }, [selectedBaseId, bases, contentTypeOptions, selectedTypes]) - // 默认选择第一个可用的知识库 + // 默认选择第一个可用知识库 useEffect(() => { if (!selectedBaseId) { const firstAvailableBase = bases.find((base) => base.version) @@ -152,49 +199,51 @@ const PopupContainer: React.FC = ({ message, title, resolve }) => { } }, [bases, selectedBaseId]) - // 默认选择所有可用的内容类型(仅在初始化时) + // 默认选择所有可用内容类型 useEffect(() => { if (!hasInitialized && contentTypeOptions.length > 0) { - const availableTypes = contentTypeOptions.map((option) => option.type) - setSelectedTypes(availableTypes) + setSelectedTypes(contentTypeOptions.map((option) => option.type)) setHasInitialized(true) } }, [contentTypeOptions, hasInitialized]) - // 计算UI状态 + // UI状态 const uiState = useMemo(() => { + if (analysisLoading) { + return { type: 'loading', message: t('chat.save.topic.knowledge.loading') } + } if (!formState.hasContent) { - return { type: 'empty', message: t('chat.save.knowledge.empty.no_content') } + return { + type: 'empty', + message: t(isTopicMode ? 'chat.save.topic.knowledge.empty.no_content' : 'chat.save.knowledge.empty.no_content') + } } if (bases.length === 0) { return { type: 'empty', message: t('chat.save.knowledge.empty.no_knowledge_base') } } return { type: 'form' } - }, [formState.hasContent, bases.length, t]) + }, [analysisLoading, formState.hasContent, bases.length, t, isTopicMode]) - // 处理内容类型选择切换 const handleContentTypeToggle = (type: ContentType) => { setSelectedTypes((prev) => (prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type])) } const onOk = async () => { - if (!formState.canSubmit) { - return - } + if (!formState.canSubmit) return setLoading(true) let savedCount = 0 try { - const result = processMessageContent(message, selectedTypes) + const result = isTopicMode + ? await processTopicContent(source?.data as Topic, selectedTypes) + : processMessageContent(source?.data as Message, selectedTypes) - // 保存文本内容 if (result.text.trim() && selectedTypes.some((type) => type !== CONTENT_TYPES.FILE)) { await addNote(result.text) savedCount++ } - // 保存文件 if (result.files.length > 0 && selectedTypes.includes(CONTENT_TYPES.FILE)) { addFiles(result.files) savedCount += result.files.length @@ -204,27 +253,22 @@ const PopupContainer: React.FC = ({ message, title, resolve }) => { resolve({ success: true, savedCount }) } catch (error) { logger.error('save failed:', error as Error) - window.message.error(t('chat.save.knowledge.error.save_failed')) + window.message.error( + t(isTopicMode ? 'chat.save.topic.knowledge.error.save_failed' : 'chat.save.knowledge.error.save_failed') + ) setLoading(false) } } - const onCancel = () => { - setOpen(false) - } + const onCancel = () => setOpen(false) + const onClose = () => resolve(null) - const onClose = () => { - resolve(null) - } - - // 渲染空状态 const renderEmptyState = () => ( {uiState.message} ) - // 渲染表单内容 const renderFormContent = () => ( <>
@@ -241,7 +285,10 @@ const PopupContainer: React.FC = ({ message, title, resolve }) => { /> - + {contentTypeOptions.map((option) => ( = ({ message, title, resolve }) => { - {formState.selectedCount > 0 && ( - + + {formState.selectedCount > 0 && ( - {t('chat.save.knowledge.select.content.tip', { count: formState.selectedCount })} + {t( + isTopicMode + ? 'chat.save.topic.knowledge.select.content.selected_tip' + : 'chat.save.knowledge.select.content.tip', + { + count: formState.selectedCount, + ...(isTopicMode && { messages: (contentStats as TopicContentStats)?.messages || 0 }) + } + )} - - )} - - {formState.hasNoSelection && ( - + )} + {formState.hasNoSelection && ( {t('chat.save.knowledge.error.no_content_selected')} - - )} + )} + {!formState.hasNoSelection && formState.selectedCount === 0 && ( + +   + + )} + ) return ( = ({ message, title, resolve }) => { width={500} okText={t('common.save')} cancelText={t('common.cancel')} - okButtonProps={{ - loading, - disabled: !formState.canSubmit - }}> - {uiState.type === 'empty' ? renderEmptyState() : renderFormContent()} + okButtonProps={{ loading, disabled: !formState.canSubmit || analysisLoading }}> + {uiState.type === 'form' ? renderFormContent() : renderEmptyState()} ) } @@ -327,11 +381,22 @@ export default class SaveToKnowledgePopup { ) }) } + + static showForMessage(message: Message, title?: string): Promise { + return this.show({ source: { type: 'message', data: message }, title }) + } + + static showForTopic(topic: Topic, title?: string): Promise { + return this.show({ source: { type: 'topic', data: topic }, title }) + } } const EmptyContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + min-height: 100px; text-align: center; - padding: 40px 20px; ` const ContentTypeItem = styled(Flex)` @@ -352,4 +417,7 @@ const InfoContainer = styled.div` padding: 12px; border-radius: 6px; margin-top: 16px; + min-height: 40px; /* To avoid layout shift */ + display: flex; + align-items: center; ` diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 66a998f54f..117d776670 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -483,7 +483,32 @@ }, "title": "Save to Knowledge Base" }, - "label": "Save" + "label": "Save", + "topic": { + "knowledge": { + "content": { + "maintext": { + "description": "Includes topic title and main text content from all messages" + } + }, + "empty": { + "no_content": "This topic has no saveable content" + }, + "error": { + "save_failed": "Failed to save topic, please check knowledge base configuration" + }, + "loading": "Analyzing topic content...", + "select": { + "content": { + "label": "Select content types to save", + "selected_tip": "Selected {{count}} items from {{messages}} messages", + "tip": "Topic will be saved to knowledge base with complete conversation context" + } + }, + "success": "Topic successfully saved to knowledge base ({{count}} items)", + "title": "Save Topic to Knowledge Base" + } + } }, "settings": { "code": { diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 42b86582b3..6ca63cd8a0 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -483,7 +483,32 @@ }, "title": "ナレッジベースに保存" }, - "label": "保存" + "label": "保存", + "topic": { + "knowledge": { + "content": { + "maintext": { + "description": "トピックのタイトルとすべてのメッセージの本文を含む" + } + }, + "empty": { + "no_content": "このトピックには保存可能なコンテンツがありません" + }, + "error": { + "save_failed": "トピックの保存に失敗しました。ナレッジベースの設定を確認してください" + }, + "loading": "トピックの内容を分析中...", + "select": { + "content": { + "label": "保存するコンテンツの種類を選択", + "selected_tip": "{{messages}} 件のメッセージから {{count}} 個のコンテンツを選択済み", + "tip": "トピックは、完全な会話コンテキストを含んだ形でナレッジベースに保存されます" + } + }, + "success": "トピックがナレッジベースに正常に保存されました({{count}} 個のコンテンツ)", + "title": "トピックをナレッジベースに保存" + } + } }, "settings": { "code": { diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 850c46309f..bf76ab6978 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -483,7 +483,32 @@ }, "title": "Сохранить в базу знаний" }, - "label": "Сохранить" + "label": "Сохранить", + "topic": { + "knowledge": { + "content": { + "maintext": { + "description": "Основное текстовое содержимое, включая заголовок темы и все сообщения" + } + }, + "empty": { + "no_content": "В этой теме нет содержимого для сохранения" + }, + "error": { + "save_failed": "Не удалось сохранить тему. Проверьте настройки базы знаний" + }, + "loading": "Анализ содержимого темы...", + "select": { + "content": { + "label": "Выберите тип содержимого для сохранения", + "selected_tip": "Выбрано {{count}} элементов из {{messages}} сообщений", + "tip": "Тема будет сохранена в базе знаний с полным контекстом диалога" + } + }, + "success": "Тема успешно сохранена в базе знаний ({{count}} элементов)", + "title": "Сохранить тему в базу знаний" + } + } }, "settings": { "code": { diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index b1c08f4e4e..e2cac34b0a 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -483,7 +483,32 @@ }, "title": "保存到知识库" }, - "label": "保存" + "label": "保存", + "topic": { + "knowledge": { + "content": { + "maintext": { + "description": "包括话题标题和所有消息的主要文本内容" + } + }, + "empty": { + "no_content": "此话题没有可保存的内容" + }, + "error": { + "save_failed": "保存话题失败,请检查知识库配置" + }, + "loading": "正在分析话题内容...", + "select": { + "content": { + "label": "选择要保存的内容类型", + "selected_tip": "已选择 {{count}} 项内容,来自 {{messages}} 条消息", + "tip": "话题将以包含完整对话上下文的形式保存到知识库" + } + }, + "success": "话题已成功保存到知识库({{count}} 项内容)", + "title": "保存话题到知识库" + } + } }, "settings": { "code": { diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 36d39f370c..e407da3439 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -483,7 +483,32 @@ }, "title": "儲存到知識庫" }, - "label": "儲存" + "label": "保存", + "topic": { + "knowledge": { + "content": { + "maintext": { + "description": "包含話題標題及所有訊息的主要文字內容" + } + }, + "empty": { + "no_content": "此話題沒有可保存的內容" + }, + "error": { + "save_failed": "保存話題失敗,請檢查知識庫設定" + }, + "loading": "正在分析話題內容...", + "select": { + "content": { + "label": "選擇要保存的內容類型", + "selected_tip": "已選擇 {{count}} 項內容,來自 {{messages}} 條訊息", + "tip": "話題將以包含完整對話上下文的形式保存到知識庫" + } + }, + "success": "話題已成功保存到知識庫({{count}} 項內容)", + "title": "保存話題到知識庫" + } + } }, "settings": { "code": { diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 7e22afefe6..82b33057d0 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -483,7 +483,32 @@ }, "title": "Αποθήκευση στη βάση γνώσεων" }, - "label": "Αποθήκευση" + "label": "Αποθήκευση", + "topic": { + "knowledge": { + "content": { + "maintext": { + "description": "Συμπεριλαμβάνονται ο τίτλος του θέματος και όλο το κύριο περιεχόμενο των μηνυμάτων" + } + }, + "empty": { + "no_content": "Αυτό το θέμα δεν έχει περιεχόμενο που μπορεί να αποθηκευτεί." + }, + "error": { + "save_failed": "Αποτυχία αποθήκευσης θέματος, ελέγξτε τη ρύθμιση της γνωσιακής βάσης" + }, + "loading": "Γίνεται ανάλυση του περιεχομένου του θέματος...", + "select": { + "content": { + "label": "Επιλέξτε τον τύπο περιεχομένου που θέλετε να αποθηκεύσετε", + "selected_tip": "Έχουν επιλεγεί {{count}} στοιχεία, από {{messages}} μηνύματα", + "tip": "Τα θέματα θα αποθηκευτούν στη βάση γνώσεων σε μορφή που περιλαμβάνει το πλήρες συμφραζόμενο της συνομιλίας" + } + }, + "success": "Το θέμα αποθηκεύτηκε με επιτυχία στη βάση γνώσεων ({{count}} στοιχεία περιεχομένου)", + "title": "Αποθήκευση θέματος στη βάση γνώσεων" + } + } }, "settings": { "code": { diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index d68114880a..68ab44cfae 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -483,7 +483,32 @@ }, "title": "Guardar en la base de conocimientos" }, - "label": "Guardar" + "label": "Guardar", + "topic": { + "knowledge": { + "content": { + "maintext": { + "description": "Incluye el título del tema y el contenido principal de todos los mensajes." + } + }, + "empty": { + "no_content": "Este tema no tiene contenido guardable." + }, + "error": { + "save_failed": "Error al guardar el tema, verifica la configuración de la base de conocimientos" + }, + "loading": "Analizando el contenido del tema...", + "select": { + "content": { + "label": "Seleccionar el tipo de contenido que desea guardar", + "selected_tip": "Se han seleccionado {{count}} elementos, de {{messages}} mensajes", + "tip": "El tema se guardará en la base de conocimientos en forma de contexto de conversación completo." + } + }, + "success": "El tema se ha guardado correctamente en la base de conocimiento ({{count}} elementos)", + "title": "Guardar tema en la base de conocimientos" + } + } }, "settings": { "code": { diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 86f424d5e5..fd20eee52e 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -483,7 +483,32 @@ }, "title": "Enregistrer dans la base de connaissances" }, - "label": "Enregistrer" + "label": "Enregistrer", + "topic": { + "knowledge": { + "content": { + "maintext": { + "description": "Inclure le titre du sujet et le contenu principal de tous les messages" + } + }, + "empty": { + "no_content": "Ce sujet ne contient aucun contenu à enregistrer" + }, + "error": { + "save_failed": "Échec de l’enregistrement du sujet, veuillez vérifier la configuration de la base de connaissances" + }, + "loading": "Analyse du contenu du sujet en cours...", + "select": { + "content": { + "label": "Sélectionner le type de contenu à enregistrer", + "selected_tip": "{{count}} éléments sélectionnés, provenant de {{messages}} messages", + "tip": "Le sujet sera enregistré dans la base de connaissances sous la forme d’un contexte de conversation complet." + } + }, + "success": "Le sujet a été enregistré avec succès dans la base de connaissances ({{count}} éléments de contenu)", + "title": "Enregistrer le sujet dans la base de connaissances" + } + } }, "settings": { "code": { diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 3fcfd9189c..85a86a13b9 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -483,7 +483,32 @@ }, "title": "Salvar na Base de Conhecimento" }, - "label": "Salvar" + "label": "Salvar", + "topic": { + "knowledge": { + "content": { + "maintext": { + "description": "Incluir o título do tópico e todo o conteúdo principal das mensagens" + } + }, + "empty": { + "no_content": "Este tópico não tem conteúdo que possa ser guardado" + }, + "error": { + "save_failed": "Falha ao guardar o tópico, verifique a configuração da base de conhecimento" + }, + "loading": "A analisar o conteúdo do tópico...", + "select": { + "content": { + "label": "Selecionar o tipo de conteúdo a guardar", + "selected_tip": "Selecionadas {{count}} itens de conteúdo, provenientes de {{messages}} mensagens", + "tip": "O tópico será guardado na base de conhecimento com o contexto completo da conversa." + } + }, + "success": "O tópico foi guardado com sucesso na base de conhecimento ({{count}} itens de conteúdo)", + "title": "Guardar tópico na base de conhecimento" + } + } }, "settings": { "code": { diff --git a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx index be637bb2ad..4fe08bb55a 100644 --- a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx +++ b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx @@ -225,7 +225,7 @@ const MessageMenubar: FC = (props) => { label: t('chat.save.knowledge.title'), key: 'knowledge', onClick: () => { - SaveToKnowledgePopup.show({ message }) + SaveToKnowledgePopup.showForMessage(message) } } ] diff --git a/src/renderer/src/pages/home/Tabs/TopicsTab.tsx b/src/renderer/src/pages/home/Tabs/TopicsTab.tsx index bfbfee7eea..52d442b5ae 100644 --- a/src/renderer/src/pages/home/Tabs/TopicsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/TopicsTab.tsx @@ -2,6 +2,7 @@ import { DraggableVirtualList } from '@renderer/components/DraggableList' import { CopyIcon, DeleteIcon, EditIcon } from '@renderer/components/Icons' import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup' import PromptPopup from '@renderer/components/Popups/PromptPopup' +import SaveToKnowledgePopup from '@renderer/components/Popups/SaveToKnowledgePopup' import { isMac } from '@renderer/config/constant' import { useAssistant, useAssistants } from '@renderer/hooks/useAssistant' import { useInPlaceEdit } from '@renderer/hooks/useInPlaceEdit' @@ -38,6 +39,7 @@ import { PinIcon, PinOffIcon, PlusIcon, + Save, Sparkles, UploadIcon, XIcon @@ -312,6 +314,27 @@ const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic, } ] }, + { + label: t('chat.save.label'), + key: 'save', + icon: , + children: [ + { + label: t('chat.save.topic.knowledge.title'), + key: 'knowledge', + onClick: async () => { + try { + const result = await SaveToKnowledgePopup.showForTopic(topic) + if (result?.success) { + window.message.success(t('chat.save.topic.knowledge.success', { count: result.savedCount })) + } + } catch { + window.message.error(t('chat.save.topic.knowledge.error.save_failed')) + } + } + } + ] + }, { label: t('chat.topics.export.title'), key: 'export', diff --git a/src/renderer/src/utils/__tests__/topicKnowledge.test.ts b/src/renderer/src/utils/__tests__/topicKnowledge.test.ts new file mode 100644 index 0000000000..0e63053413 --- /dev/null +++ b/src/renderer/src/utils/__tests__/topicKnowledge.test.ts @@ -0,0 +1,74 @@ +import type { Topic } from '@renderer/types' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { CONTENT_TYPES } from '../knowledge' + +// Simple mocks +vi.mock('@renderer/hooks/useTopic', () => ({ + TopicManager: { + getTopicMessages: vi.fn() + } +})) + +describe('Topic Knowledge Functions', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const createTestTopic = (): Topic => ({ + id: 'test-topic-1', + assistantId: 'test-assistant', + name: 'Test Topic', + createdAt: '2023-01-01T00:00:00Z', + updatedAt: '2023-01-01T00:00:00Z', + messages: [] + }) + + describe('CONTENT_TYPES', () => { + it('should have all expected content types', () => { + expect(CONTENT_TYPES.TEXT).toBe('text') + expect(CONTENT_TYPES.CODE).toBe('code') + expect(CONTENT_TYPES.THINKING).toBe('thinking') + expect(CONTENT_TYPES.TOOL_USE).toBe('tools') + expect(CONTENT_TYPES.CITATION).toBe('citations') + expect(CONTENT_TYPES.TRANSLATION).toBe('translations') + expect(CONTENT_TYPES.ERROR).toBe('errors') + expect(CONTENT_TYPES.FILE).toBe('files') + expect(CONTENT_TYPES.IMAGES).toBe('images') + }) + }) + + describe('Topic Data Structure', () => { + it('should create valid topic structure', () => { + const topic = createTestTopic() + + expect(topic).toHaveProperty('id') + expect(topic).toHaveProperty('name') + expect(topic).toHaveProperty('assistantId') + expect(topic).toHaveProperty('createdAt') + expect(topic).toHaveProperty('updatedAt') + expect(topic).toHaveProperty('messages') + expect(Array.isArray(topic.messages)).toBe(true) + }) + }) + + describe('Topic Knowledge Functions Integration', () => { + it('should be importable without circular dependencies', async () => { + // This test verifies that the knowledge functions can be imported + // without causing circular dependency issues + const knowledgeModule = await import('../knowledge') + + expect(knowledgeModule).toHaveProperty('analyzeTopicContent') + expect(knowledgeModule).toHaveProperty('processTopicContent') + expect(knowledgeModule).toHaveProperty('CONTENT_TYPES') + expect(typeof knowledgeModule.analyzeTopicContent).toBe('function') + expect(typeof knowledgeModule.processTopicContent).toBe('function') + }) + + it('should handle TopicManager mock correctly', async () => { + const { TopicManager } = await import('@renderer/hooks/useTopic') + expect(TopicManager).toHaveProperty('getTopicMessages') + expect(typeof TopicManager.getTopicMessages).toBe('function') + }) + }) +}) diff --git a/src/renderer/src/utils/knowledge.ts b/src/renderer/src/utils/knowledge.ts index df9e722faa..5bb57962b1 100644 --- a/src/renderer/src/utils/knowledge.ts +++ b/src/renderer/src/utils/knowledge.ts @@ -1,4 +1,6 @@ -import type { FileType } from '@renderer/types' +import { TopicManager } from '@renderer/hooks/useTopic' +import i18n from '@renderer/i18n' +import type { FileType, Topic } from '@renderer/types' import type { Message, MessageBlock } from '@renderer/types/newMessage' import type { CitationMessageBlock, @@ -47,6 +49,13 @@ export interface MessageContentStats { errors: number // 错误数量 } +/** + * 话题内容统计(包含消息数量) + */ +export interface TopicContentStats extends MessageContentStats { + messages: number // 消息数量 +} + /** * 消息预处理结果 */ @@ -58,6 +67,17 @@ export interface MessagePreprocessResult { files: FileType[] } +/** + * 话题预处理结果 + */ +export interface TopicPreprocessResult { + // 合并后的文本内容(包含话题名称) + text: string + + // 文件列表 + files: FileType[] +} + /** * 分析消息内容,统计各类型内容数量 */ @@ -267,3 +287,84 @@ function processFileBlocks(block: MessageBlock): FileType | null { return null } } + +/** + * 分析话题内容,统计各类型内容数量 + * @param topic 话题对象 + * @returns 话题内容统计 + */ +export async function analyzeTopicContent(topic: Topic): Promise { + // 获取话题的所有消息 + const messages = await TopicManager.getTopicMessages(topic.id) + + const stats: TopicContentStats = { + text: 0, + code: 0, + thinking: 0, + images: 0, + files: 0, + tools: 0, + citations: 0, + translations: 0, + errors: 0, + messages: messages.length + } + + // 分析每个消息的内容 + for (const message of messages) { + const messageStats = analyzeMessageContent(message) + + // 累加各类型统计 + stats.text += messageStats.text + stats.code += messageStats.code + stats.thinking += messageStats.thinking + stats.images += messageStats.images + stats.files += messageStats.files + stats.tools += messageStats.tools + stats.citations += messageStats.citations + stats.translations += messageStats.translations + stats.errors += messageStats.errors + } + + return stats +} + +/** + * 根据选择的内容类型,处理话题内容 + * 将选中的文本类型合并为字符串,提取文件列表 + * @param topic 话题对象 + * @param selectedTypes 选择的内容类型 + * @returns 话题预处理结果 + */ +export async function processTopicContent(topic: Topic, selectedTypes: ContentType[]): Promise { + // 获取话题的所有消息 + const messages = await TopicManager.getTopicMessages(topic.id) + + const textParts: string[] = [] + const files: FileType[] = [] + + // 添加话题标题(如果选择了文本类型) + const selectedTypeSet = new Set(selectedTypes) + if (selectedTypeSet.has(CONTENT_TYPES.TEXT)) { + textParts.push(`# ${topic.name}`) + } + + // 处理每个消息 + for (const message of messages) { + const messageResult = processMessageContent(message, selectedTypes) + + // 合并文本内容 + if (messageResult.text.trim()) { + const rolePrefix = message.role === 'user' ? `## ${i18n.t('common.you')}:` : `## ${i18n.t('common.assistant')}:` + textParts.push(`${rolePrefix}\n\n${messageResult.text}`) + } + + // 合并文件内容 + files.push(...messageResult.files) + } + + return { + text: textParts.join('\n\n---\n\n'), + files + } +}