refactor(export): 添加导出菜单选项设置、思维链导出功能 (#4168)

* refactor(settings): Add export menu setting & optimize data settings page

* feat: add dynamic export menu options from Redux state in MessageMenubar and TopicsTab

* feat(export): Add export to markdown with reasoning method

* feat(export): optimize reasoning style

* feat(export): Add export to markdown with reasoning to export menu

* feat(i18n): Update i18n for new export options

---------

Co-authored-by: 亢奋猫 <kangfenmao@qq.com>
This commit is contained in:
George·Dong 2025-04-06 08:43:54 +08:00 committed by GitHub
parent 95639df35c
commit 56e9a7371a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 406 additions and 62 deletions

View File

@ -0,0 +1,35 @@
import React from 'react'
import styled from 'styled-components'
interface DividerWithTextProps {
text: string
}
const DividerWithText: React.FC<DividerWithTextProps> = ({ text }) => {
return (
<DividerContainer>
<DividerText>{text}</DividerText>
<DividerLine />
</DividerContainer>
)
}
const DividerContainer = styled.div`
display: flex;
align-items: center;
margin: 0px 0;
`
const DividerText = styled.span`
font-size: 12px;
color: var(--color-text-2);
margin-right: 8px;
`
const DividerLine = styled.div`
flex: 1;
height: 1px;
background-color: var(--color-border);
`
export default DividerWithText

View File

@ -196,6 +196,7 @@
"topics.export.image": "Export as image", "topics.export.image": "Export as image",
"topics.export.joplin": "Export to Joplin", "topics.export.joplin": "Export to Joplin",
"topics.export.md": "Export as markdown", "topics.export.md": "Export as markdown",
"topics.export.md.reason": "Export as Markdown (with reasoning)",
"topics.export.notion": "Export to Notion", "topics.export.notion": "Export to Notion",
"topics.export.obsidian": "Export to Obsidian", "topics.export.obsidian": "Export to Obsidian",
"topics.export.obsidian_vault": "Vault", "topics.export.obsidian_vault": "Vault",
@ -296,7 +297,8 @@
"select": "Select", "select": "Select",
"topics": "Topics", "topics": "Topics",
"warning": "Warning", "warning": "Warning",
"you": "You" "you": "You",
"reasoning_content": "Deep reasoning"
}, },
"docs": { "docs": {
"title": "Docs" "title": "Docs"
@ -795,8 +797,24 @@
"title": "Clear Cache" "title": "Clear Cache"
}, },
"data.title": "Data Directory", "data.title": "Data Directory",
"divider.basic": "Basic Data Settings",
"divider.cloud_storage": "Cloud Backup Settings",
"divider.export_settings": "Export Settings",
"divider.third_party": "Third-party Connections",
"hour_interval_one": "{{count}} hour", "hour_interval_one": "{{count}} hour",
"hour_interval_other": "{{count}} hours", "hour_interval_other": "{{count}} hours",
"export_menu": {
"title": "Export Menu Settings",
"image": "Export as Image",
"markdown": "Export as Markdown",
"markdown_reason": "Export as Markdown (with reasoning)",
"notion": "Export to Notion",
"yuque": "Export to Yuque",
"obsidian": "Export to Obsidian",
"siyuan": "Export to SiYuan Note",
"joplin": "Export to Joplin",
"docx": "Export as Word"
},
"joplin": { "joplin": {
"check": { "check": {
"button": "Check", "button": "Check",

View File

@ -196,6 +196,7 @@
"topics.export.image": "画像としてエクスポート", "topics.export.image": "画像としてエクスポート",
"topics.export.joplin": "Joplin にエクスポート", "topics.export.joplin": "Joplin にエクスポート",
"topics.export.md": "Markdownとしてエクスポート", "topics.export.md": "Markdownとしてエクスポート",
"topics.export.md.reason": "Markdown としてエクスポート (思考内容を含む)",
"topics.export.notion": "Notion にエクスポート", "topics.export.notion": "Notion にエクスポート",
"topics.export.obsidian": "Obsidian にエクスポート", "topics.export.obsidian": "Obsidian にエクスポート",
"topics.export.obsidian_vault": "保管庫", "topics.export.obsidian_vault": "保管庫",
@ -296,7 +297,8 @@
"select": "選択", "select": "選択",
"topics": "トピック", "topics": "トピック",
"warning": "警告", "warning": "警告",
"you": "あなた" "you": "あなた",
"reasoning_content": "深く考察済み"
}, },
"docs": { "docs": {
"title": "ドキュメント" "title": "ドキュメント"
@ -795,8 +797,24 @@
"title": "キャッシュをクリア" "title": "キャッシュをクリア"
}, },
"data.title": "データディレクトリ", "data.title": "データディレクトリ",
"divider.basic": "基本データ設定",
"divider.cloud_storage": "クラウドバックアップ設定",
"divider.export_settings": "エクスポート設定",
"divider.third_party": "サードパーティー連携",
"hour_interval_one": "{{count}} 時間", "hour_interval_one": "{{count}} 時間",
"hour_interval_other": "{{count}} 時間", "hour_interval_other": "{{count}} 時間",
"export_menu": {
"title": "エクスポートメニュー設定",
"image": "画像としてエクスポート",
"markdown": "Markdownとしてエクスポート",
"markdown_reason": "Markdownとしてエクスポート思考内容を含む",
"notion": "Notionにエクスポート",
"yuque": "語雀にエクスポート",
"obsidian": "Obsidianにエクスポート",
"siyuan": "思源ノートにエクスポート",
"joplin": "Joplinにエクスポート",
"docx": "Wordとしてエクスポート"
},
"joplin": { "joplin": {
"check": { "check": {
"button": "確認", "button": "確認",

View File

@ -196,6 +196,7 @@
"topics.export.image": "Экспорт как изображение", "topics.export.image": "Экспорт как изображение",
"topics.export.joplin": "Экспорт в Joplin", "topics.export.joplin": "Экспорт в Joplin",
"topics.export.md": "Экспорт как markdown", "topics.export.md": "Экспорт как markdown",
"topics.export.md.reason": "Экспорт в Markdown (с рассуждениями)",
"topics.export.notion": "Экспорт в Notion", "topics.export.notion": "Экспорт в Notion",
"topics.export.obsidian": "Экспорт в Obsidian", "topics.export.obsidian": "Экспорт в Obsidian",
"topics.export.obsidian_vault": "Хранилище", "topics.export.obsidian_vault": "Хранилище",
@ -296,7 +297,8 @@
"select": "Выбрать", "select": "Выбрать",
"topics": "Топики", "topics": "Топики",
"warning": "Предупреждение", "warning": "Предупреждение",
"you": "Вы" "you": "Вы",
"reasoning_content": "Глубокий анализ"
}, },
"docs": { "docs": {
"title": "Документация" "title": "Документация"
@ -795,8 +797,24 @@
"title": "Очистка кэша" "title": "Очистка кэша"
}, },
"data.title": "Каталог данных", "data.title": "Каталог данных",
"divider.basic": "Основные настройки данных",
"divider.cloud_storage": "Настройки облачного резервирования",
"divider.export_settings": "Настройки экспорта",
"divider.third_party": "Сторонние подключения",
"hour_interval_one": "{{count}} час", "hour_interval_one": "{{count}} час",
"hour_interval_other": "{{count}} часов", "hour_interval_other": "{{count}} часов",
"export_menu": {
"title": "Настройки меню экспорта",
"image": "Экспорт как изображение",
"markdown": "Экспорт в Markdown",
"markdown_reason": "Экспорт в Markdown (с рассуждениями)",
"notion": "Экспорт в Notion",
"yuque": "Экспорт в Yuque",
"obsidian": "Экспорт в Obsidian",
"siyuan": "Экспорт в SiYuan Note",
"joplin": "Экспорт в Joplin",
"docx": "Экспорт в Word"
},
"joplin": { "joplin": {
"check": { "check": {
"button": "Проверить", "button": "Проверить",

View File

@ -198,6 +198,7 @@
"topics.export.image": "导出为图片", "topics.export.image": "导出为图片",
"topics.export.joplin": "导出到 Joplin", "topics.export.joplin": "导出到 Joplin",
"topics.export.md": "导出为 Markdown", "topics.export.md": "导出为 Markdown",
"topics.export.md.reason": "导出为 Markdown (包含思考)",
"topics.export.notion": "导出到 Notion", "topics.export.notion": "导出到 Notion",
"topics.export.obsidian": "导出到 Obsidian", "topics.export.obsidian": "导出到 Obsidian",
"topics.export.obsidian_vault": "保管库", "topics.export.obsidian_vault": "保管库",
@ -296,7 +297,8 @@
"select": "选择", "select": "选择",
"topics": "话题", "topics": "话题",
"warning": "警告", "warning": "警告",
"you": "用户" "you": "用户",
"reasoning_content": "已深度思考"
}, },
"docs": { "docs": {
"title": "帮助文档" "title": "帮助文档"
@ -795,8 +797,24 @@
"title": "清除缓存" "title": "清除缓存"
}, },
"data.title": "数据目录", "data.title": "数据目录",
"divider.basic": "基础数据设置",
"divider.cloud_storage": "云备份设置",
"divider.export_settings": "导出设置",
"divider.third_party": "第三方连接",
"hour_interval_one": "{{count}} 小时", "hour_interval_one": "{{count}} 小时",
"hour_interval_other": "{{count}} 小时", "hour_interval_other": "{{count}} 小时",
"export_menu": {
"title": "导出菜单设置",
"image": "导出为图片",
"markdown": "导出为Markdown",
"markdown_reason": "导出为Markdown包含思考",
"notion": "导出到Notion",
"yuque": "导出到语雀",
"obsidian": "导出到Obsidian",
"siyuan": "导出到思源笔记",
"joplin": "导出到Joplin",
"docx": "导出为Word"
},
"joplin": { "joplin": {
"check": { "check": {
"button": "检查", "button": "检查",

View File

@ -196,6 +196,7 @@
"topics.export.image": "匯出為圖片", "topics.export.image": "匯出為圖片",
"topics.export.joplin": "匯出到 Joplin", "topics.export.joplin": "匯出到 Joplin",
"topics.export.md": "匯出為 Markdown", "topics.export.md": "匯出為 Markdown",
"topics.export.md.reason": "匯出為 Markdown (包含思考)",
"topics.export.notion": "匯出到 Notion", "topics.export.notion": "匯出到 Notion",
"topics.export.obsidian": "匯出到 Obsidian", "topics.export.obsidian": "匯出到 Obsidian",
"topics.export.obsidian_vault": "保管庫", "topics.export.obsidian_vault": "保管庫",
@ -296,7 +297,8 @@
"select": "選擇", "select": "選擇",
"topics": "話題", "topics": "話題",
"warning": "警告", "warning": "警告",
"you": "您" "you": "您",
"reasoning_content": "已深度思考"
}, },
"docs": { "docs": {
"title": "說明文件" "title": "說明文件"
@ -795,8 +797,24 @@
"title": "清除快取" "title": "清除快取"
}, },
"data.title": "資料目錄", "data.title": "資料目錄",
"divider.basic": "基礎數據設定",
"divider.cloud_storage": "雲備份設定",
"divider.export_settings": "匯出設定",
"divider.third_party": "第三方連接",
"hour_interval_one": "{{count}} 小時", "hour_interval_one": "{{count}} 小時",
"hour_interval_other": "{{count}} 小時", "hour_interval_other": "{{count}} 小時",
"export_menu": {
"title": "匯出選單設定",
"image": "匯出為圖片",
"markdown": "匯出為Markdown",
"markdown_reason": "匯出為Markdown包含思考",
"notion": "匯出到Notion",
"yuque": "匯出到語雀",
"obsidian": "匯出到Obsidian",
"siyuan": "匯出到思源筆記",
"joplin": "匯出到Joplin",
"docx": "匯出為Word"
},
"joplin": { "joplin": {
"check": { "check": {
"button": "檢查", "button": "檢查",

View File

@ -21,6 +21,7 @@ import { useMessageOperations, useTopicLoading } from '@renderer/hooks/useMessag
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { getMessageTitle, resetAssistantMessage } from '@renderer/services/MessagesService' import { getMessageTitle, resetAssistantMessage } from '@renderer/services/MessagesService'
import { translateText } from '@renderer/services/TranslateService' import { translateText } from '@renderer/services/TranslateService'
import { RootState } from '@renderer/store'
import type { Message, Model } from '@renderer/types' import type { Message, Model } from '@renderer/types'
import type { Assistant, Topic } from '@renderer/types' import type { Assistant, Topic } from '@renderer/types'
import { captureScrollableDivAsBlob, captureScrollableDivAsDataURL, removeTrailingDoubleSpaces } from '@renderer/utils' import { captureScrollableDivAsBlob, captureScrollableDivAsDataURL, removeTrailingDoubleSpaces } from '@renderer/utils'
@ -38,6 +39,7 @@ import dayjs from 'dayjs'
import { clone } from 'lodash' import { clone } from 'lodash'
import { FC, memo, useCallback, useMemo, useState } from 'react' import { FC, memo, useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux'
import styled from 'styled-components' import styled from 'styled-components'
interface Props { interface Props {
@ -68,6 +70,21 @@ const MessageMenubar: FC<Props> = (props) => {
const isUserMessage = message.role === 'user' const isUserMessage = message.role === 'user'
const exportMenuOptions = useSelector(
(state: RootState) =>
state.settings.exportMenuOptions || {
image: true,
markdown: true,
markdown_reason: true,
notion: true,
yuque: true,
joplin: true,
obsidian: true,
siyuan: true,
docx: true
}
)
const onCopy = useCallback( const onCopy = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
@ -182,7 +199,7 @@ const MessageMenubar: FC<Props> = (props) => {
key: 'export', key: 'export',
icon: <UploadOutlined />, icon: <UploadOutlined />,
children: [ children: [
{ exportMenuOptions.image && {
label: t('chat.topics.copy.image'), label: t('chat.topics.copy.image'),
key: 'img', key: 'img',
onClick: async () => { onClick: async () => {
@ -193,7 +210,7 @@ const MessageMenubar: FC<Props> = (props) => {
}) })
} }
}, },
{ exportMenuOptions.image && {
label: t('chat.topics.export.image'), label: t('chat.topics.export.image'),
key: 'image', key: 'image',
onClick: async () => { onClick: async () => {
@ -204,9 +221,17 @@ const MessageMenubar: FC<Props> = (props) => {
} }
} }
}, },
{ label: t('chat.topics.export.md'), key: 'markdown', onClick: () => exportMessageAsMarkdown(message) }, exportMenuOptions.markdown && {
label: t('chat.topics.export.md'),
{ key: 'markdown',
onClick: () => exportMessageAsMarkdown(message)
},
exportMenuOptions.markdown_reason && {
label: t('chat.topics.export.md.reason'),
key: 'markdown_reason',
onClick: () => exportMessageAsMarkdown(message, true)
},
exportMenuOptions.docx && {
label: t('chat.topics.export.word'), label: t('chat.topics.export.word'),
key: 'word', key: 'word',
onClick: async () => { onClick: async () => {
@ -215,7 +240,7 @@ const MessageMenubar: FC<Props> = (props) => {
window.api.export.toWord(markdown, title) window.api.export.toWord(markdown, title)
} }
}, },
{ exportMenuOptions.notion && {
label: t('chat.topics.export.notion'), label: t('chat.topics.export.notion'),
key: 'notion', key: 'notion',
onClick: async () => { onClick: async () => {
@ -224,7 +249,7 @@ const MessageMenubar: FC<Props> = (props) => {
exportMarkdownToNotion(title, markdown) exportMarkdownToNotion(title, markdown)
} }
}, },
{ exportMenuOptions.yuque && {
label: t('chat.topics.export.yuque'), label: t('chat.topics.export.yuque'),
key: 'yuque', key: 'yuque',
onClick: async () => { onClick: async () => {
@ -233,7 +258,7 @@ const MessageMenubar: FC<Props> = (props) => {
exportMarkdownToYuque(title, markdown) exportMarkdownToYuque(title, markdown)
} }
}, },
{ exportMenuOptions.obsidian && {
label: t('chat.topics.export.obsidian'), label: t('chat.topics.export.obsidian'),
key: 'obsidian', key: 'obsidian',
onClick: async () => { onClick: async () => {
@ -242,7 +267,7 @@ const MessageMenubar: FC<Props> = (props) => {
await ObsidianExportPopup.show({ title, markdown, processingMethod: '1' }) await ObsidianExportPopup.show({ title, markdown, processingMethod: '1' })
} }
}, },
{ exportMenuOptions.joplin && {
label: t('chat.topics.export.joplin'), label: t('chat.topics.export.joplin'),
key: 'joplin', key: 'joplin',
onClick: async () => { onClick: async () => {
@ -251,7 +276,7 @@ const MessageMenubar: FC<Props> = (props) => {
exportMarkdownToJoplin(title, markdown) exportMarkdownToJoplin(title, markdown)
} }
}, },
{ exportMenuOptions.siyuan && {
label: t('chat.topics.export.siyuan'), label: t('chat.topics.export.siyuan'),
key: 'siyuan', key: 'siyuan',
onClick: async () => { onClick: async () => {
@ -260,10 +285,10 @@ const MessageMenubar: FC<Props> = (props) => {
exportMarkdownToSiyuan(title, markdown) exportMarkdownToSiyuan(title, markdown)
} }
} }
] ].filter(Boolean)
} }
], ],
[message, messageContainerRef, onEdit, onNewBranch, t, topic.name] [message, messageContainerRef, onEdit, onNewBranch, t, topic.name, exportMenuOptions]
) )
const onRegenerate = async (e: React.MouseEvent | undefined) => { const onRegenerate = async (e: React.MouseEvent | undefined) => {

View File

@ -21,6 +21,7 @@ import { TopicManager } from '@renderer/hooks/useTopic'
import { fetchMessagesSummary } from '@renderer/services/ApiService' import { fetchMessagesSummary } from '@renderer/services/ApiService'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import store from '@renderer/store' import store from '@renderer/store'
import { RootState } from '@renderer/store'
import { setGenerating } from '@renderer/store/runtime' import { setGenerating } from '@renderer/store/runtime'
import { Assistant, Topic } from '@renderer/types' import { Assistant, Topic } from '@renderer/types'
import { removeSpecialCharactersForFileName } from '@renderer/utils' import { removeSpecialCharactersForFileName } from '@renderer/utils'
@ -35,10 +36,12 @@ import {
} from '@renderer/utils/export' } from '@renderer/utils/export'
import { hasTopicPendingRequests } from '@renderer/utils/queue' import { hasTopicPendingRequests } from '@renderer/utils/queue'
import { Dropdown, MenuProps, Tooltip } from 'antd' import { Dropdown, MenuProps, Tooltip } from 'antd'
import { ItemType, MenuItemType } from 'antd/es/menu/interface'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { findIndex } from 'lodash' import { findIndex } from 'lodash'
import { FC, startTransition, useCallback, useMemo, useRef, useState } from 'react' import { FC, startTransition, useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux'
import styled from 'styled-components' import styled from 'styled-components'
interface Props { interface Props {
@ -153,6 +156,21 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
[setActiveTopic] [setActiveTopic]
) )
const exportMenuOptions = useSelector(
(state: RootState) =>
state.settings.exportMenuOptions || {
image: true,
markdown: true,
markdown_reason: true,
notion: true,
yuque: true,
joplin: true,
obsidian: true,
siyuan: true,
docx: true
}
)
const getTopicMenuItems = useCallback( const getTopicMenuItems = useCallback(
(topic: Topic) => { (topic: Topic) => {
const menus: MenuProps['items'] = [ const menus: MenuProps['items'] = [
@ -255,18 +273,22 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
key: 'export', key: 'export',
icon: <UploadOutlined />, icon: <UploadOutlined />,
children: [ children: [
{ exportMenuOptions.image !== false && {
label: t('chat.topics.export.image'), label: t('chat.topics.export.image'),
key: 'image', key: 'image',
onClick: () => EventEmitter.emit(EVENT_NAMES.EXPORT_TOPIC_IMAGE, topic) onClick: () => EventEmitter.emit(EVENT_NAMES.EXPORT_TOPIC_IMAGE, topic)
}, },
{ exportMenuOptions.markdown !== false && {
label: t('chat.topics.export.md'), label: t('chat.topics.export.md'),
key: 'markdown', key: 'markdown',
onClick: () => exportTopicAsMarkdown(topic) onClick: () => exportTopicAsMarkdown(topic)
}, },
exportMenuOptions.markdown_reason !== false && {
{ label: t('chat.topics.export.md.reason'),
key: 'markdown_reason',
onClick: () => exportTopicAsMarkdown(topic, true)
},
exportMenuOptions.docx !== false && {
label: t('chat.topics.export.word'), label: t('chat.topics.export.word'),
key: 'word', key: 'word',
onClick: async () => { onClick: async () => {
@ -274,14 +296,14 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
window.api.export.toWord(markdown, removeSpecialCharactersForFileName(topic.name)) window.api.export.toWord(markdown, removeSpecialCharactersForFileName(topic.name))
} }
}, },
{ exportMenuOptions.notion !== false && {
label: t('chat.topics.export.notion'), label: t('chat.topics.export.notion'),
key: 'notion', key: 'notion',
onClick: async () => { onClick: async () => {
exportTopicToNotion(topic) exportTopicToNotion(topic)
} }
}, },
{ exportMenuOptions.yuque !== false && {
label: t('chat.topics.export.yuque'), label: t('chat.topics.export.yuque'),
key: 'yuque', key: 'yuque',
onClick: async () => { onClick: async () => {
@ -289,7 +311,7 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
exportMarkdownToYuque(topic.name, markdown) exportMarkdownToYuque(topic.name, markdown)
} }
}, },
{ exportMenuOptions.obsidian !== false && {
label: t('chat.topics.export.obsidian'), label: t('chat.topics.export.obsidian'),
key: 'obsidian', key: 'obsidian',
onClick: async () => { onClick: async () => {
@ -297,7 +319,7 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
await ObsidianExportPopup.show({ title: topic.name, markdown, processingMethod: '3' }) await ObsidianExportPopup.show({ title: topic.name, markdown, processingMethod: '3' })
} }
}, },
{ exportMenuOptions.joplin !== false && {
label: t('chat.topics.export.joplin'), label: t('chat.topics.export.joplin'),
key: 'joplin', key: 'joplin',
onClick: async () => { onClick: async () => {
@ -305,7 +327,7 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
exportMarkdownToJoplin(topic.name, markdown) exportMarkdownToJoplin(topic.name, markdown)
} }
}, },
{ exportMenuOptions.siyuan !== false && {
label: t('chat.topics.export.siyuan'), label: t('chat.topics.export.siyuan'),
key: 'siyuan', key: 'siyuan',
onClick: async () => { onClick: async () => {
@ -313,7 +335,7 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
exportMarkdownToSiyuan(topic.name, markdown) exportMarkdownToSiyuan(topic.name, markdown)
} }
} }
] ].filter(Boolean) as ItemType<MenuItemType>[]
} }
] ]
@ -345,18 +367,7 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
return menus return menus
}, },
[ [assistant, assistants, onClearMessages, onDeleteTopic, onPinTopic, onMoveTopic, t, updateTopic, exportMenuOptions]
t,
assistants,
assistant,
updateTopic,
activeTopic.id,
setActiveTopic,
onPinTopic,
onClearMessages,
onMoveTopic,
onDeleteTopic
]
) )
return ( return (

View File

@ -4,9 +4,11 @@ import {
FileMarkdownOutlined, FileMarkdownOutlined,
FileSearchOutlined, FileSearchOutlined,
FolderOpenOutlined, FolderOpenOutlined,
MenuOutlined,
SaveOutlined, SaveOutlined,
YuqueOutlined YuqueOutlined
} from '@ant-design/icons' } from '@ant-design/icons'
import DividerWithText from '@renderer/components/DividerWithText'
import { NutstoreIcon } from '@renderer/components/Icons/NutstoreIcons' import { NutstoreIcon } from '@renderer/components/Icons/NutstoreIcons'
import { HStack } from '@renderer/components/Layout' import { HStack } from '@renderer/components/Layout'
import ListItem from '@renderer/components/ListItem' import ListItem from '@renderer/components/ListItem'
@ -23,6 +25,7 @@ import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..' import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
import ExportMenuOptions from './ExportMenuSettings'
import JoplinSettings from './JoplinSettings' import JoplinSettings from './JoplinSettings'
import MarkdownExportSettings from './MarkdownExportSettings' import MarkdownExportSettings from './MarkdownExportSettings'
import NotionSettings from './NotionSettings' import NotionSettings from './NotionSettings'
@ -63,14 +66,23 @@ const DataSettings: FC = () => {
) )
const menuItems = [ const menuItems = [
{ key: 'divider_0', isDivider: true, text: t('settings.data.divider.basic') },
{ key: 'data', title: 'settings.data.data.title', icon: <DatabaseOutlined style={{ fontSize: 16 }} /> }, { key: 'data', title: 'settings.data.data.title', icon: <DatabaseOutlined style={{ fontSize: 16 }} /> },
{ key: 'divider_1', isDivider: true, text: t('settings.data.divider.cloud_storage') },
{ key: 'webdav', title: 'settings.data.webdav.title', icon: <CloudSyncOutlined style={{ fontSize: 16 }} /> }, { key: 'webdav', title: 'settings.data.webdav.title', icon: <CloudSyncOutlined style={{ fontSize: 16 }} /> },
{ key: 'nutstore', title: 'settings.data.nutstore.title', icon: <NutstoreIcon /> }, { key: 'nutstore', title: 'settings.data.nutstore.title', icon: <NutstoreIcon /> },
{ key: 'divider_2', isDivider: true, text: t('settings.data.divider.export_settings') },
{
key: 'export_menu',
title: 'settings.data.export_menu.title',
icon: <MenuOutlined style={{ fontSize: 16 }} />
},
{ {
key: 'markdown_export', key: 'markdown_export',
title: 'settings.data.markdown_export.title', title: 'settings.data.markdown_export.title',
icon: <FileMarkdownOutlined style={{ fontSize: 16 }} /> icon: <FileMarkdownOutlined style={{ fontSize: 16 }} />
}, },
{ key: 'divider_3', isDivider: true, text: t('settings.data.divider.third_party') },
{ key: 'notion', title: 'settings.data.notion.title', icon: <i className="iconfont icon-notion" /> }, { key: 'notion', title: 'settings.data.notion.title', icon: <i className="iconfont icon-notion" /> },
{ {
key: 'yuque', key: 'yuque',
@ -80,7 +92,6 @@ const DataSettings: FC = () => {
{ {
key: 'joplin', key: 'joplin',
title: 'settings.data.joplin.title', title: 'settings.data.joplin.title',
//joplin icon needs to be updated into iconfont
icon: <JoplinIcon /> icon: <JoplinIcon />
}, },
{ {
@ -148,16 +159,20 @@ const DataSettings: FC = () => {
return ( return (
<Container> <Container>
<MenuList> <MenuList>
{menuItems.map((item) => ( {menuItems.map((item) =>
item.isDivider ? (
<DividerWithText key={item.key} text={item.text || ''} /> // 动态传递分隔符文字
) : (
<ListItem <ListItem
key={item.key} key={item.key}
title={t(item.title)} title={t(item.title || '')}
active={menu === item.key} active={menu === item.key}
onClick={() => setMenu(item.key)} onClick={() => setMenu(item.key)}
titleStyle={{ fontWeight: 500 }} titleStyle={{ fontWeight: 500 }}
icon={item.icon} icon={item.icon}
/> />
))} )
)}
</MenuList> </MenuList>
<SettingContainer theme={theme} style={{ display: 'flex', flex: 1 }}> <SettingContainer theme={theme} style={{ display: 'flex', flex: 1 }}>
{menu === 'data' && ( {menu === 'data' && (
@ -227,6 +242,7 @@ const DataSettings: FC = () => {
)} )}
{menu === 'webdav' && <WebDavSettings />} {menu === 'webdav' && <WebDavSettings />}
{menu === 'nutstore' && <NutstoreSettings />} {menu === 'nutstore' && <NutstoreSettings />}
{menu === 'export_menu' && <ExportMenuOptions />}
{menu === 'markdown_export' && <MarkdownExportSettings />} {menu === 'markdown_export' && <MarkdownExportSettings />}
{menu === 'notion' && <NotionSettings />} {menu === 'notion' && <NotionSettings />}
{menu === 'yuque' && <YuqueSettings />} {menu === 'yuque' && <YuqueSettings />}

View File

@ -0,0 +1,104 @@
import { useTheme } from '@renderer/context/ThemeProvider'
import { RootState, useAppDispatch } from '@renderer/store'
import { setExportMenuOptions } from '@renderer/store/settings'
import { Switch } from 'antd'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux'
import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
const ExportMenuOptions: FC = () => {
const { t } = useTranslation()
const { theme } = useTheme()
const dispatch = useAppDispatch()
const exportMenuOptions = useSelector(
(state: RootState) =>
state.settings.exportMenuOptions || {
image: true,
markdown: true,
markdown_reason: true,
notion: true,
yuque: true,
joplin: true,
obsidian: true,
siyuan: true,
docx: true
}
)
const handleToggleOption = (option: string, checked: boolean) => {
dispatch(
setExportMenuOptions({
...exportMenuOptions,
[option]: checked
})
)
}
return (
<SettingGroup theme={theme}>
<SettingTitle>{t('settings.data.export_menu.title')}</SettingTitle>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.export_menu.image')}</SettingRowTitle>
<Switch checked={exportMenuOptions.image} onChange={(checked) => handleToggleOption('image', checked)} />
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.export_menu.markdown')}</SettingRowTitle>
<Switch checked={exportMenuOptions.markdown} onChange={(checked) => handleToggleOption('markdown', checked)} />
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.export_menu.markdown_reason')}</SettingRowTitle>
<Switch
checked={exportMenuOptions.markdown_reason}
onChange={(checked) => handleToggleOption('markdown_reason', checked)}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.export_menu.notion')}</SettingRowTitle>
<Switch checked={exportMenuOptions.notion} onChange={(checked) => handleToggleOption('notion', checked)} />
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.export_menu.yuque')}</SettingRowTitle>
<Switch checked={exportMenuOptions.yuque} onChange={(checked) => handleToggleOption('yuque', checked)} />
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.export_menu.joplin')}</SettingRowTitle>
<Switch checked={exportMenuOptions.joplin} onChange={(checked) => handleToggleOption('joplin', checked)} />
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.export_menu.obsidian')}</SettingRowTitle>
<Switch checked={exportMenuOptions.obsidian} onChange={(checked) => handleToggleOption('obsidian', checked)} />
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.export_menu.siyuan')}</SettingRowTitle>
<Switch checked={exportMenuOptions.siyuan} onChange={(checked) => handleToggleOption('siyuan', checked)} />
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.export_menu.docx')}</SettingRowTitle>
<Switch checked={exportMenuOptions.docx} onChange={(checked) => handleToggleOption('docx', checked)} />
</SettingRow>
</SettingGroup>
)
}
export default ExportMenuOptions

View File

@ -110,6 +110,17 @@ export interface SettingsState {
showOpenedMinappsInSidebar: boolean showOpenedMinappsInSidebar: boolean
// 隐私设置 // 隐私设置
enableDataCollection: boolean enableDataCollection: boolean
exportMenuOptions: {
image: boolean
markdown: boolean
markdown_reason: boolean
notion: boolean
yuque: boolean
joplin: boolean
obsidian: boolean
siyuan: boolean
docx: boolean
}
} }
export type MultiModelMessageStyle = 'horizontal' | 'vertical' | 'fold' | 'grid' export type MultiModelMessageStyle = 'horizontal' | 'vertical' | 'fold' | 'grid'
@ -196,7 +207,18 @@ const initialState: SettingsState = {
siyuanRootPath: null, siyuanRootPath: null,
maxKeepAliveMinapps: 3, maxKeepAliveMinapps: 3,
showOpenedMinappsInSidebar: true, showOpenedMinappsInSidebar: true,
enableDataCollection: false enableDataCollection: false,
exportMenuOptions: {
image: true,
markdown: true,
markdown_reason: true,
notion: true,
yuque: true,
joplin: true,
obsidian: true,
siyuan: true,
docx: true
}
} }
const settingsSlice = createSlice({ const settingsSlice = createSlice({
@ -451,6 +473,9 @@ const settingsSlice = createSlice({
}, },
setEnableDataCollection: (state, action: PayloadAction<boolean>) => { setEnableDataCollection: (state, action: PayloadAction<boolean>) => {
state.enableDataCollection = action.payload state.enableDataCollection = action.payload
},
setExportMenuOptions: (state, action: PayloadAction<typeof initialState.exportMenuOptions>) => {
state.exportMenuOptions = action.payload
} }
} }
}) })
@ -536,7 +561,8 @@ export const {
setSiyuanRootPath, setSiyuanRootPath,
setMaxKeepAliveMinapps, setMaxKeepAliveMinapps,
setShowOpenedMinappsInSidebar, setShowOpenedMinappsInSidebar,
setEnableDataCollection setEnableDataCollection,
setExportMenuOptions
} = settingsSlice.actions } = settingsSlice.actions
export default settingsSlice.reducer export default settingsSlice.reducer

View File

@ -8,6 +8,7 @@ import { Message, Topic } from '@renderer/types'
import { convertMathFormula, removeSpecialCharactersForFileName } from '@renderer/utils/index' import { convertMathFormula, removeSpecialCharactersForFileName } from '@renderer/utils/index'
import { markdownToBlocks } from '@tryfabric/martian' import { markdownToBlocks } from '@tryfabric/martian'
import dayjs from 'dayjs' import dayjs from 'dayjs'
//TODO: 添加对思考内容的支持
export const messageToMarkdown = (message: Message) => { export const messageToMarkdown = (message: Message) => {
const { forceDollarMathInMarkdown } = store.getState().settings const { forceDollarMathInMarkdown } = store.getState().settings
@ -18,27 +19,63 @@ export const messageToMarkdown = (message: Message) => {
return [titleSection, '', contentSection].join('\n') return [titleSection, '', contentSection].join('\n')
} }
export const messagesToMarkdown = (messages: Message[]) => { // 保留接口用于其它导出方法使用
return messages.map((message) => messageToMarkdown(message)).join('\n\n---\n\n') export const messageToMarkdownWithReasoning = (message: Message) => {
const { forceDollarMathInMarkdown } = store.getState().settings
const roleText = message.role === 'user' ? '🧑‍💻 User' : '🤖 Assistant'
const titleSection = `### ${roleText}`
// 处理思考内容
let reasoningSection = ''
if (message.reasoning_content) {
// 移除开头的<think>标记和换行符,并将所有换行符替换为<br>
let reasoningContent = message.reasoning_content
if (reasoningContent.startsWith('<think>\n')) {
reasoningContent = reasoningContent.substring(8)
} else if (reasoningContent.startsWith('<think>')) {
reasoningContent = reasoningContent.substring(7)
}
reasoningContent = reasoningContent.replace(/\n/g, '<br>')
// 应用数学公式转换(如果启用)
if (forceDollarMathInMarkdown) {
reasoningContent = convertMathFormula(reasoningContent)
}
// 添加思考内容的Markdown格式
reasoningSection = `<details style="background-color: #f5f5f5; padding: 5px; border-radius: 10px; margin-bottom: 10px;">
<summary>${i18n.t('common.reasoning_content')}</summary><hr>
${reasoningContent}
</details>`
} }
export const topicToMarkdown = async (topic: Topic) => { const contentSection = forceDollarMathInMarkdown ? convertMathFormula(message.content) : message.content
return [titleSection, '', reasoningSection + contentSection].join('\n')
}
export const messagesToMarkdown = (messages: Message[], exportReasoning?: boolean) => {
return messages
.map((message) => (exportReasoning ? messageToMarkdownWithReasoning(message) : messageToMarkdown(message)))
.join('\n\n---\n\n')
}
export const topicToMarkdown = async (topic: Topic, exportReasoning?: boolean) => {
const topicName = `# ${topic.name}` const topicName = `# ${topic.name}`
const topicMessages = await db.topics.get(topic.id) const topicMessages = await db.topics.get(topic.id)
if (topicMessages) { if (topicMessages) {
return topicName + '\n\n' + messagesToMarkdown(topicMessages.messages) return topicName + '\n\n' + messagesToMarkdown(topicMessages.messages, exportReasoning)
} }
return '' return ''
} }
export const exportTopicAsMarkdown = async (topic: Topic) => { export const exportTopicAsMarkdown = async (topic: Topic, exportReasoning?: boolean) => {
const { markdownExportPath } = store.getState().settings const { markdownExportPath } = store.getState().settings
if (!markdownExportPath) { if (!markdownExportPath) {
try { try {
const fileName = removeSpecialCharactersForFileName(topic.name) + '.md' const fileName = removeSpecialCharactersForFileName(topic.name) + '.md'
const markdown = await topicToMarkdown(topic) const markdown = await topicToMarkdown(topic, exportReasoning)
const result = await window.api.file.save(fileName, markdown) const result = await window.api.file.save(fileName, markdown)
if (result) { if (result) {
window.message.success({ window.message.success({
@ -53,7 +90,7 @@ export const exportTopicAsMarkdown = async (topic: Topic) => {
try { try {
const timestamp = dayjs().format('YYYY-MM-DD-HH-mm-ss') const timestamp = dayjs().format('YYYY-MM-DD-HH-mm-ss')
const fileName = removeSpecialCharactersForFileName(topic.name) + ` ${timestamp}.md` const fileName = removeSpecialCharactersForFileName(topic.name) + ` ${timestamp}.md`
const markdown = await topicToMarkdown(topic) const markdown = await topicToMarkdown(topic, exportReasoning)
await window.api.file.write(markdownExportPath + '/' + fileName, markdown) await window.api.file.write(markdownExportPath + '/' + fileName, markdown)
window.message.success({ content: i18n.t('message.success.markdown.export.preconf'), key: 'markdown-success' }) window.message.success({ content: i18n.t('message.success.markdown.export.preconf'), key: 'markdown-success' })
} catch (error: any) { } catch (error: any) {
@ -62,13 +99,13 @@ export const exportTopicAsMarkdown = async (topic: Topic) => {
} }
} }
export const exportMessageAsMarkdown = async (message: Message) => { export const exportMessageAsMarkdown = async (message: Message, exportReasoning?: boolean) => {
const { markdownExportPath } = store.getState().settings const { markdownExportPath } = store.getState().settings
if (!markdownExportPath) { if (!markdownExportPath) {
try { try {
const title = await getMessageTitle(message) const title = await getMessageTitle(message)
const fileName = removeSpecialCharactersForFileName(title) + '.md' const fileName = removeSpecialCharactersForFileName(title) + '.md'
const markdown = messageToMarkdown(message) const markdown = exportReasoning ? messageToMarkdownWithReasoning(message) : messageToMarkdown(message)
const result = await window.api.file.save(fileName, markdown) const result = await window.api.file.save(fileName, markdown)
if (result) { if (result) {
window.message.success({ window.message.success({
@ -84,7 +121,7 @@ export const exportMessageAsMarkdown = async (message: Message) => {
const timestamp = dayjs().format('YYYY-MM-DD-HH-mm-ss') const timestamp = dayjs().format('YYYY-MM-DD-HH-mm-ss')
const title = await getMessageTitle(message) const title = await getMessageTitle(message)
const fileName = removeSpecialCharactersForFileName(title) + ` ${timestamp}.md` const fileName = removeSpecialCharactersForFileName(title) + ` ${timestamp}.md`
const markdown = messageToMarkdown(message) const markdown = exportReasoning ? messageToMarkdownWithReasoning(message) : messageToMarkdown(message)
await window.api.file.write(markdownExportPath + '/' + fileName, markdown) await window.api.file.write(markdownExportPath + '/' + fileName, markdown)
window.message.success({ content: i18n.t('message.success.markdown.export.preconf'), key: 'markdown-success' }) window.message.success({ content: i18n.t('message.success.markdown.export.preconf'), key: 'markdown-success' })
} catch (error: any) { } catch (error: any) {