feat(knowledge): add save topic to knowledge (#8731)

This commit is contained in:
George·Dong 2025-08-08 10:10:29 +08:00 committed by GitHub
parent b112797a3e
commit 4860d03c38
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 560 additions and 69 deletions

View File

@ -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<Props> = ({ message, title, resolve }) => {
const PopupContainer: React.FC<Props> = ({ source, title, resolve }) => {
const [open, setOpen] = useState(true)
const [loading, setLoading] = useState(false)
const [analysisLoading, setAnalysisLoading] = useState(true)
const [selectedBaseId, setSelectedBaseId] = useState<string>()
const [selectedTypes, setSelectedTypes] = useState<ContentType[]>([])
const [hasInitialized, setHasInitialized] = useState(false)
const [contentStats, setContentStats] = useState<ContentStats | null>(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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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 = () => (
<EmptyContainer>
<Text type="secondary">{uiState.message}</Text>
</EmptyContainer>
)
// 渲染表单内容
const renderFormContent = () => (
<>
<Form layout="vertical">
@ -241,7 +285,10 @@ const PopupContainer: React.FC<Props> = ({ message, title, resolve }) => {
/>
</Form.Item>
<Form.Item label={t('chat.save.knowledge.select.content.title')}>
<Form.Item
label={t(
isTopicMode ? 'chat.save.topic.knowledge.select.content.label' : 'chat.save.knowledge.select.content.title'
)}>
<Flex gap={8} style={{ flexDirection: 'column' }}>
{contentTypeOptions.map((option) => (
<ContentTypeItem
@ -267,27 +314,37 @@ const PopupContainer: React.FC<Props> = ({ message, title, resolve }) => {
</Form.Item>
</Form>
{formState.selectedCount > 0 && (
<InfoContainer>
<InfoContainer>
{formState.selectedCount > 0 && (
<Text type="secondary" style={{ fontSize: '12px' }}>
{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 })
}
)}
</Text>
</InfoContainer>
)}
{formState.hasNoSelection && (
<InfoContainer>
)}
{formState.hasNoSelection && (
<Text type="warning" style={{ fontSize: '12px' }}>
{t('chat.save.knowledge.error.no_content_selected')}
</Text>
</InfoContainer>
)}
)}
{!formState.hasNoSelection && formState.selectedCount === 0 && (
<Text type="secondary" style={{ fontSize: '12px', opacity: 0 }}>
&nbsp;
</Text>
)}
</InfoContainer>
</>
)
return (
<Modal
title={title || t('chat.save.knowledge.title')}
title={title || t(isTopicMode ? 'chat.save.topic.knowledge.title' : 'chat.save.knowledge.title')}
open={open}
onOk={onOk}
onCancel={onCancel}
@ -297,11 +354,8 @@ const PopupContainer: React.FC<Props> = ({ 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()}
</Modal>
)
}
@ -327,11 +381,22 @@ export default class SaveToKnowledgePopup {
)
})
}
static showForMessage(message: Message, title?: string): Promise<SaveResult | null> {
return this.show({ source: { type: 'message', data: message }, title })
}
static showForTopic(topic: Topic, title?: string): Promise<SaveResult | null> {
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;
`

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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 lenregistrement 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 dun 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": {

View File

@ -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": {

View File

@ -225,7 +225,7 @@ const MessageMenubar: FC<Props> = (props) => {
label: t('chat.save.knowledge.title'),
key: 'knowledge',
onClick: () => {
SaveToKnowledgePopup.show({ message })
SaveToKnowledgePopup.showForMessage(message)
}
}
]

View File

@ -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<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic,
}
]
},
{
label: t('chat.save.label'),
key: 'save',
icon: <Save size={14} />,
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',

View File

@ -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')
})
})
})

View File

@ -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<TopicContentStats> {
// 获取话题的所有消息
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<TopicPreprocessResult> {
// 获取话题的所有消息
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
}
}