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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 794 additions and 13 deletions

View 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;
`

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.",

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": "コード",

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": "Код",

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": "允许缓存的字符数上限(千字符),按照高亮后的代码计算。高亮后的代码长度相比于纯文本会长很多",

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": "視覺化"
}
}
}
}

View File

@ -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',

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
}
}