diff --git a/src/renderer/src/components/ObsidianExportDialog.tsx b/src/renderer/src/components/ObsidianExportDialog.tsx index 267473f483..631c085cad 100644 --- a/src/renderer/src/components/ObsidianExportDialog.tsx +++ b/src/renderer/src/components/ObsidianExportDialog.tsx @@ -1,26 +1,36 @@ import i18n from '@renderer/i18n' import store from '@renderer/store' -import { exportMarkdownToObsidian } from '@renderer/utils/export' -import { Alert, Empty, Form, Input, Modal, Select, Spin, TreeSelect } from 'antd' +import type { Topic } from '@renderer/types' +import type { Message } from '@renderer/types/newMessage' +import { + exportMarkdownToObsidian, + messagesToMarkdown, + messageToMarkdown, + messageToMarkdownWithReasoning, + topicToMarkdown +} from '@renderer/utils/export' +import { Alert, Empty, Form, Input, Modal, Select, Spin, Switch, TreeSelect } from 'antd' import React, { useEffect, useState } from 'react' const { Option } = Select -interface ObsidianExportDialogProps { - title: string - markdown: string - open: boolean - onClose: (success: boolean) => void - obsidianTags: string | null - processingMethod: string | '3' //默认新增(存在就覆盖) -} - interface FileInfo { path: string type: 'folder' | 'markdown' name: string } +interface PopupContainerProps { + title: string + obsidianTags: string | null + processingMethod: string | '3' + open: boolean + resolve: (success: boolean) => void + message?: Message + messages?: Message[] + topic?: Topic +} + // 转换文件信息数组为树形结构 const convertToTreeData = (files: FileInfo[]) => { const treeData: any[] = [ @@ -113,13 +123,15 @@ const convertToTreeData = (files: FileInfo[]) => { return treeData } -const ObsidianExportDialog: React.FC = ({ +const PopupContainer: React.FC = ({ title, - markdown, - open, - onClose, obsidianTags, - processingMethod + processingMethod, + open, + resolve, + message, + messages, + topic }) => { const defaultObsidianVault = store.getState().settings.defaultObsidianVault const [state, setState] = useState({ @@ -130,8 +142,6 @@ const ObsidianExportDialog: React.FC = ({ processingMethod: processingMethod, folder: '' }) - - // 是否手动编辑过标题 const [hasTitleBeenManuallyEdited, setHasTitleBeenManuallyEdited] = useState(false) const [vaults, setVaults] = useState>([]) const [files, setFiles] = useState([]) @@ -139,8 +149,8 @@ const ObsidianExportDialog: React.FC = ({ const [selectedVault, setSelectedVault] = useState('') const [loading, setLoading] = useState(false) const [error, setError] = useState(null) + const [exportReasoning, setExportReasoning] = useState(false) - // 处理文件数据转为树形结构 useEffect(() => { if (files.length > 0) { const treeData = convertToTreeData(files) @@ -157,28 +167,21 @@ const ObsidianExportDialog: React.FC = ({ } }, [files]) - // 组件加载时获取Vault列表 useEffect(() => { const fetchVaults = async () => { try { setLoading(true) setError(null) const vaultsData = await window.obsidian.getVaults() - if (vaultsData.length === 0) { setError(i18n.t('chat.topics.export.obsidian_no_vaults')) setLoading(false) return } - setVaults(vaultsData) - - // 如果没有选择的vault,使用默认值或第一个 const vaultToUse = defaultObsidianVault || vaultsData[0]?.name if (vaultToUse) { setSelectedVault(vaultToUse) - - // 获取选中vault的文件和文件夹 const filesData = await window.obsidian.getFiles(vaultToUse) setFiles(filesData) } @@ -189,11 +192,9 @@ const ObsidianExportDialog: React.FC = ({ setLoading(false) } } - fetchVaults() }, [defaultObsidianVault]) - // 当选择的vault变化时,获取其文件和文件夹 useEffect(() => { if (selectedVault) { const fetchFiles = async () => { @@ -209,7 +210,6 @@ const ObsidianExportDialog: React.FC = ({ setLoading(false) } } - fetchFiles() } }, [selectedVault]) @@ -219,82 +219,71 @@ const ObsidianExportDialog: React.FC = ({ setError(i18n.t('chat.topics.export.obsidian_no_vault_selected')) return } - - //构建content 并复制到粘贴板 + let markdown = '' + if (topic) { + markdown = await topicToMarkdown(topic, exportReasoning) + } else if (messages && messages.length > 0) { + markdown = messagesToMarkdown(messages, exportReasoning) + } else if (message) { + markdown = exportReasoning ? messageToMarkdownWithReasoning(message) : messageToMarkdown(message) + } else { + markdown = '' + } let content = '' if (state.processingMethod !== '3') { content = `\n---\n${markdown}` } else { - content = `--- - \ntitle: ${state.title} - \ncreated: ${state.createdAt} - \nsource: ${state.source} - \ntags: ${state.tags} - \n---\n${markdown}` + content = `---\n\ntitle: ${state.title}\ncreated: ${state.createdAt}\nsource: ${state.source}\ntags: ${state.tags}\n---\n${markdown}` } if (content === '') { window.message.error(i18n.t('chat.topics.export.obsidian_export_failed')) return } - await navigator.clipboard.writeText(content) - - // 导出到Obsidian exportMarkdownToObsidian({ ...state, folder: state.folder, vault: selectedVault }) - - onClose(true) + setOpen(false) + resolve(true) } + const [openState, setOpen] = useState(open) + useEffect(() => { + setOpen(open) + }, [open]) + const handleCancel = () => { - onClose(false) + setOpen(false) + resolve(false) } const handleChange = (key: string, value: any) => { setState((prevState) => ({ ...prevState, [key]: value })) } - - // 处理title输入变化 const handleTitleInputChange = (newTitle: string) => { handleChange('title', newTitle) setHasTitleBeenManuallyEdited(true) } - const handleVaultChange = (value: string) => { setSelectedVault(value) - // 文件夹会通过useEffect自动获取 - setState((prevState) => ({ - ...prevState, - folder: '' - })) + setState((prevState) => ({ ...prevState, folder: '' })) } - - // 处理文件选择 const handleFileSelect = (value: string) => { - // 更新folder值 handleChange('folder', value) - - // 检查是否选中md文件 if (value) { const selectedFile = files.find((file) => file.path === value) if (selectedFile) { if (selectedFile.type === 'markdown') { - // 如果是md文件,自动设置标题为文件名并设置处理方式为1(追加) const fileName = selectedFile.name const titleWithoutExt = fileName.endsWith('.md') ? fileName.substring(0, fileName.length - 3) : fileName handleChange('title', titleWithoutExt) - // 重置手动编辑标记,因为这是非用户设置的title setHasTitleBeenManuallyEdited(false) handleChange('processingMethod', '1') } else { - // 如果是文件夹,自动设置标题为话题名并设置处理方式为3(新建) handleChange('processingMethod', '3') - // 仅当用户未手动编辑过 title 时,才将其重置为 props.title if (!hasTitleBeenManuallyEdited) { - // title 是 props.title handleChange('title', title) } } @@ -305,7 +294,7 @@ const ObsidianExportDialog: React.FC = ({ return ( = ({ type: 'primary', disabled: vaults.length === 0 || loading || !!error }} - okText={i18n.t('chat.topics.export.obsidian_btn')}> + okText={i18n.t('chat.topics.export.obsidian_btn')} + afterClose={() => setOpen(open)}> {error && } -
= ({ placeholder={i18n.t('chat.topics.export.obsidian_title_placeholder')} /> - {vaults.length > 0 ? ( = ({ placeholder={i18n.t('chat.topics.export.obsidian_source_placeholder')} /> - + + +
) } -export default ObsidianExportDialog +export { PopupContainer } diff --git a/src/renderer/src/components/Popups/ObsidianExportPopup.tsx b/src/renderer/src/components/Popups/ObsidianExportPopup.tsx index 23a7da2dcd..49dc320c7c 100644 --- a/src/renderer/src/components/Popups/ObsidianExportPopup.tsx +++ b/src/renderer/src/components/Popups/ObsidianExportPopup.tsx @@ -1,44 +1,38 @@ -import ObsidianExportDialog from '@renderer/components/ObsidianExportDialog' -import { createRoot } from 'react-dom/client' +import { PopupContainer } from '@renderer/components/ObsidianExportDialog' +import { TopView } from '@renderer/components/TopView' +import type { Topic } from '@renderer/types' +import type { Message } from '@renderer/types/newMessage' interface ObsidianExportOptions { title: string - markdown: string - processingMethod: string | '3' // 默认新增(存在就覆盖) + processingMethod: string | '3' + topic?: Topic + message?: Message + messages?: Message[] } -/** - * 配置Obsidian 笔记属性弹窗 - * @param options.title 标题 - * @param options.markdown markdown内容 - * @param options.processingMethod 处理方式 - * @returns - */ -const showObsidianExportDialog = async (options: ObsidianExportOptions): Promise => { - return new Promise((resolve) => { - const div = document.createElement('div') - document.body.appendChild(div) - const root = createRoot(div) - - const handleClose = (success: boolean) => { - root.unmount() - document.body.removeChild(div) - resolve(success) - } - // 不再从store中获取tag配置 - root.render( - - ) - }) -} - -export default { - show: showObsidianExportDialog +export default class ObsidianExportPopup { + static hide() { + TopView.hide('ObsidianExportPopup') + } + static show(options: ObsidianExportOptions): Promise { + return new Promise((resolve) => { + TopView.show( + { + resolve(v) + ObsidianExportPopup.hide() + }} + />, + 'ObsidianExportPopup' + ) + }) + } } diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index e6afa7fb4f..593ed6f95b 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -322,6 +322,7 @@ "translate": "Translate", "topics.export.siyuan": "Export to Siyuan Note", "topics.export.wait_for_title_naming": "Generating title...", + "topics.export.obsidian_reasoning": "Include Reasoning Chain", "topics.export.title_naming_success": "Title generated successfully", "topics.export.title_naming_failed": "Failed to generate title, using default title", "input.translating": "Translating...", @@ -1104,7 +1105,9 @@ "token": "Joplin Authorization Token", "token_placeholder": "Joplin Authorization Token", "url": "Joplin Web Clipper Service URL", - "url_placeholder": "http://127.0.0.1:41184/" + "url_placeholder": "http://127.0.0.1:41184/", + "export_reasoning.title": "Include Reasoning Chain in Export", + "export_reasoning.help": "When enabled, the exported content will include the reasoning chain (thought process) generated by the assistant." }, "markdown_export.force_dollar_math.help": "When enabled, $$ will be forcibly used to mark LaTeX formulas when exporting to Markdown. Note: This option also affects all export methods through Markdown, such as Notion, Yuque, etc.", "markdown_export.force_dollar_math.title": "Force $$ for LaTeX formulas", @@ -1113,12 +1116,14 @@ "markdown_export.path_placeholder": "Export Path", "markdown_export.select": "Select", "markdown_export.title": "Markdown Export", + "markdown_export.show_model_name.title": "Use Model Name on Export", + "markdown_export.show_model_name.help": "When enabled, the topic-naming model will be used to create titles for exported messages. Note: This option also affects all export methods through Markdown, such as Notion, Yuque, etc.", + "markdown_export.show_model_provider.title": "Show Model Provider", + "markdown_export.show_model_provider.help": "Display the model provider (e.g., OpenAI, Gemini) when exporting to Markdown", "minute_interval_one": "{{count}} minute", "minute_interval_other": "{{count}} minutes", "notion.api_key": "Notion API Key", "notion.api_key_placeholder": "Enter Notion API Key", - "notion.auto_split": "Auto split when exporting", - "notion.auto_split_tip": "Automatically split pages when exporting long topics to Notion", "notion.check": { "button": "Check", "empty_api_key": "API key is not configured", @@ -1132,10 +1137,9 @@ "notion.help": "Notion Configuration Documentation", "notion.page_name_key": "Page Title Field Name", "notion.page_name_key_placeholder": "Enter page title field name, default is Name", - "notion.split_size": "Split size", - "notion.split_size_help": "Recommended: 90 for Free plan, 24990 for Plus plan, default is 90", - "notion.split_size_placeholder": "Enter block limit per page (default 90)", - "notion.title": "Notion Configuration", + "notion.title": "Notion Settings", + "notion.export_reasoning.title": "Include Reasoning Chain in Export", + "notion.export_reasoning.help": "When enabled, exported content will include reasoning chain (thought process).", "title": "Data Settings", "webdav": { "autoSync": "Auto Backup", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 5f5a1d11fd..c1341a90d3 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -322,6 +322,7 @@ "translate": "翻訳", "topics.export.siyuan": "思源笔记にエクスポート", "topics.export.wait_for_title_naming": "タイトルを生成中...", + "topics.export.obsidian_reasoning": "思考過程を含める", "topics.export.title_naming_success": "タイトルの生成に成功しました", "topics.export.title_naming_failed": "タイトルの生成に失敗しました。デフォルトのタイトルを使用します", "input.translating": "翻訳中...", @@ -1102,7 +1103,9 @@ "token": "Joplin 認証トークン", "token_placeholder": "Joplin 認証トークンを入力してください", "url": "Joplin 剪輯服務 URL", - "url_placeholder": "http://127.0.0.1:41184/" + "url_placeholder": "http://127.0.0.1:41184/", + "export_reasoning.title": "エクスポート時に思考過程を含める", + "export_reasoning.help": "有効にすると、エクスポートされる内容にアシスタントが生成した思考過程(リースニングチェーン)が含まれます。" }, "markdown_export.force_dollar_math.help": "有効にすると、Markdownにエクスポートする際にLaTeX数式を$$で強制的にマークします。注意:この設定はNotion、Yuqueなど、Markdownを通じたすべてのエクスポート方法にも影響します。", "markdown_export.force_dollar_math.title": "LaTeX数式に$$を強制使用", @@ -1111,29 +1114,32 @@ "markdown_export.path_placeholder": "エクスポートパス", "markdown_export.select": "選択", "markdown_export.title": "Markdown エクスポート", + "markdown_export.show_model_name.title": "エクスポート時にモデル名を使用", + "markdown_export.show_model_name.help": "有効にすると、トピック命名モデルがエクスポートされたメッセージのタイトル作成に使用されます。注意:この設定はNotion、Yuqueなど、Markdownを通じたすべてのエクスポート方法にも影響します。", + "markdown_export.show_model_provider.title": "モデルプロバイダーを表示", + "markdown_export.show_model_provider.help": "Markdownエクスポート時にモデルプロバイダー(例:OpenAI、Geminiなど)を表示します。", "minute_interval_one": "{{count}} 分", "minute_interval_other": "{{count}} 分", - "notion.api_key": "Notion APIキー", - "notion.api_key_placeholder": "Notion APIキーを入力してください", - "notion.auto_split": "ダイアログをエクスポートすると自動ページ分割", - "notion.auto_split_tip": "ダイアログが長い場合、Notionに自動的にページ分割してエクスポートします", - "notion.check": { - "button": "確認", - "empty_api_key": "Api_keyが設定されていません", - "empty_database_id": "Database_idが設定されていません", - "error": "接続エラー、ネットワーク設定とApi_keyとDatabase_idを確認してください", - "fail": "接続エラー、ネットワーク設定とApi_keyとDatabase_idを確認してください", - "success": "接続に成功しました。" + "notion": { + "api_key": "Notion APIキー", + "api_key_placeholder": "Notion APIキーを入力してください", + "check": { + "button": "確認", + "empty_api_key": "Api_keyが設定されていません", + "empty_database_id": "Database_idが設定されていません", + "error": "接続エラー、ネットワーク設定とApi_keyとDatabase_idを確認してください", + "fail": "接続エラー、ネットワーク設定とApi_keyとDatabase_idを確認してください", + "success": "接続に成功しました。" + }, + "database_id": "Notion データベースID", + "database_id_placeholder": "Notion データベースIDを入力してください", + "help": "Notion 設定ドキュメント", + "page_name_key": "ページタイトルフィールド名", + "page_name_key_placeholder": "ページタイトルフィールド名を入力してください。デフォルトは Name です", + "title": "Notion 設定", + "export_reasoning.title": "エクスポート時に思考チェーンを含める", + "export_reasoning.help": "有効にすると、Notionにエクスポートする際に思考チェーンの内容が含まれます。" }, - "notion.database_id": "Notion データベースID", - "notion.database_id_placeholder": "Notion データベースIDを入力してください", - "notion.help": "Notion 設定ドキュメント", - "notion.page_name_key": "ページタイトルフィールド名", - "notion.page_name_key_placeholder": "ページタイトルフィールド名を入力してください。デフォルトは Name です", - "notion.split_size": "自動ページ分割サイズ", - "notion.split_size_help": "Notion無料版ユーザーは90、有料版ユーザーは24990、デフォルトは90", - "notion.split_size_placeholder": "ページごとのブロック数制限を入力してください(デフォルト90)", - "notion.title": "Notion 設定", "title": "データ設定", "webdav": { "autoSync": "自動バックアップ", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index a26b78ba3a..019726f8d3 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -322,6 +322,7 @@ "translate": "Перевести", "topics.export.siyuan": "Экспорт в Siyuan Note", "topics.export.wait_for_title_naming": "Создание заголовка...", + "topics.export.obsidian_reasoning": "Включить цепочку рассуждений", "topics.export.title_naming_success": "Заголовок успешно создан", "topics.export.title_naming_failed": "Не удалось создать заголовок, используется заголовок по умолчанию", "input.translating": "Перевод...", @@ -1102,7 +1103,9 @@ "token": "Токен Joplin", "token_placeholder": "Введите токен Joplin", "url": "URL Joplin", - "url_placeholder": "http://127.0.0.1:41184/" + "url_placeholder": "http://127.0.0.1:41184/", + "export_reasoning.title": "Включить цепочку рассуждений при экспорте", + "export_reasoning.help": "Если включено, экспортируемый контент будет содержать цепочку рассуждений, сгенерированную ассистентом." }, "markdown_export.force_dollar_math.help": "Если включено, при экспорте в Markdown для обозначения формул LaTeX будет принудительно использоваться $$. Примечание: Эта опция также влияет на все методы экспорта через Markdown, такие как Notion, Yuque и т.д.", "markdown_export.force_dollar_math.title": "Принудительно использовать $$ для формул LaTeX", @@ -1111,12 +1114,14 @@ "markdown_export.path_placeholder": "Путь экспорта", "markdown_export.select": "Выбрать", "markdown_export.title": "Экспорт в Markdown", + "markdown_export.show_model_name.title": "Использовать имя модели при экспорте", + "markdown_export.show_model_name.help": "Если включено, для создания заголовков экспортируемых сообщений будет использоваться модель именования темы. Примечание: Эта опция также влияет на все методы экспорта через Markdown, такие как Notion, Yuque и т.д.", + "markdown_export.show_model_provider.title": "Показать поставщика модели", + "markdown_export.show_model_provider.help": "Показывать поставщика модели (например, OpenAI, Gemini) при экспорте в Markdown", "minute_interval_one": "{{count}} минута", "minute_interval_other": "{{count}} минут", "notion.api_key": "Ключ API Notion", "notion.api_key_placeholder": "Введите ключ API Notion", - "notion.auto_split": "Автоматическое разбиение на страницы при экспорте диалога", - "notion.auto_split_tip": "Автоматическое разбиение на страницы при экспорте в Notion, если тема слишком длинная", "notion.check": { "button": "Проверить", "empty_api_key": "Не настроен API key", @@ -1130,10 +1135,9 @@ "notion.help": "Документация по настройке Notion", "notion.page_name_key": "Название поля заголовка страницы", "notion.page_name_key_placeholder": "Введите название поля заголовка страницы, по умолчанию Name", - "notion.split_size": "Размер автоматического разбиения", - "notion.split_size_help": "Рекомендуется 90 для пользователей бесплатной версии Notion, 24990 для пользователей премиум-версии, значение по умолчанию — 90", - "notion.split_size_placeholder": "Введите ограничение количества блоков на странице (по умолчанию 90)", "notion.title": "Настройки Notion", + "notion.export_reasoning.title": "Включить цепочку рассуждений при экспорте", + "notion.export_reasoning.help": "При включении, содержимое цепочки рассуждений будет включено при экспорте в Notion.", "title": "Настройки данных", "webdav": { "autoSync": "Автоматическое резервное копирование", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index a344549317..0d04e66e72 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -325,6 +325,7 @@ "topics.export.obsidian_no_vault_selected": "请先选择一个保管库", "topics.export.obsidian_select_vault_first": "请先选择保管库", "topics.export.obsidian_root_directory": "根目录", + "topics.export.obsidian_reasoning": "导出思维链", "topics.export.title": "导出", "topics.export.word": "导出为 Word", "topics.export.yuque": "导出到语雀", @@ -654,7 +655,7 @@ "group.delete.content": "删除分组消息会删除用户提问和所有助手的回答", "group.delete.title": "删除分组消息", "ignore.knowledge.base": "联网模式开启,忽略知识库", - "info.notion.block_reach_limit": "对话过长,正在分页导出到Notion", + "info.notion.block_reach_limit": "对话过长,正在分段导出到Notion", "loading.notion.exporting_progress": "正在导出到Notion ({{current}}/{{total}})...", "loading.notion.preparing": "正在准备导出到Notion...", "mention.title": "切换模型回答", @@ -1104,7 +1105,9 @@ "token": "Joplin 授权令牌", "token_placeholder": "请输入 Joplin 授权令牌", "url": "Joplin 剪裁服务监听 URL", - "url_placeholder": "http://127.0.0.1:41184/" + "url_placeholder": "http://127.0.0.1:41184/", + "export_reasoning.title": "导出时包含思维链", + "export_reasoning.help": "开启后,导出到Joplin时会包含思维链内容。" }, "markdown_export.force_dollar_math.help": "开启后,导出Markdown时会将强制使用$$来标记LaTeX公式。注意:该项也会影响所有通过Markdown导出的方式,如Notion、语雀等", "markdown_export.force_dollar_math.title": "强制使用$$来标记LaTeX公式", @@ -1113,14 +1116,16 @@ "markdown_export.path_placeholder": "导出路径", "markdown_export.select": "选择", "markdown_export.title": "Markdown 导出", + "markdown_export.show_model_name.title": "导出时使用模型名称", + "markdown_export.show_model_name.help": "开启后,使用话题命名模型为导出的消息创建标题。注意:该项也会影响所有通过Markdown导出的方式,如Notion、语雀等。", + "markdown_export.show_model_provider.title": "显示模型供应商", + "markdown_export.show_model_provider.help": "在导出Markdown时显示模型供应商,如OpenAI、Gemini等", "message_title.use_topic_naming.title": "使用话题命名模型为导出的消息创建标题", "message_title.use_topic_naming.help": "开启后,使用话题命名模型为导出的消息创建标题。该项也会影响所有通过Markdown导出的方式", "minute_interval_one": "{{count}} 分钟", "minute_interval_other": "{{count}} 分钟", "notion.api_key": "Notion 密钥", "notion.api_key_placeholder": "请输入Notion 密钥", - "notion.auto_split": "导出对话时自动分页", - "notion.auto_split_tip": "当要导出的话题过长时自动分页导出到Notion", "notion.check": { "button": "检测", "empty_api_key": "未配置 API key", @@ -1134,10 +1139,9 @@ "notion.help": "Notion 配置文档", "notion.page_name_key": "页面标题字段名", "notion.page_name_key_placeholder": "请输入页面标题字段名,默认为 Name", - "notion.split_size": "自动分页大小", - "notion.split_size_help": "Notion免费版用户建议设置为90,高级版用户建议设置为24990,默认值为90", - "notion.split_size_placeholder": "请输入每页块数限制(默认90)", - "notion.title": "Notion 配置", + "notion.title": "Notion 设置", + "notion.export_reasoning.title": "导出时包含思维链", + "notion.export_reasoning.help": "开启后,导出到Notion时会包含思维链内容。", "title": "数据设置", "webdav": { "autoSync": "自动备份", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index ce1a1a5403..73867953de 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -322,6 +322,7 @@ "translate": "翻譯", "topics.export.siyuan": "匯出到思源筆記", "topics.export.wait_for_title_naming": "正在生成標題...", + "topics.export.obsidian_reasoning": "包含思維鏈", "topics.export.title_naming_success": "標題生成成功", "topics.export.title_naming_failed": "標題生成失敗,使用預設標題", "input.translating": "翻譯中...", @@ -1104,7 +1105,9 @@ "token": "Joplin 授權Token", "token_placeholder": "請輸入 Joplin 授權Token", "url": "Joplin 剪輯服務 URL", - "url_placeholder": "http://127.0.0.1:41184/" + "url_placeholder": "http://127.0.0.1:41184/", + "export_reasoning.title": "匯出時包含思維鏈", + "export_reasoning.help": "啟用後,匯出內容將包含助手生成的思維鏈(思考過程)。" }, "markdown_export.force_dollar_math.help": "開啟後,匯出Markdown時會強制使用$$來標記LaTeX公式。注意:該項也會影響所有透過Markdown匯出的方式,如Notion、語雀等", "markdown_export.force_dollar_math.title": "LaTeX公式強制使用$$", @@ -1113,12 +1116,14 @@ "markdown_export.path_placeholder": "匯出路徑", "markdown_export.select": "選擇", "markdown_export.title": "Markdown 匯出", + "markdown_export.show_model_name.title": "匯出時使用模型名稱", + "markdown_export.show_model_name.help": "啟用後,將以主題命名模型為匯出的訊息建立標題。注意:該項也會影響所有透過Markdown匯出的方式,如Notion、語雀等。", + "markdown_export.show_model_provider.title": "顯示模型供應商", + "markdown_export.show_model_provider.help": "在匯出Markdown時顯示模型供應商,如OpenAI、Gemini等", "minute_interval_one": "{{count}} 分鐘", "minute_interval_other": "{{count}} 分鐘", "notion.api_key": "Notion 金鑰", "notion.api_key_placeholder": "請輸入 Notion 金鑰", - "notion.auto_split": "匯出對話時自動分頁", - "notion.auto_split_tip": "當要匯出的話題過長時自動分頁匯出到 Notion", "notion.check": { "button": "檢查", "empty_api_key": "未設定 API key", @@ -1132,10 +1137,9 @@ "notion.help": "Notion 設定文件", "notion.page_name_key": "頁面標題欄位名稱", "notion.page_name_key_placeholder": "請輸入頁面標題欄位名稱,預設為 Name", - "notion.split_size": "自動分頁大小", - "notion.split_size_help": "Notion 免費版使用者建議設定為 90,進階版使用者建議設定為 24990,預設值為 90", - "notion.split_size_placeholder": "請輸入每頁塊數限制 (預設 90)", "notion.title": "Notion 設定", + "notion.export_reasoning.title": "匯出時包含思維鏈", + "notion.export_reasoning.help": "啟用後,匯出到Notion時會包含思維鏈內容。", "title": "資料設定", "webdav": { "autoSync": "自動備份", diff --git a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx index 7df542588b..0073eee791 100644 --- a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx +++ b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx @@ -16,10 +16,10 @@ import type { Message } from '@renderer/types/newMessage' import { captureScrollableDivAsBlob, captureScrollableDivAsDataURL } from '@renderer/utils' import { exportMarkdownToJoplin, - exportMarkdownToNotion, exportMarkdownToSiyuan, exportMarkdownToYuque, exportMessageAsMarkdown, + exportMessageToNotion, messageToMarkdown } from '@renderer/utils/export' // import { withMessageThought } from '@renderer/utils/formats' @@ -244,7 +244,7 @@ const MessageMenubar: FC = (props) => { onClick: async () => { const title = await getMessageTitle(message) const markdown = messageToMarkdown(message) - exportMarkdownToNotion(title, markdown) + exportMessageToNotion(title, markdown, message) } }, exportMenuOptions.yuque && { @@ -260,9 +260,8 @@ const MessageMenubar: FC = (props) => { label: t('chat.topics.export.obsidian'), key: 'obsidian', onClick: async () => { - const markdown = messageToMarkdown(message) const title = topic.name?.replace(/\//g, '_') || 'Untitled' - await ObsidianExportPopup.show({ title, markdown, processingMethod: '1' }) + await ObsidianExportPopup.show({ title, message, processingMethod: '1' }) } }, exportMenuOptions.joplin && { @@ -270,8 +269,7 @@ const MessageMenubar: FC = (props) => { key: 'joplin', onClick: async () => { const title = await getMessageTitle(message) - const markdown = messageToMarkdown(message) - exportMarkdownToJoplin(title, markdown) + exportMarkdownToJoplin(title, message) } }, exportMenuOptions.siyuan && { diff --git a/src/renderer/src/pages/home/Tabs/TopicsTab.tsx b/src/renderer/src/pages/home/Tabs/TopicsTab.tsx index 490cfb3c29..34652e72b8 100644 --- a/src/renderer/src/pages/home/Tabs/TopicsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/TopicsTab.tsx @@ -312,16 +312,15 @@ const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic label: t('chat.topics.export.obsidian'), key: 'obsidian', onClick: async () => { - const markdown = await topicToMarkdown(topic) - await ObsidianExportPopup.show({ title: topic.name, markdown, processingMethod: '3' }) + await ObsidianExportPopup.show({ title: topic.name, topic, processingMethod: '3' }) } }, exportMenuOptions.joplin && { label: t('chat.topics.export.joplin'), key: 'joplin', onClick: async () => { - const markdown = await topicToMarkdown(topic) - exportMarkdownToJoplin(topic.name, markdown) + const topicMessages = await TopicManager.getTopicMessages(topic.id) + exportMarkdownToJoplin(topic.name, topicMessages) } }, exportMenuOptions.siyuan && { diff --git a/src/renderer/src/pages/settings/DataSettings/JoplinSettings.tsx b/src/renderer/src/pages/settings/DataSettings/JoplinSettings.tsx index 322fdd8836..3574c808d1 100644 --- a/src/renderer/src/pages/settings/DataSettings/JoplinSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/JoplinSettings.tsx @@ -3,14 +3,14 @@ import { HStack } from '@renderer/components/Layout' import { useTheme } from '@renderer/context/ThemeProvider' import { useMinappPopup } from '@renderer/hooks/useMinappPopup' import { RootState, useAppDispatch } from '@renderer/store' -import { setJoplinToken, setJoplinUrl } from '@renderer/store/settings' -import { Button, Tooltip } from 'antd' +import { setJoplinExportReasoning, setJoplinToken, setJoplinUrl } from '@renderer/store/settings' +import { Button, Switch, Tooltip } from 'antd' import Input from 'antd/es/input/Input' import { FC } from 'react' import { useTranslation } from 'react-i18next' import { useSelector } from 'react-redux' -import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..' +import { SettingDivider, SettingGroup, SettingHelpText, SettingRow, SettingRowTitle, SettingTitle } from '..' const JoplinSettings: FC = () => { const { t } = useTranslation() @@ -20,6 +20,7 @@ const JoplinSettings: FC = () => { const joplinToken = useSelector((state: RootState) => state.settings.joplinToken) const joplinUrl = useSelector((state: RootState) => state.settings.joplinUrl) + const joplinExportReasoning = useSelector((state: RootState) => state.settings.joplinExportReasoning) const handleJoplinTokenChange = (e: React.ChangeEvent) => { dispatch(setJoplinToken(e.target.value)) @@ -72,6 +73,10 @@ const JoplinSettings: FC = () => { }) } + const handleToggleJoplinExportReasoning = (checked: boolean) => { + dispatch(setJoplinExportReasoning(checked)) + } + return ( {t('settings.data.joplin.title')} @@ -111,6 +116,14 @@ const JoplinSettings: FC = () => { + + + {t('settings.data.joplin.export_reasoning.title')} + + + + {t('settings.data.joplin.export_reasoning.help')} + ) } diff --git a/src/renderer/src/pages/settings/DataSettings/MarkdownExportSettings.tsx b/src/renderer/src/pages/settings/DataSettings/MarkdownExportSettings.tsx index e4c8169279..2ed7cb7f47 100644 --- a/src/renderer/src/pages/settings/DataSettings/MarkdownExportSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/MarkdownExportSettings.tsx @@ -5,6 +5,8 @@ import { RootState, useAppDispatch } from '@renderer/store' import { setForceDollarMathInMarkdown, setmarkdownExportPath, + setShowModelNameInMarkdown, + setShowModelProviderInMarkdown, setUseTopicNamingForMessageTitle } from '@renderer/store/settings' import { Button, Switch } from 'antd' @@ -23,6 +25,8 @@ const MarkdownExportSettings: FC = () => { const markdownExportPath = useSelector((state: RootState) => state.settings.markdownExportPath) const forceDollarMathInMarkdown = useSelector((state: RootState) => state.settings.forceDollarMathInMarkdown) const useTopicNamingForMessageTitle = useSelector((state: RootState) => state.settings.useTopicNamingForMessageTitle) + const showModelNameInExport = useSelector((state: RootState) => state.settings.showModelNameInMarkdown) + const showModelProviderInMarkdown = useSelector((state: RootState) => state.settings.showModelProviderInMarkdown) const handleSelectFolder = async () => { const path = await window.api.file.selectFolder() @@ -43,6 +47,14 @@ const MarkdownExportSettings: FC = () => { dispatch(setUseTopicNamingForMessageTitle(checked)) } + const handleToggleShowModelName = (checked: boolean) => { + dispatch(setShowModelNameInMarkdown(checked)) + } + + const handleToggleShowModelProvider = (checked: boolean) => { + dispatch(setShowModelProviderInMarkdown(checked)) + } + return ( {t('settings.data.markdown_export.title')} @@ -86,6 +98,22 @@ const MarkdownExportSettings: FC = () => { {t('settings.data.message_title.use_topic_naming.help')} + + + {t('settings.data.markdown_export.show_model_name.title')} + + + + {t('settings.data.markdown_export.show_model_name.help')} + + + + {t('settings.data.markdown_export.show_model_provider.title')} + + + + {t('settings.data.markdown_export.show_model_provider.help')} + ) } diff --git a/src/renderer/src/pages/settings/DataSettings/NotionSettings.tsx b/src/renderer/src/pages/settings/DataSettings/NotionSettings.tsx index de670ad438..719a2363d7 100644 --- a/src/renderer/src/pages/settings/DataSettings/NotionSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/NotionSettings.tsx @@ -6,12 +6,11 @@ import { useMinappPopup } from '@renderer/hooks/useMinappPopup' import { RootState, useAppDispatch } from '@renderer/store' import { setNotionApiKey, - setNotionAutoSplit, setNotionDatabaseID, - setNotionPageNameKey, - setNotionSplitSize + setNotionExportReasoning, + setNotionPageNameKey } from '@renderer/store/settings' -import { Button, InputNumber, Switch, Tooltip } from 'antd' +import { Button, Switch, Tooltip } from 'antd' import Input from 'antd/es/input/Input' import { FC } from 'react' import { useTranslation } from 'react-i18next' @@ -27,8 +26,7 @@ const NotionSettings: FC = () => { const notionApiKey = useSelector((state: RootState) => state.settings.notionApiKey) const notionDatabaseID = useSelector((state: RootState) => state.settings.notionDatabaseID) const notionPageNameKey = useSelector((state: RootState) => state.settings.notionPageNameKey) - const notionAutoSplit = useSelector((state: RootState) => state.settings.notionAutoSplit) - const notionSplitSize = useSelector((state: RootState) => state.settings.notionSplitSize) + const notionExportReasoning = useSelector((state: RootState) => state.settings.notionExportReasoning) const handleNotionTokenChange = (e: React.ChangeEvent) => { dispatch(setNotionApiKey(e.target.value)) @@ -76,14 +74,8 @@ const NotionSettings: FC = () => { }) } - const handleNotionAutoSplitChange = (checked: boolean) => { - dispatch(setNotionAutoSplit(checked)) - } - - const handleNotionSplitSizeChange = (value: number | null) => { - if (value !== null) { - dispatch(setNotionSplitSize(value)) - } + const handleNotionExportReasoningChange = (checked: boolean) => { + dispatch(setNotionExportReasoning(checked)) } return ( @@ -140,38 +132,14 @@ const NotionSettings: FC = () => { - {/* 添加分割线 */} + - - - - {t('settings.data.notion.auto_split')} - - - - - + {t('settings.data.notion.export_reasoning.title')} + + + + {t('settings.data.notion.export_reasoning.help')} - {notionAutoSplit && ( - <> - - - {t('settings.data.notion.split_size')} - - - - {t('settings.data.notion.split_size_help')} - - - )} ) } diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index 70e91e56aa..2f5e627545 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -129,14 +129,16 @@ export interface SettingsState { markdownExportPath: string | null forceDollarMathInMarkdown: boolean useTopicNamingForMessageTitle: boolean + showModelNameInMarkdown: boolean + showModelProviderInMarkdown: boolean thoughtAutoCollapse: boolean - notionAutoSplit: boolean - notionSplitSize: number + notionExportReasoning: boolean yuqueToken: string | null yuqueUrl: string | null yuqueRepoId: string | null joplinToken: string | null joplinUrl: string | null + joplinExportReasoning: boolean defaultObsidianVault: string | null defaultAgent: string | null // 思源笔记配置 @@ -271,14 +273,16 @@ export const initialState: SettingsState = { markdownExportPath: null, forceDollarMathInMarkdown: false, useTopicNamingForMessageTitle: false, + showModelNameInMarkdown: false, + showModelProviderInMarkdown: false, thoughtAutoCollapse: true, - notionAutoSplit: false, - notionSplitSize: 90, + notionExportReasoning: false, yuqueToken: '', yuqueUrl: '', yuqueRepoId: '', joplinToken: '', joplinUrl: '', + joplinExportReasoning: false, defaultObsidianVault: null, defaultAgent: null, siyuanApiUrl: null, @@ -580,14 +584,17 @@ const settingsSlice = createSlice({ setUseTopicNamingForMessageTitle: (state, action: PayloadAction) => { state.useTopicNamingForMessageTitle = action.payload }, + setShowModelNameInMarkdown: (state, action: PayloadAction) => { + state.showModelNameInMarkdown = action.payload + }, + setShowModelProviderInMarkdown: (state, action: PayloadAction) => { + state.showModelProviderInMarkdown = action.payload + }, setThoughtAutoCollapse: (state, action: PayloadAction) => { state.thoughtAutoCollapse = action.payload }, - setNotionAutoSplit: (state, action: PayloadAction) => { - state.notionAutoSplit = action.payload - }, - setNotionSplitSize: (state, action: PayloadAction) => { - state.notionSplitSize = action.payload + setNotionExportReasoning: (state, action: PayloadAction) => { + state.notionExportReasoning = action.payload }, setYuqueToken: (state, action: PayloadAction) => { state.yuqueToken = action.payload @@ -604,6 +611,9 @@ const settingsSlice = createSlice({ setJoplinUrl: (state, action: PayloadAction) => { state.joplinUrl = action.payload }, + setJoplinExportReasoning: (state, action: PayloadAction) => { + state.joplinExportReasoning = action.payload + }, setMessageNavigation: (state, action: PayloadAction<'none' | 'buttons' | 'anchor'>) => { state.messageNavigation = action.payload }, @@ -665,6 +675,8 @@ const settingsSlice = createSlice({ }) export const { + setShowModelNameInMarkdown, + setShowModelProviderInMarkdown, setShowAssistants, toggleShowAssistants, setShowTopics, @@ -737,13 +749,13 @@ export const { setForceDollarMathInMarkdown, setUseTopicNamingForMessageTitle, setThoughtAutoCollapse, - setNotionAutoSplit, - setNotionSplitSize, + setNotionExportReasoning, setYuqueToken, setYuqueRepoId, setYuqueUrl, setJoplinToken, setJoplinUrl, + setJoplinExportReasoning, setMessageNavigation, setDefaultObsidianVault, setDefaultAgent, diff --git a/src/renderer/src/utils/export.ts b/src/renderer/src/utils/export.ts index ef52156fb3..0c858647bf 100644 --- a/src/renderer/src/utils/export.ts +++ b/src/renderer/src/utils/export.ts @@ -11,7 +11,6 @@ import { convertMathFormula } from '@renderer/utils/markdown' import { getCitationContent, getMainTextContent, getThinkingContent } from '@renderer/utils/messageUtils/find' import { markdownToBlocks } from '@tryfabric/martian' import dayjs from 'dayjs' -//TODO: 添加对思考内容的支持 /** * 从消息内容中提取标题,限制长度并处理换行和标点符号。用于导出功能。 @@ -43,9 +42,35 @@ export function getTitleFromString(str: string, length: number = 80) { return title } +const getRoleText = (role: string, modelName?: string, modelProvider?: string) => { + const { showModelNameInMarkdown, showModelProviderInMarkdown } = store.getState().settings + + if (role === 'user') { + return '🧑‍💻 User' + } else if (role === 'system') { + return '🤖 System' + } else { + let assistantText = '🤖 ' + if (showModelNameInMarkdown && modelName) { + assistantText += `${modelName}` + if (showModelProviderInMarkdown && modelProvider) { + const providerDisplayName = i18n.t(`provider.${modelProvider}`, { defaultValue: modelProvider }) + assistantText += ` | ${providerDisplayName}` + return assistantText + } + return assistantText + } else if (showModelProviderInMarkdown && modelProvider) { + const providerDisplayName = i18n.t(`provider.${modelProvider}`, { defaultValue: modelProvider }) + assistantText += `Assistant | ${providerDisplayName}` + return assistantText + } + return assistantText + 'Assistant' + } +} + const createBaseMarkdown = (message: Message, includeReasoning: boolean = false) => { const { forceDollarMathInMarkdown } = store.getState().settings - const roleText = message.role === 'user' ? '🧑‍💻 User' : '🤖 Assistant' + const roleText = getRoleText(message.role, message.model?.name, message.model?.provider) const titleSection = `### ${roleText}` let reasoningSection = '' @@ -57,15 +82,22 @@ const createBaseMarkdown = (message: Message, includeReasoning: boolean = false) } else if (reasoningContent.startsWith('')) { reasoningContent = reasoningContent.substring(7) } - reasoningContent = reasoningContent.replace(/\n/g, '
') - + reasoningContent = reasoningContent + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(/\n/g, '
') if (forceDollarMathInMarkdown) { reasoningContent = convertMathFormula(reasoningContent) } - reasoningSection = `
- ${i18n.t('common.reasoning_content')}
+ reasoningSection = `
+
+ ${i18n.t('common.reasoning_content')} ${reasoningContent} -
` +
+` } } @@ -81,7 +113,6 @@ export const messageToMarkdown = (message: Message) => { return [titleSection, '', contentSection, citation].join('\n\n') } -// 保留接口用于其它导出方法使用 export const messageToMarkdownWithReasoning = (message: Message) => { const { titleSection, reasoningSection, contentSection, citation } = createBaseMarkdown(message, true) return [titleSection, '', reasoningSection + contentSection, citation].join('\n\n') @@ -167,14 +198,10 @@ export const exportMessageAsMarkdown = async (message: Message, exportReasoning? const convertMarkdownToNotionBlocks = async (markdown: string) => { return markdownToBlocks(markdown) } -// 修改 splitNotionBlocks 函数 -const splitNotionBlocks = (blocks: any[]) => { - const { notionAutoSplit, notionSplitSize } = store.getState().settings - // 如果未开启自动分页,返回单页 - if (!notionAutoSplit) { - return [blocks] - } +const splitNotionBlocks = (blocks: any[]) => { + // Notion API限制单次传输100块 + const notionSplitSize = 95 const pages: any[][] = [] let currentPage: any[] = [] @@ -195,25 +222,68 @@ const splitNotionBlocks = (blocks: any[]) => { return pages } -export const exportTopicToNotion = async (topic: Topic) => { +const convertThinkingToNotionBlocks = async (thinkingContent: string): Promise => { + if (!thinkingContent.trim()) { + return [] + } + + const thinkingBlocks = [ + { + object: 'block', + type: 'toggle', + toggle: { + rich_text: [ + { + type: 'text', + text: { + content: '🤔 ' + i18n.t('common.reasoning_content') + }, + annotations: { + bold: true + } + } + ], + children: [ + { + object: 'block', + type: 'paragraph', + paragraph: { + rich_text: [ + { + type: 'text', + text: { + content: thinkingContent + } + } + ] + } + } + ] + } + } + ] + + return thinkingBlocks +} + +const executeNotionExport = async (title: string, allBlocks: any[]): Promise => { const { isExporting } = store.getState().runtime.export if (isExporting) { window.message.warning({ content: i18n.t('message.warn.notion.exporting'), key: 'notion-exporting' }) - return + return null } - setExportState({ - isExporting: true - }) + + setExportState({ isExporting: true }) + const { notionDatabaseID, notionApiKey } = store.getState().settings if (!notionApiKey || !notionDatabaseID) { window.message.error({ content: i18n.t('message.error.notion.no_api_key'), key: 'notion-no-apikey-error' }) - return + setExportState({ isExporting: false }) + return null } try { const notion = new Client({ auth: notionApiKey }) - const markdown = await topicToMarkdown(topic) - const allBlocks = await convertMarkdownToNotionBlocks(markdown) const blockPages = splitNotionBlocks(allBlocks) if (blockPages.length === 0) { @@ -223,25 +293,33 @@ export const exportTopicToNotion = async (topic: Topic) => { // 创建主页面和子页面 let mainPageResponse: any = null let parentBlockId: string | null = null + for (let i = 0; i < blockPages.length; i++) { - const pageTitle = topic.name const pageBlocks = blockPages[i] // 导出进度提示 - window.message.loading({ - content: i18n.t('message.loading.notion.exporting_progress', { - current: i + 1, - total: blockPages.length - }), - key: 'notion-export-progress' - }) + if (blockPages.length > 1) { + window.message.loading({ + content: i18n.t('message.loading.notion.exporting_progress', { + current: i + 1, + total: blockPages.length + }), + key: 'notion-export-progress' + }) + } else { + window.message.loading({ + content: i18n.t('message.loading.notion.preparing'), + key: 'notion-export-progress' + }) + } if (i === 0) { + // 创建主页面 const response = await notion.pages.create({ parent: { database_id: notionDatabaseID }, properties: { [store.getState().settings.notionPageNameKey || 'Name']: { - title: [{ text: { content: pageTitle } }] + title: [{ text: { content: title } }] } }, children: pageBlocks @@ -249,6 +327,7 @@ export const exportTopicToNotion = async (topic: Topic) => { mainPageResponse = response parentBlockId = response.id } else { + // 追加后续页面的块到主页面 if (!parentBlockId) { throw new Error('Parent block ID is null') } @@ -259,63 +338,71 @@ export const exportTopicToNotion = async (topic: Topic) => { } } - window.message.success({ content: i18n.t('message.success.notion.export'), key: 'notion-export-progress' }) + const messageKey = blockPages.length > 1 ? 'notion-export-progress' : 'notion-success' + window.message.success({ content: i18n.t('message.success.notion.export'), key: messageKey }) return mainPageResponse } catch (error: any) { window.message.error({ content: i18n.t('message.error.notion.export'), key: 'notion-export-progress' }) return null } finally { - setExportState({ - isExporting: false - }) + setExportState({ isExporting: false }) } } -export const exportMarkdownToNotion = async (title: string, content: string) => { - const { isExporting } = store.getState().runtime.export +export const exportMessageToNotion = async (title: string, content: string, message?: Message) => { + const { notionExportReasoning } = store.getState().settings - if (isExporting) { - window.message.warning({ content: i18n.t('message.warn.notion.exporting'), key: 'notion-exporting' }) - return + const notionBlocks = await convertMarkdownToNotionBlocks(content) + + if (notionExportReasoning && message) { + const thinkingContent = getThinkingContent(message) + if (thinkingContent) { + const thinkingBlocks = await convertThinkingToNotionBlocks(thinkingContent) + if (notionBlocks.length > 0) { + notionBlocks.splice(1, 0, ...thinkingBlocks) + } else { + notionBlocks.push(...thinkingBlocks) + } + } } - setExportState({ isExporting: true }) + return executeNotionExport(title, notionBlocks) +} - const { notionDatabaseID, notionApiKey } = store.getState().settings +export const exportTopicToNotion = async (topic: Topic) => { + const { notionExportReasoning } = store.getState().settings - if (!notionApiKey || !notionDatabaseID) { - window.message.error({ content: i18n.t('message.error.notion.no_api_key'), key: 'notion-no-apikey-error' }) - return - } + // 获取话题消息 + const topicRecord = await db.topics.get(topic.id) + const topicMessages = topicRecord?.messages || [] - try { - const notion = new Client({ auth: notionApiKey }) - const notionBlocks = await convertMarkdownToNotionBlocks(content) + // 创建话题标题块 + const titleBlocks = await convertMarkdownToNotionBlocks(`# ${topic.name}`) - if (notionBlocks.length === 0) { - throw new Error('No content to export') + // 为每个消息创建blocks + const allBlocks: any[] = [...titleBlocks] + + for (const message of topicMessages) { + // 将单个消息转换为markdown + const messageMarkdown = messageToMarkdown(message) + const messageBlocks = await convertMarkdownToNotionBlocks(messageMarkdown) + + if (notionExportReasoning) { + const thinkingContent = getThinkingContent(message) + if (thinkingContent) { + const thinkingBlocks = await convertThinkingToNotionBlocks(thinkingContent) + if (messageBlocks.length > 0) { + messageBlocks.splice(1, 0, ...thinkingBlocks) + } else { + messageBlocks.push(...thinkingBlocks) + } + } } - const response = await notion.pages.create({ - parent: { database_id: notionDatabaseID }, - properties: { - [store.getState().settings.notionPageNameKey || 'Name']: { - title: [{ text: { content: title } }] - } - }, - children: notionBlocks as any[] - }) - - window.message.success({ content: i18n.t('message.success.notion.export'), key: 'notion-success' }) - return response - } catch (error: any) { - window.message.error({ content: i18n.t('message.error.notion.export'), key: 'notion-error' }) - return null - } finally { - setExportState({ - isExporting: false - }) + allBlocks.push(...messageBlocks) } + + return executeNotionExport(topic.name, allBlocks) } export const exportMarkdownToYuque = async (title: string, content: string) => { @@ -464,7 +551,6 @@ export const exportMarkdownToObsidian = async (attributes: any) => { * @param fileName * @returns */ - function transformObsidianFileName(fileName: string): string { const platform = window.navigator.userAgent const isWindows = /win/i.test(platform) @@ -482,7 +568,7 @@ function transformObsidianFileName(fileName: string): string { } else if (isMac) { // Mac 的清理 sanitized = sanitized - .replace(/[/:\u0020-\u007E]/g, '') // 移除无效字符 + .replace(/[<>:"\\/\\|?*]/g, '') // 移除无效字符 .replace(/^\./, '_') // 避免以句点开头 } else { // Linux 或其他系统 @@ -504,14 +590,27 @@ function transformObsidianFileName(fileName: string): string { return sanitized } -export const exportMarkdownToJoplin = async (title: string, content: string) => { - const { joplinUrl, joplinToken } = store.getState().settings + +export const exportMarkdownToJoplin = async (title: string, contentOrMessages: string | Message | Message[]) => { + const { joplinUrl, joplinToken, joplinExportReasoning } = store.getState().settings if (!joplinUrl || !joplinToken) { window.message.error(i18n.t('message.error.joplin.no_config')) return } + let content: string + if (typeof contentOrMessages === 'string') { + content = contentOrMessages + } else if (Array.isArray(contentOrMessages)) { + content = messagesToMarkdown(contentOrMessages, joplinExportReasoning) + } else { + // 单条Message + content = joplinExportReasoning + ? messageToMarkdownWithReasoning(contentOrMessages) + : messageToMarkdown(contentOrMessages) + } + try { const baseUrl = joplinUrl.endsWith('/') ? joplinUrl : `${joplinUrl}/` const response = await fetch(`${baseUrl}notes?token=${joplinToken}`, { diff --git a/src/renderer/src/utils/messageUtils/find.ts b/src/renderer/src/utils/messageUtils/find.ts index a2d804a10a..fa6c8cd668 100644 --- a/src/renderer/src/utils/messageUtils/find.ts +++ b/src/renderer/src/utils/messageUtils/find.ts @@ -133,7 +133,7 @@ export const getCitationContent = (message: Message): string => { return citationBlocks .map((block) => formatCitationsFromBlock(block)) .flat() - .map((citation) => `[${citation.number}] [${citation.url}](${citation.title || citation.url})`) + .map((citation) => `[${citation.number}] [${citation.title || citation.url}](${citation.url})`) .join('\n\n') }