mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-31 00:10:22 +08:00
feat: save to knowledge (#7528)
* feat: save to knowledge * refactor: simplify checkbox * feat(i18n): add 'Save to Local File' translation key for multiple languages --------- Co-authored-by: suyao <sy20010504@gmail.com>
This commit is contained in:
parent
7c6db809bb
commit
ba742b7b1f
353
src/renderer/src/components/Popups/SaveToKnowledgePopup.tsx
Normal file
353
src/renderer/src/components/Popups/SaveToKnowledgePopup.tsx
Normal file
@ -0,0 +1,353 @@
|
||||
import CustomTag from '@renderer/components/CustomTag'
|
||||
import { TopView } from '@renderer/components/TopView'
|
||||
import Logger from '@renderer/config/logger'
|
||||
import { useKnowledge, useKnowledgeBases } from '@renderer/hooks/useKnowledge'
|
||||
import { Message } from '@renderer/types/newMessage'
|
||||
import {
|
||||
analyzeMessageContent,
|
||||
CONTENT_TYPES,
|
||||
ContentType,
|
||||
MessageContentStats,
|
||||
processMessageContent
|
||||
} from '@renderer/utils/knowledge'
|
||||
import { Flex, Form, Modal, Select, Tooltip, Typography } from 'antd'
|
||||
import { Check, CircleHelp } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
// 内容类型配置
|
||||
const CONTENT_TYPE_CONFIG = {
|
||||
[CONTENT_TYPES.TEXT]: {
|
||||
label: 'chat.save.knowledge.content.maintext.title',
|
||||
description: 'chat.save.knowledge.content.maintext.description'
|
||||
},
|
||||
[CONTENT_TYPES.CODE]: {
|
||||
label: 'chat.save.knowledge.content.code.title',
|
||||
description: 'chat.save.knowledge.content.code.description'
|
||||
},
|
||||
[CONTENT_TYPES.THINKING]: {
|
||||
label: 'chat.save.knowledge.content.thinking.title',
|
||||
description: 'chat.save.knowledge.content.thinking.description'
|
||||
},
|
||||
[CONTENT_TYPES.TOOL_USE]: {
|
||||
label: 'chat.save.knowledge.content.tool_use.title',
|
||||
description: 'chat.save.knowledge.content.tool_use.description'
|
||||
},
|
||||
[CONTENT_TYPES.CITATION]: {
|
||||
label: 'chat.save.knowledge.content.citation.title',
|
||||
description: 'chat.save.knowledge.content.citation.description'
|
||||
},
|
||||
[CONTENT_TYPES.TRANSLATION]: {
|
||||
label: 'chat.save.knowledge.content.translation.title',
|
||||
description: 'chat.save.knowledge.content.translation.description'
|
||||
},
|
||||
[CONTENT_TYPES.ERROR]: {
|
||||
label: 'chat.save.knowledge.content.error.title',
|
||||
description: 'chat.save.knowledge.content.error.description'
|
||||
},
|
||||
[CONTENT_TYPES.FILE]: {
|
||||
label: 'chat.save.knowledge.content.file.title',
|
||||
description: 'chat.save.knowledge.content.file.description'
|
||||
}
|
||||
} as const
|
||||
|
||||
// Tag 颜色常量
|
||||
const TAG_COLORS = {
|
||||
SELECTED: '#008001',
|
||||
UNSELECTED: '#8c8c8c'
|
||||
} as const
|
||||
|
||||
interface ContentTypeOption {
|
||||
type: ContentType
|
||||
label: string
|
||||
count: number
|
||||
enabled: boolean
|
||||
description?: string
|
||||
}
|
||||
|
||||
interface ShowParams {
|
||||
message: Message
|
||||
title?: string
|
||||
}
|
||||
|
||||
interface SaveResult {
|
||||
success: boolean
|
||||
savedCount: number
|
||||
}
|
||||
|
||||
interface Props extends ShowParams {
|
||||
resolve: (data: SaveResult | null) => void
|
||||
}
|
||||
|
||||
const PopupContainer: React.FC<Props> = ({ message, title, resolve }) => {
|
||||
const [open, setOpen] = useState(true)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selectedBaseId, setSelectedBaseId] = useState<string>()
|
||||
const [selectedTypes, setSelectedTypes] = useState<ContentType[]>([])
|
||||
const [hasInitialized, setHasInitialized] = useState(false)
|
||||
const { bases } = useKnowledgeBases()
|
||||
const { addNote, addFiles } = useKnowledge(selectedBaseId || '')
|
||||
const { t } = useTranslation()
|
||||
|
||||
// 分析消息内容统计
|
||||
const contentStats = useMemo(() => analyzeMessageContent(message), [message])
|
||||
|
||||
// 生成内容类型选项(只显示有内容的类型)
|
||||
const contentTypeOptions: ContentTypeOption[] = useMemo(() => {
|
||||
return Object.entries(CONTENT_TYPE_CONFIG)
|
||||
.map(([type, config]) => {
|
||||
const contentType = type as ContentType
|
||||
const count = contentStats[contentType as keyof MessageContentStats] || 0
|
||||
return {
|
||||
type: contentType,
|
||||
count,
|
||||
enabled: count > 0,
|
||||
label: t(config.label),
|
||||
description: t(config.description)
|
||||
}
|
||||
})
|
||||
.filter((option) => option.enabled) // 只显示有内容的类型
|
||||
}, [contentStats, t])
|
||||
|
||||
// 知识库选项
|
||||
const knowledgeBaseOptions = useMemo(
|
||||
() =>
|
||||
bases.map((base) => ({
|
||||
label: base.name,
|
||||
value: base.id,
|
||||
disabled: !base.version // 如果知识库没有配置好就禁用
|
||||
})),
|
||||
[bases]
|
||||
)
|
||||
|
||||
// 合并状态计算
|
||||
const formState = useMemo(() => {
|
||||
const hasValidBase = selectedBaseId && bases.find((base) => base.id === selectedBaseId)?.version
|
||||
const hasContent = contentTypeOptions.length > 0
|
||||
const selectedCount = contentTypeOptions
|
||||
.filter((option) => selectedTypes.includes(option.type))
|
||||
.reduce((sum, option) => sum + option.count, 0)
|
||||
|
||||
return {
|
||||
hasValidBase,
|
||||
hasContent,
|
||||
canSubmit: hasValidBase && selectedTypes.length > 0 && hasContent,
|
||||
selectedCount,
|
||||
hasNoSelection: selectedTypes.length === 0 && hasContent
|
||||
}
|
||||
}, [selectedBaseId, bases, contentTypeOptions, selectedTypes])
|
||||
|
||||
// 默认选择第一个可用的知识库
|
||||
useEffect(() => {
|
||||
if (!selectedBaseId) {
|
||||
const firstAvailableBase = bases.find((base) => base.version)
|
||||
if (firstAvailableBase) {
|
||||
setSelectedBaseId(firstAvailableBase.id)
|
||||
}
|
||||
}
|
||||
}, [bases, selectedBaseId])
|
||||
|
||||
// 默认选择所有可用的内容类型(仅在初始化时)
|
||||
useEffect(() => {
|
||||
if (!hasInitialized && contentTypeOptions.length > 0) {
|
||||
const availableTypes = contentTypeOptions.map((option) => option.type)
|
||||
setSelectedTypes(availableTypes)
|
||||
setHasInitialized(true)
|
||||
}
|
||||
}, [contentTypeOptions, hasInitialized])
|
||||
|
||||
// 计算UI状态
|
||||
const uiState = useMemo(() => {
|
||||
if (!formState.hasContent) {
|
||||
return { type: 'empty', message: t('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])
|
||||
|
||||
// 处理内容类型选择切换
|
||||
const handleContentTypeToggle = (type: ContentType) => {
|
||||
setSelectedTypes((prev) => (prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type]))
|
||||
}
|
||||
|
||||
const onOk = async () => {
|
||||
if (!formState.canSubmit) {
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
let savedCount = 0
|
||||
|
||||
try {
|
||||
const result = processMessageContent(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
|
||||
}
|
||||
|
||||
setOpen(false)
|
||||
resolve({ success: true, savedCount })
|
||||
} catch (error) {
|
||||
Logger.error('[SaveToKnowledgePopup] save failed:', error)
|
||||
window.message.error(t('chat.save.knowledge.error.save_failed'))
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
resolve(null)
|
||||
}
|
||||
|
||||
// 渲染空状态
|
||||
const renderEmptyState = () => (
|
||||
<EmptyContainer>
|
||||
<Text type="secondary">{uiState.message}</Text>
|
||||
</EmptyContainer>
|
||||
)
|
||||
|
||||
// 渲染表单内容
|
||||
const renderFormContent = () => (
|
||||
<>
|
||||
<Form layout="vertical">
|
||||
<Form.Item
|
||||
label={t('chat.save.knowledge.select.base.title')}
|
||||
help={!formState.hasValidBase && selectedBaseId ? t('chat.save.knowledge.error.invalid_base') : undefined}
|
||||
validateStatus={!formState.hasValidBase && selectedBaseId ? 'error' : undefined}>
|
||||
<Select
|
||||
value={selectedBaseId}
|
||||
onChange={setSelectedBaseId}
|
||||
options={knowledgeBaseOptions}
|
||||
placeholder={t('chat.save.knowledge.select.base.placeholder')}
|
||||
showSearch
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t('chat.save.knowledge.select.content.title')}>
|
||||
<Flex gap={8} style={{ flexDirection: 'column' }}>
|
||||
{contentTypeOptions.map((option) => (
|
||||
<ContentTypeItem
|
||||
key={option.type}
|
||||
align="center"
|
||||
justify="space-between"
|
||||
onClick={() => handleContentTypeToggle(option.type)}>
|
||||
<Flex align="center" gap={8}>
|
||||
<CustomTag
|
||||
color={selectedTypes.includes(option.type) ? TAG_COLORS.SELECTED : TAG_COLORS.UNSELECTED}
|
||||
size={12}>
|
||||
{option.count}
|
||||
</CustomTag>
|
||||
<span>{option.label}</span>
|
||||
<Tooltip title={option.description} mouseLeaveDelay={0}>
|
||||
<CircleHelp size={16} style={{ cursor: 'help' }} />
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
{selectedTypes.includes(option.type) && <Check size={16} color={TAG_COLORS.SELECTED} />}
|
||||
</ContentTypeItem>
|
||||
))}
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
{formState.selectedCount > 0 && (
|
||||
<InfoContainer>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
{t('chat.save.knowledge.select.content.tip', { count: formState.selectedCount })}
|
||||
</Text>
|
||||
</InfoContainer>
|
||||
)}
|
||||
|
||||
{formState.hasNoSelection && (
|
||||
<InfoContainer>
|
||||
<Text type="warning" style={{ fontSize: '12px' }}>
|
||||
{t('chat.save.knowledge.error.no_content_selected')}
|
||||
</Text>
|
||||
</InfoContainer>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={title || t('chat.save.knowledge.title')}
|
||||
open={open}
|
||||
onOk={onOk}
|
||||
onCancel={onCancel}
|
||||
afterClose={onClose}
|
||||
destroyOnClose
|
||||
centered
|
||||
width={500}
|
||||
okText={t('common.save')}
|
||||
cancelText={t('common.cancel')}
|
||||
okButtonProps={{
|
||||
loading,
|
||||
disabled: !formState.canSubmit
|
||||
}}>
|
||||
{uiState.type === 'empty' ? renderEmptyState() : renderFormContent()}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const TopViewKey = 'SaveToKnowledgePopup'
|
||||
|
||||
export default class SaveToKnowledgePopup {
|
||||
static hide() {
|
||||
TopView.hide(TopViewKey)
|
||||
}
|
||||
|
||||
static show(props: ShowParams): Promise<SaveResult | null> {
|
||||
return new Promise<SaveResult | null>((resolve) => {
|
||||
TopView.show(
|
||||
<PopupContainer
|
||||
{...props}
|
||||
resolve={(result) => {
|
||||
resolve(result)
|
||||
this.hide()
|
||||
}}
|
||||
/>,
|
||||
TopViewKey
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const EmptyContainer = styled.div`
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
`
|
||||
|
||||
const ContentTypeItem = styled(Flex)`
|
||||
padding: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
`
|
||||
|
||||
const InfoContainer = styled.div`
|
||||
background: var(--color-background-soft);
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
margin-top: 16px;
|
||||
`
|
||||
@ -234,6 +234,35 @@
|
||||
},
|
||||
"resend": "Resend",
|
||||
"save": "Save",
|
||||
"save.file.title": "Save to Local File",
|
||||
"save.knowledge": {
|
||||
"title": "Save to Knowledge Base",
|
||||
"content.maintext.title": "Main Text",
|
||||
"content.maintext.description": "Includes primary text content",
|
||||
"content.code.title": "Code Blocks",
|
||||
"content.code.description": "Includes standalone code blocks",
|
||||
"content.thinking.title": "Reasoning",
|
||||
"content.thinking.description": "Includes model reasoning content",
|
||||
"content.tool_use.title": "Tool Usage",
|
||||
"content.tool_use.description": "Includes tool call parameters and execution results",
|
||||
"content.citation.title": "Citations",
|
||||
"content.citation.description": "Includes web search and knowledge base reference information",
|
||||
"content.translation.title": "Translations",
|
||||
"content.translation.description": "Includes translation content",
|
||||
"content.error.title": "Errors",
|
||||
"content.error.description": "Includes error messages during execution",
|
||||
"content.file.title": "Files",
|
||||
"content.file.description": "Includes attached files",
|
||||
"empty.no_content": "This message has no saveable content",
|
||||
"empty.no_knowledge_base": "No knowledge bases available, please create one first",
|
||||
"error.save_failed": "Save failed, please check knowledge base configuration",
|
||||
"error.invalid_base": "Selected knowledge base is not properly configured",
|
||||
"error.no_content_selected": "Please select at least one content type",
|
||||
"select.base.title": "Select Knowledge Base",
|
||||
"select.base.placeholder": "Please select a knowledge base",
|
||||
"select.content.title": "Select content types to save",
|
||||
"select.content.tip": "Selected {{count}} items, text types will be merged and saved as one note"
|
||||
},
|
||||
"settings.code.title": "Code Block Settings",
|
||||
"settings.code_cache_max_size": "Max cache size",
|
||||
"settings.code_cache_max_size.tip": "The maximum number of characters allowed to be cached (thousand characters), calculated according to the highlighted code. The length of the highlighted code is much longer than the pure text.",
|
||||
|
||||
@ -234,6 +234,34 @@
|
||||
},
|
||||
"resend": "再送信",
|
||||
"save": "保存",
|
||||
"save.knowledge": {
|
||||
"title": "ナレッジベースに保存",
|
||||
"content.maintext.title": "メインテキスト",
|
||||
"content.maintext.description": "主要なテキストコンテンツを含む",
|
||||
"content.code.title": "コードブロック",
|
||||
"content.code.description": "独立したコードブロックを含む",
|
||||
"content.thinking.title": "思考プロセス",
|
||||
"content.thinking.description": "モデルの推論内容を含む",
|
||||
"content.tool_use.title": "ツール使用",
|
||||
"content.tool_use.description": "ツール呼び出しパラメーターと実行結果を含む",
|
||||
"content.citation.title": "引用",
|
||||
"content.citation.description": "ウェブ検索とナレッジベース参照情報を含む",
|
||||
"content.translation.title": "翻訳",
|
||||
"content.translation.description": "翻訳コンテンツを含む",
|
||||
"content.error.title": "エラー",
|
||||
"content.error.description": "実行中のエラーメッセージを含む",
|
||||
"content.file.title": "ファイル",
|
||||
"content.file.description": "添付ファイルを含む",
|
||||
"empty.no_content": "このメッセージには保存可能なコンテンツがありません",
|
||||
"empty.no_knowledge_base": "利用可能なナレッジベースがありません。まず作成してください",
|
||||
"error.save_failed": "保存に失敗しました。ナレッジベースの設定を確認してください",
|
||||
"error.invalid_base": "選択されたナレッジベースが正しく設定されていません",
|
||||
"error.no_content_selected": "少なくとも1つのコンテンツタイプを選択してください",
|
||||
"select.base.title": "ナレッジベースを選択",
|
||||
"select.base.placeholder": "ナレッジベースを選択してください",
|
||||
"select.content.title": "保存するコンテンツタイプを選択",
|
||||
"select.content.tip": "{{count}}項目が選択されました。テキストタイプは統合されて1つのノートとして保存されます"
|
||||
},
|
||||
"settings.code.title": "コード設定",
|
||||
"settings.code_cache_max_size": "キャッシュ上限",
|
||||
"settings.code_cache_max_size.tip": "キャッシュできる文字数の上限(千字符)。ハイライトされたコードの長さは純粋なテキストよりもはるかに長くなります。",
|
||||
@ -337,7 +365,8 @@
|
||||
"topics.prompt.tips": "トピック提示語:現在のトピックに対して追加の補足提示語を提供",
|
||||
"topics.title": "トピック",
|
||||
"topics.unpinned": "固定解除",
|
||||
"translate": "翻訳"
|
||||
"translate": "翻訳",
|
||||
"save.file.title": "ローカルファイルに保存"
|
||||
},
|
||||
"html_artifacts": {
|
||||
"code": "コード",
|
||||
|
||||
@ -234,6 +234,34 @@
|
||||
},
|
||||
"resend": "Переотправить",
|
||||
"save": "Сохранить",
|
||||
"save.knowledge": {
|
||||
"title": "Сохранить в базу знаний",
|
||||
"content.maintext.title": "Основной текст",
|
||||
"content.maintext.description": "Включает основное текстовое содержимое",
|
||||
"content.code.title": "Блоки кода",
|
||||
"content.code.description": "Включает отдельные блоки кода",
|
||||
"content.thinking.title": "Размышления",
|
||||
"content.thinking.description": "Включает содержимое рассуждений модели",
|
||||
"content.tool_use.title": "Использование инструментов",
|
||||
"content.tool_use.description": "Включает параметры вызова инструментов и результаты выполнения",
|
||||
"content.citation.title": "Цитаты",
|
||||
"content.citation.description": "Включает информацию веб-поиска и ссылки на базу знаний",
|
||||
"content.translation.title": "Переводы",
|
||||
"content.translation.description": "Включает переводное содержимое",
|
||||
"content.error.title": "Ошибки",
|
||||
"content.error.description": "Включает сообщения об ошибках во время выполнения",
|
||||
"content.file.title": "Файлы",
|
||||
"content.file.description": "Включает прикрепленные файлы",
|
||||
"empty.no_content": "Это сообщение не содержит сохраняемого контента",
|
||||
"empty.no_knowledge_base": "Нет доступных баз знаний, сначала создайте одну",
|
||||
"error.save_failed": "Сохранение не удалось, проверьте конфигурацию базы знаний",
|
||||
"error.invalid_base": "Выбранная база знаний настроена неправильно",
|
||||
"error.no_content_selected": "Выберите хотя бы один тип контента",
|
||||
"select.base.title": "Выберите базу знаний",
|
||||
"select.base.placeholder": "Пожалуйста, выберите базу знаний",
|
||||
"select.content.title": "Выберите типы контента для сохранения",
|
||||
"select.content.tip": "Выбрано {{count}} элементов, текстовые типы будут объединены и сохранены как одна заметка"
|
||||
},
|
||||
"settings.code.title": "Настройки кода",
|
||||
"settings.code_cache_max_size": "Максимальный размер кэша",
|
||||
"settings.code_cache_max_size.tip": "Максимальное количество символов, которое может быть кэшировано (тысяч символов), рассчитывается по кэшированному коду. Длина кэшированного кода значительно превышает длину чистого текста.",
|
||||
@ -337,7 +365,8 @@
|
||||
"topics.prompt.tips": "Тематические подсказки: Дополнительные подсказки, предоставленные для текущей темы",
|
||||
"topics.title": "Топики",
|
||||
"topics.unpinned": "Открепленные темы",
|
||||
"translate": "Перевести"
|
||||
"translate": "Перевести",
|
||||
"save.file.title": "Сохранить в локальный файл"
|
||||
},
|
||||
"html_artifacts": {
|
||||
"code": "Код",
|
||||
|
||||
@ -234,6 +234,35 @@
|
||||
},
|
||||
"resend": "重新发送",
|
||||
"save": "保存",
|
||||
"save.file.title": "保存到本地文件",
|
||||
"save.knowledge": {
|
||||
"title": "保存到知识库",
|
||||
"content.maintext.title": "主文本",
|
||||
"content.maintext.description": "包括主要的文本内容",
|
||||
"content.code.title": "代码块",
|
||||
"content.code.description": "包括独立的代码块",
|
||||
"content.thinking.title": "思考",
|
||||
"content.thinking.description": "包括模型思考内容",
|
||||
"content.tool_use.title": "工具调用",
|
||||
"content.tool_use.description": "包括工具调用参数和执行结果",
|
||||
"content.citation.title": "引用",
|
||||
"content.citation.description": "包括网络搜索和知识库引用信息",
|
||||
"content.translation.title": "翻译",
|
||||
"content.translation.description": "包括翻译内容",
|
||||
"content.error.title": "错误",
|
||||
"content.error.description": "包括执行过程中的错误信息",
|
||||
"content.file.title": "文件",
|
||||
"content.file.description": "包括作为附件的文件",
|
||||
"empty.no_content": "此消息没有可保存的内容",
|
||||
"empty.no_knowledge_base": "暂无可用知识库,请先创建知识库",
|
||||
"error.save_failed": "保存失败,请检查知识库配置",
|
||||
"error.invalid_base": "所选知识库未正确配置",
|
||||
"error.no_content_selected": "请至少选择一种内容",
|
||||
"select.base.title": "选择知识库",
|
||||
"select.base.placeholder": "请选择知识库",
|
||||
"select.content.title": "选择要保存的内容类型",
|
||||
"select.content.tip": "已选择 {{count}} 项内容,文本类型将合并保存为一个笔记"
|
||||
},
|
||||
"settings.code.title": "代码块设置",
|
||||
"settings.code_cache_max_size": "缓存上限",
|
||||
"settings.code_cache_max_size.tip": "允许缓存的字符数上限(千字符),按照高亮后的代码计算。高亮后的代码长度相比于纯文本会长很多",
|
||||
|
||||
@ -234,6 +234,34 @@
|
||||
},
|
||||
"resend": "重新傳送",
|
||||
"save": "儲存",
|
||||
"save.knowledge": {
|
||||
"title": "儲存到知識庫",
|
||||
"content.maintext.title": "主文本",
|
||||
"content.maintext.description": "包括主要的文本內容",
|
||||
"content.code.title": "程式碼區塊",
|
||||
"content.code.description": "包括獨立的程式碼區塊",
|
||||
"content.thinking.title": "思考過程",
|
||||
"content.thinking.description": "包括模型思考內容",
|
||||
"content.tool_use.title": "工具使用",
|
||||
"content.tool_use.description": "包括工具呼叫參數和執行結果",
|
||||
"content.citation.title": "引用",
|
||||
"content.citation.description": "包括網路搜尋和知識庫引用資訊",
|
||||
"content.translation.title": "翻譯",
|
||||
"content.translation.description": "包括翻譯內容",
|
||||
"content.error.title": "錯誤",
|
||||
"content.error.description": "包括執行過程中的錯誤資訊",
|
||||
"content.file.title": "檔案",
|
||||
"content.file.description": "包括作為附件的檔案",
|
||||
"empty.no_content": "此訊息沒有可儲存的內容",
|
||||
"empty.no_knowledge_base": "暫無可用知識庫,請先建立知識庫",
|
||||
"error.save_failed": "儲存失敗,請檢查知識庫設定",
|
||||
"error.invalid_base": "所選知識庫未正確設定",
|
||||
"error.no_content_selected": "請至少選擇一種內容類型",
|
||||
"select.base.title": "選擇知識庫",
|
||||
"select.base.placeholder": "請選擇知識庫",
|
||||
"select.content.title": "選擇要儲存的內容類型",
|
||||
"select.content.tip": "已選擇 {{count}} 項內容,文本類型將合併儲存為一個筆記"
|
||||
},
|
||||
"settings.code.title": "程式碼區塊",
|
||||
"settings.code_cache_max_size": "快取上限",
|
||||
"settings.code_cache_max_size.tip": "允許快取的字元數上限(千字符),按照高亮後的程式碼計算。高亮後的程式碼長度相比純文字會長很多",
|
||||
@ -337,7 +365,8 @@
|
||||
"topics.prompt.tips": "話題提示詞:針對目前話題提供額外的補充提示詞",
|
||||
"topics.title": "話題",
|
||||
"topics.unpinned": "取消固定",
|
||||
"translate": "翻譯"
|
||||
"translate": "翻譯",
|
||||
"save.file.title": "[to be translated]:Save to Local File"
|
||||
},
|
||||
"html_artifacts": {
|
||||
"code": "程式碼",
|
||||
@ -2412,4 +2441,4 @@
|
||||
"visualization": "視覺化"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
import { CheckOutlined, EditOutlined, QuestionCircleOutlined, SyncOutlined } from '@ant-design/icons'
|
||||
import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup'
|
||||
import SaveToKnowledgePopup from '@renderer/components/Popups/SaveToKnowledgePopup'
|
||||
import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup'
|
||||
import { isVisionModel } from '@renderer/config/models'
|
||||
import { translateLanguageOptions } from '@renderer/config/translate'
|
||||
@ -182,15 +183,6 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
|
||||
const dropdownItems = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: t('chat.save'),
|
||||
key: 'save',
|
||||
icon: <Save size={16} />,
|
||||
onClick: () => {
|
||||
const fileName = dayjs(message.createdAt).format('YYYYMMDDHHmm') + '.md'
|
||||
window.api.file.save(fileName, mainTextContent)
|
||||
}
|
||||
},
|
||||
...(isEditable
|
||||
? [
|
||||
{
|
||||
@ -215,6 +207,28 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
toggleMultiSelectMode(true)
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('chat.save'),
|
||||
key: 'save',
|
||||
icon: <Save size={16} color="var(--color-icon)" style={{ marginTop: 3 }} />,
|
||||
children: [
|
||||
{
|
||||
label: t('chat.save.file.title'),
|
||||
key: 'file',
|
||||
onClick: () => {
|
||||
const fileName = dayjs(message.createdAt).format('YYYYMMDDHHmm') + '.md'
|
||||
window.api.file.save(fileName, mainTextContent)
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('chat.save.knowledge.title'),
|
||||
key: 'knowledge',
|
||||
onClick: () => {
|
||||
SaveToKnowledgePopup.show({ message })
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: t('chat.topics.export.title'),
|
||||
key: 'export',
|
||||
|
||||
269
src/renderer/src/utils/knowledge.ts
Normal file
269
src/renderer/src/utils/knowledge.ts
Normal file
@ -0,0 +1,269 @@
|
||||
import type { FileType } from '@renderer/types'
|
||||
import type { Message, MessageBlock } from '@renderer/types/newMessage'
|
||||
import type {
|
||||
CitationMessageBlock,
|
||||
CodeMessageBlock,
|
||||
ErrorMessageBlock,
|
||||
FileMessageBlock,
|
||||
ImageMessageBlock,
|
||||
MainTextMessageBlock,
|
||||
ThinkingMessageBlock,
|
||||
ToolMessageBlock,
|
||||
TranslationMessageBlock
|
||||
} from '@renderer/types/newMessage'
|
||||
import { MessageBlockType } from '@renderer/types/newMessage'
|
||||
|
||||
import { findAllBlocks } from './messageUtils/find'
|
||||
|
||||
/**
|
||||
* 内容类型常量定义
|
||||
*/
|
||||
export const CONTENT_TYPES = {
|
||||
TEXT: 'text',
|
||||
CODE: 'code',
|
||||
THINKING: 'thinking',
|
||||
TOOL_USE: 'tools',
|
||||
CITATION: 'citations',
|
||||
TRANSLATION: 'translations',
|
||||
ERROR: 'errors',
|
||||
FILE: 'files',
|
||||
IMAGES: 'images'
|
||||
} as const
|
||||
|
||||
export type ContentType = (typeof CONTENT_TYPES)[keyof typeof CONTENT_TYPES]
|
||||
|
||||
/**
|
||||
* 消息内容统计
|
||||
*/
|
||||
export interface MessageContentStats {
|
||||
text: number // 主文本块数量
|
||||
code: number // 代码块数量
|
||||
thinking: number // 思考块数量
|
||||
images: number // 图片数量
|
||||
files: number // 文件数量
|
||||
tools: number // 工具调用数量
|
||||
citations: number // 引用数量
|
||||
translations: number // 翻译数量
|
||||
errors: number // 错误数量
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息预处理结果
|
||||
*/
|
||||
export interface MessagePreprocessResult {
|
||||
// 合并后的文本内容
|
||||
text: string
|
||||
|
||||
// 文件列表
|
||||
files: FileType[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析消息内容,统计各类型内容数量
|
||||
*/
|
||||
export function analyzeMessageContent(message: Message): MessageContentStats {
|
||||
const blocks = findAllBlocks(message)
|
||||
|
||||
const stats: MessageContentStats = {
|
||||
text: 0,
|
||||
code: 0,
|
||||
thinking: 0,
|
||||
images: 0,
|
||||
files: 0,
|
||||
tools: 0,
|
||||
citations: 0,
|
||||
translations: 0,
|
||||
errors: 0
|
||||
}
|
||||
|
||||
for (const block of blocks) {
|
||||
switch (block.type) {
|
||||
case MessageBlockType.MAIN_TEXT: {
|
||||
const mainTextBlock = block as MainTextMessageBlock
|
||||
if (mainTextBlock.content?.trim()) {
|
||||
stats.text++
|
||||
}
|
||||
break
|
||||
}
|
||||
case MessageBlockType.CODE: {
|
||||
const codeBlock = block as CodeMessageBlock
|
||||
if (codeBlock.content?.trim()) {
|
||||
stats.code++
|
||||
}
|
||||
break
|
||||
}
|
||||
case MessageBlockType.THINKING:
|
||||
stats.thinking++
|
||||
break
|
||||
case MessageBlockType.TOOL:
|
||||
stats.tools++
|
||||
break
|
||||
case MessageBlockType.IMAGE:
|
||||
stats.images++
|
||||
break
|
||||
case MessageBlockType.FILE:
|
||||
stats.files++
|
||||
break
|
||||
case MessageBlockType.CITATION:
|
||||
stats.citations++
|
||||
break
|
||||
case MessageBlockType.TRANSLATION:
|
||||
stats.translations++
|
||||
break
|
||||
case MessageBlockType.ERROR:
|
||||
stats.errors++
|
||||
break
|
||||
case MessageBlockType.UNKNOWN:
|
||||
// 占位符块不计入统计
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据选择的内容类型,处理消息内容
|
||||
* 将选中的文本类型合并为字符串,提取文件列表
|
||||
*/
|
||||
export function processMessageContent(message: Message, selectedTypes: ContentType[]): MessagePreprocessResult {
|
||||
const blocks = findAllBlocks(message)
|
||||
const textParts: string[] = []
|
||||
const files: FileType[] = []
|
||||
|
||||
// 提高查找效率
|
||||
const selectedTypeSet = new Set(selectedTypes)
|
||||
|
||||
for (const block of blocks) {
|
||||
// 处理文本内容
|
||||
const textContent = processTextlikeBlocks(block, selectedTypeSet)
|
||||
if (textContent.trim()) {
|
||||
textParts.push(textContent)
|
||||
}
|
||||
|
||||
// 处理文件内容
|
||||
if (selectedTypeSet.has(CONTENT_TYPES.FILE)) {
|
||||
const fileContent = processFileBlocks(block)
|
||||
if (fileContent) {
|
||||
files.push(fileContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
text: textParts.join('\n\n'),
|
||||
files
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理所选类型的文本内容
|
||||
*/
|
||||
function processTextlikeBlocks(block: MessageBlock, selectedTypes: Set<ContentType>): string {
|
||||
switch (block.type) {
|
||||
case MessageBlockType.MAIN_TEXT: {
|
||||
if (!selectedTypes.has(CONTENT_TYPES.TEXT)) return ''
|
||||
const mainTextBlock = block as MainTextMessageBlock
|
||||
return mainTextBlock.content || ''
|
||||
}
|
||||
|
||||
case MessageBlockType.CODE: {
|
||||
if (!selectedTypes.has(CONTENT_TYPES.CODE)) return ''
|
||||
const codeBlock = block as CodeMessageBlock
|
||||
return codeBlock.content || ''
|
||||
}
|
||||
|
||||
case MessageBlockType.THINKING: {
|
||||
if (!selectedTypes.has(CONTENT_TYPES.THINKING)) return ''
|
||||
const thinkingBlock = block as ThinkingMessageBlock
|
||||
const thinkingContent = thinkingBlock.content || ''
|
||||
return `<think>\n${thinkingContent}\n</think>`
|
||||
}
|
||||
|
||||
case MessageBlockType.TOOL: {
|
||||
if (!selectedTypes.has(CONTENT_TYPES.TOOL_USE)) return ''
|
||||
const toolBlock = block as ToolMessageBlock
|
||||
const rawResponse = toolBlock.metadata?.rawMcpToolResponse
|
||||
const toolInfo = {
|
||||
id: toolBlock.toolId,
|
||||
name: toolBlock.toolName || '',
|
||||
description: rawResponse?.tool?.description,
|
||||
arguments: rawResponse?.arguments,
|
||||
status: rawResponse?.status,
|
||||
response: rawResponse?.response
|
||||
}
|
||||
return `<tool>\n${JSON.stringify(toolInfo, null, 2)}\n</tool>`
|
||||
}
|
||||
|
||||
case MessageBlockType.IMAGE: {
|
||||
if (!selectedTypes.has(CONTENT_TYPES.IMAGES)) return ''
|
||||
const imageBlock = block as ImageMessageBlock
|
||||
if (imageBlock.file) {
|
||||
return `<image id="${imageBlock.id}" filename="${imageBlock.file.name}" type="${imageBlock.file.type}" />`
|
||||
} else if (imageBlock.url) {
|
||||
return `<image id="${imageBlock.id}" url="${imageBlock.url}" />`
|
||||
}
|
||||
return `<image id="${imageBlock.id}" />`
|
||||
}
|
||||
|
||||
case MessageBlockType.FILE: {
|
||||
// 文件信息在文本中只作为元信息记录,实际文件在files数组中
|
||||
if (!selectedTypes.has(CONTENT_TYPES.FILE)) return ''
|
||||
const fileBlock = block as FileMessageBlock
|
||||
return `<file id="${fileBlock.id}" filename="${fileBlock.file.name}" type="${fileBlock.file.type}" size="${fileBlock.file.size}" />`
|
||||
}
|
||||
|
||||
case MessageBlockType.CITATION: {
|
||||
if (!selectedTypes.has(CONTENT_TYPES.CITATION)) return ''
|
||||
const citationBlock = block as CitationMessageBlock
|
||||
const citationInfo = {
|
||||
id: citationBlock.id,
|
||||
response: citationBlock.response,
|
||||
knowledge: citationBlock.knowledge
|
||||
}
|
||||
if (citationInfo.response || citationInfo.knowledge) {
|
||||
return `<citation id="${citationInfo.id}">\n${JSON.stringify(citationInfo, null, 2)}\n</citation>`
|
||||
}
|
||||
return `<citation id="${citationInfo.id}" />`
|
||||
}
|
||||
|
||||
case MessageBlockType.ERROR: {
|
||||
if (!selectedTypes.has(CONTENT_TYPES.ERROR)) return ''
|
||||
const errorBlock = block as ErrorMessageBlock
|
||||
const errorContent = errorBlock.error ? JSON.stringify(errorBlock.error) : 'Error occurred'
|
||||
return `<error>\n${errorContent}\n</error>`
|
||||
}
|
||||
|
||||
case MessageBlockType.TRANSLATION: {
|
||||
if (!selectedTypes.has(CONTENT_TYPES.TRANSLATION)) return ''
|
||||
const translationBlock = block as TranslationMessageBlock
|
||||
return `<translation target="${translationBlock.targetLanguage}">\n${translationBlock.content}\n</translation>`
|
||||
}
|
||||
|
||||
case MessageBlockType.UNKNOWN:
|
||||
// 占位符块,通常不需要输出内容
|
||||
return ''
|
||||
|
||||
default: {
|
||||
// 未知类型的处理
|
||||
const unknownBlock = block as MessageBlock
|
||||
return `<${unknownBlock.type} id="${unknownBlock.id}" />`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理文件块
|
||||
*/
|
||||
function processFileBlocks(block: MessageBlock): FileType | null {
|
||||
switch (block.type) {
|
||||
case MessageBlockType.FILE: {
|
||||
const fileBlock = block as FileMessageBlock
|
||||
return fileBlock.file
|
||||
}
|
||||
|
||||
// 未来可能扩展其他类型
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user