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:
one
2025-07-10 21:34:01 +08:00
committed by GitHub
parent 7c6db809bb
commit ba742b7b1f
8 changed files with 794 additions and 13 deletions
@@ -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;
`
+29
View File
@@ -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.",
+30 -1
View File
@@ -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": "コード",
+30 -1
View File
@@ -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": "Код",
+29
View File
@@ -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": "允许缓存的字符数上限(千字符),按照高亮后的代码计算。高亮后的代码长度相比于纯文本会长很多",
+31 -2
View File
@@ -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
View 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
}
}