mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-07 05:39:05 +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",
|
"resend": "Resend",
|
||||||
"save": "Save",
|
"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.title": "Code Block Settings",
|
||||||
"settings.code_cache_max_size": "Max cache size",
|
"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.",
|
"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": "再送信",
|
"resend": "再送信",
|
||||||
"save": "保存",
|
"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.title": "コード設定",
|
||||||
"settings.code_cache_max_size": "キャッシュ上限",
|
"settings.code_cache_max_size": "キャッシュ上限",
|
||||||
"settings.code_cache_max_size.tip": "キャッシュできる文字数の上限(千字符)。ハイライトされたコードの長さは純粋なテキストよりもはるかに長くなります。",
|
"settings.code_cache_max_size.tip": "キャッシュできる文字数の上限(千字符)。ハイライトされたコードの長さは純粋なテキストよりもはるかに長くなります。",
|
||||||
@ -337,7 +365,8 @@
|
|||||||
"topics.prompt.tips": "トピック提示語:現在のトピックに対して追加の補足提示語を提供",
|
"topics.prompt.tips": "トピック提示語:現在のトピックに対して追加の補足提示語を提供",
|
||||||
"topics.title": "トピック",
|
"topics.title": "トピック",
|
||||||
"topics.unpinned": "固定解除",
|
"topics.unpinned": "固定解除",
|
||||||
"translate": "翻訳"
|
"translate": "翻訳",
|
||||||
|
"save.file.title": "ローカルファイルに保存"
|
||||||
},
|
},
|
||||||
"html_artifacts": {
|
"html_artifacts": {
|
||||||
"code": "コード",
|
"code": "コード",
|
||||||
|
|||||||
@ -234,6 +234,34 @@
|
|||||||
},
|
},
|
||||||
"resend": "Переотправить",
|
"resend": "Переотправить",
|
||||||
"save": "Сохранить",
|
"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.title": "Настройки кода",
|
||||||
"settings.code_cache_max_size": "Максимальный размер кэша",
|
"settings.code_cache_max_size": "Максимальный размер кэша",
|
||||||
"settings.code_cache_max_size.tip": "Максимальное количество символов, которое может быть кэшировано (тысяч символов), рассчитывается по кэшированному коду. Длина кэшированного кода значительно превышает длину чистого текста.",
|
"settings.code_cache_max_size.tip": "Максимальное количество символов, которое может быть кэшировано (тысяч символов), рассчитывается по кэшированному коду. Длина кэшированного кода значительно превышает длину чистого текста.",
|
||||||
@ -337,7 +365,8 @@
|
|||||||
"topics.prompt.tips": "Тематические подсказки: Дополнительные подсказки, предоставленные для текущей темы",
|
"topics.prompt.tips": "Тематические подсказки: Дополнительные подсказки, предоставленные для текущей темы",
|
||||||
"topics.title": "Топики",
|
"topics.title": "Топики",
|
||||||
"topics.unpinned": "Открепленные темы",
|
"topics.unpinned": "Открепленные темы",
|
||||||
"translate": "Перевести"
|
"translate": "Перевести",
|
||||||
|
"save.file.title": "Сохранить в локальный файл"
|
||||||
},
|
},
|
||||||
"html_artifacts": {
|
"html_artifacts": {
|
||||||
"code": "Код",
|
"code": "Код",
|
||||||
|
|||||||
@ -234,6 +234,35 @@
|
|||||||
},
|
},
|
||||||
"resend": "重新发送",
|
"resend": "重新发送",
|
||||||
"save": "保存",
|
"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.title": "代码块设置",
|
||||||
"settings.code_cache_max_size": "缓存上限",
|
"settings.code_cache_max_size": "缓存上限",
|
||||||
"settings.code_cache_max_size.tip": "允许缓存的字符数上限(千字符),按照高亮后的代码计算。高亮后的代码长度相比于纯文本会长很多",
|
"settings.code_cache_max_size.tip": "允许缓存的字符数上限(千字符),按照高亮后的代码计算。高亮后的代码长度相比于纯文本会长很多",
|
||||||
|
|||||||
@ -234,6 +234,34 @@
|
|||||||
},
|
},
|
||||||
"resend": "重新傳送",
|
"resend": "重新傳送",
|
||||||
"save": "儲存",
|
"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.title": "程式碼區塊",
|
||||||
"settings.code_cache_max_size": "快取上限",
|
"settings.code_cache_max_size": "快取上限",
|
||||||
"settings.code_cache_max_size.tip": "允許快取的字元數上限(千字符),按照高亮後的程式碼計算。高亮後的程式碼長度相比純文字會長很多",
|
"settings.code_cache_max_size.tip": "允許快取的字元數上限(千字符),按照高亮後的程式碼計算。高亮後的程式碼長度相比純文字會長很多",
|
||||||
@ -337,7 +365,8 @@
|
|||||||
"topics.prompt.tips": "話題提示詞:針對目前話題提供額外的補充提示詞",
|
"topics.prompt.tips": "話題提示詞:針對目前話題提供額外的補充提示詞",
|
||||||
"topics.title": "話題",
|
"topics.title": "話題",
|
||||||
"topics.unpinned": "取消固定",
|
"topics.unpinned": "取消固定",
|
||||||
"translate": "翻譯"
|
"translate": "翻譯",
|
||||||
|
"save.file.title": "[to be translated]:Save to Local File"
|
||||||
},
|
},
|
||||||
"html_artifacts": {
|
"html_artifacts": {
|
||||||
"code": "程式碼",
|
"code": "程式碼",
|
||||||
@ -2412,4 +2441,4 @@
|
|||||||
"visualization": "視覺化"
|
"visualization": "視覺化"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import { CheckOutlined, EditOutlined, QuestionCircleOutlined, SyncOutlined } from '@ant-design/icons'
|
import { CheckOutlined, EditOutlined, QuestionCircleOutlined, SyncOutlined } from '@ant-design/icons'
|
||||||
import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup'
|
import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup'
|
||||||
|
import SaveToKnowledgePopup from '@renderer/components/Popups/SaveToKnowledgePopup'
|
||||||
import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup'
|
import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup'
|
||||||
import { isVisionModel } from '@renderer/config/models'
|
import { isVisionModel } from '@renderer/config/models'
|
||||||
import { translateLanguageOptions } from '@renderer/config/translate'
|
import { translateLanguageOptions } from '@renderer/config/translate'
|
||||||
@ -182,15 +183,6 @@ const MessageMenubar: FC<Props> = (props) => {
|
|||||||
|
|
||||||
const dropdownItems = useMemo(
|
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
|
...(isEditable
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
@ -215,6 +207,28 @@ const MessageMenubar: FC<Props> = (props) => {
|
|||||||
toggleMultiSelectMode(true)
|
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'),
|
label: t('chat.topics.export.title'),
|
||||||
key: 'export',
|
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