mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-29 23:12:38 +08:00
feat(knowledge): add save topic to knowledge (#8731)
This commit is contained in:
parent
b112797a3e
commit
4860d03c38
@ -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 }}>
|
||||
|
||||
</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;
|
||||
`
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -225,7 +225,7 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
label: t('chat.save.knowledge.title'),
|
||||
key: 'knowledge',
|
||||
onClick: () => {
|
||||
SaveToKnowledgePopup.show({ message })
|
||||
SaveToKnowledgePopup.showForMessage(message)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@ -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',
|
||||
|
||||
74
src/renderer/src/utils/__tests__/topicKnowledge.test.ts
Normal file
74
src/renderer/src/utils/__tests__/topicKnowledge.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user