diff --git a/packages/shared/config/constant.ts b/packages/shared/config/constant.ts index 911a3cf90..e1fca4e6d 100644 --- a/packages/shared/config/constant.ts +++ b/packages/shared/config/constant.ts @@ -4,135 +4,368 @@ export const audioExts = ['.mp3', '.wav', '.ogg', '.flac', '.aac'] export const documentExts = ['.pdf', '.docx', '.pptx', '.xlsx', '.odt', '.odp', '.ods'] export const thirdPartyApplicationExts = ['.draftsExport'] export const bookExts = ['.epub'] -export const textExts = [ - '.txt', // 普通文本文件 - '.md', // Markdown 文件 - '.mdx', // Markdown 文件 - '.html', // HTML 文件 - '.htm', // HTML 文件的另一种扩展名 - '.xml', // XML 文件 - '.json', // JSON 文件 - '.yaml', // YAML 文件 - '.yml', // YAML 文件的另一种扩展名 - '.csv', // 逗号分隔值文件 - '.tsv', // 制表符分隔值文件 - '.ini', // 配置文件 - '.log', // 日志文件 - '.rtf', // 富文本格式文件 - '.org', // org-mode 文件 - '.wiki', // VimWiki 文件 - '.tex', // LaTeX 文件 - '.bib', // BibTeX 文件 - '.srt', // 字幕文件 - '.xhtml', // XHTML 文件 - '.nfo', // 信息文件(主要用于场景发布) - '.conf', // 配置文件 - '.config', // 配置文件 - '.env', // 环境变量文件 - '.rst', // reStructuredText 文件 - '.php', // PHP 脚本文件,包含嵌入的 HTML - '.js', // JavaScript 文件(部分是文本,部分可能包含代码) - '.ts', // TypeScript 文件 - '.jsp', // JavaServer Pages 文件 - '.aspx', // ASP.NET 文件 - '.bat', // Windows 批处理文件 - '.sh', // Unix/Linux Shell 脚本文件 - '.py', // Python 脚本文件 - '.ipynb', // Jupyter 笔记本格式 - '.rb', // Ruby 脚本文件 - '.pl', // Perl 脚本文件 - '.sql', // SQL 脚本文件 - '.css', // Cascading Style Sheets 文件 - '.less', // Less CSS 预处理器文件 - '.scss', // Sass CSS 预处理器文件 - '.sass', // Sass 文件 - '.styl', // Stylus CSS 预处理器文件 - '.coffee', // CoffeeScript 文件 - '.ino', // Arduino 代码文件 - '.asm', // Assembly 语言文件 - '.go', // Go 语言文件 - '.scala', // Scala 语言文件 - '.swift', // Swift 语言文件 - '.kt', // Kotlin 语言文件 - '.rs', // Rust 语言文件 - '.lua', // Lua 语言文件 - '.groovy', // Groovy 语言文件 - '.dart', // Dart 语言文件 - '.hs', // Haskell 语言文件 - '.clj', // Clojure 语言文件 - '.cljs', // ClojureScript 语言文件 - '.elm', // Elm 语言文件 - '.erl', // Erlang 语言文件 - '.ex', // Elixir 语言文件 - '.exs', // Elixir 脚本文件 - '.pug', // Pug (formerly Jade) 模板文件 - '.haml', // Haml 模板文件 - '.slim', // Slim 模板文件 - '.tpl', // 模板文件(通用) - '.ejs', // Embedded JavaScript 模板文件 - '.hbs', // Handlebars 模板文件 - '.mustache', // Mustache 模板文件 - '.jade', // Jade 模板文件 (已重命名为 Pug) - '.twig', // Twig 模板文件 - '.blade', // Blade 模板文件 (Laravel) - '.vue', // Vue.js 单文件组件 - '.jsx', // React JSX 文件 - '.tsx', // React TSX 文件 - '.graphql', // GraphQL 查询语言文件 - '.gql', // GraphQL 查询语言文件 - '.proto', // Protocol Buffers 文件 - '.thrift', // Thrift 文件 - '.toml', // TOML 配置文件 - '.edn', // Clojure 数据表示文件 - '.cake', // CakePHP 配置文件 - '.ctp', // CakePHP 视图文件 - '.cfm', // ColdFusion 标记语言文件 - '.cfc', // ColdFusion 组件文件 - '.m', // Objective-C 或 MATLAB 源文件 - '.mm', // Objective-C++ 源文件 - '.gradle', // Gradle 构建文件 - '.groovy', // Gradle 构建文件 - '.kts', // Kotlin Script 文件 - '.java', // Java 代码文件 - '.cs', // C# 代码文件 - '.cpp', // C++ 代码文件 - '.c', // C++ 代码文件 - '.h', // C++ 头文件 - '.hpp', // C++ 头文件 - '.cc', // C++ 源文件 - '.cxx', // C++ 源文件 - '.cppm', // C++20 模块接口文件 - '.ipp', // 模板实现文件 - '.ixx', // C++20 模块实现文件 - '.f90', // Fortran 90 源文件 - '.f', // Fortran 固定格式源代码文件 - '.f03', // Fortran 2003+ 源代码文件 - '.ahk', // AutoHotKey 语言文件 - '.tcl', // Tcl 脚本 - '.do', // Questa 或 Modelsim Tcl 脚本 - '.v', // Verilog 源文件 - '.sv', // SystemVerilog 源文件 - '.svh', // SystemVerilog 头文件 - '.vhd', // VHDL 源文件 - '.vhdl', // VHDL 源文件 - '.lef', // Library Exchange Format - '.def', // Design Exchange Format - '.edif', // Electronic Design Interchange Format - '.sdf', // Standard Delay Format - '.sdc', // Synopsys Design Constraints - '.xdc', // Xilinx Design Constraints - '.rpt', // 报告文件 - '.lisp', // Lisp 脚本 - '.il', // Cadence SKILL 脚本 - '.ils', // Cadence SKILL++ 脚本 - '.sp', // SPICE netlist 文件 - '.spi', // SPICE netlist 文件 - '.cir', // SPICE netlist 文件 - '.net', // SPICE netlist 文件 - '.scs', // Spectre netlist 文件 - '.asc', // LTspice netlist schematic 文件 - '.tf' // Technology File -] +const textExtsByCategory = new Map([ + [ + 'language', + [ + '.js', + '.mjs', + '.cjs', + '.ts', + '.jsx', + '.tsx', // JavaScript/TypeScript + '.py', // Python + '.java', // Java + '.cs', // C# + '.cpp', + '.c', + '.h', + '.hpp', + '.cc', + '.cxx', + '.cppm', + '.ipp', + '.ixx', // C/C++ + '.php', // PHP + '.rb', // Ruby + '.pl', // Perl + '.go', // Go + '.rs', // Rust + '.swift', // Swift + '.kt', + '.kts', // Kotlin + '.scala', // Scala + '.lua', // Lua + '.groovy', // Groovy + '.dart', // Dart + '.hs', // Haskell + '.clj', + '.cljs', // Clojure + '.elm', // Elm + '.erl', // Erlang + '.ex', + '.exs', // Elixir + '.ml', + '.mli', // OCaml + '.fs', // F# + '.r', + '.R', // R + '.sol', // Solidity + '.awk', // AWK + '.cob', // COBOL + '.asm', + '.s', // Assembly + '.lisp', + '.lsp', // Lisp + '.coffee', // CoffeeScript + '.ino', // Arduino + '.jl', // Julia + '.nim', // Nim + '.zig', // Zig + '.d', // D语言 + '.pas', // Pascal + '.vb', // Visual Basic + '.rkt', // Racket + '.scm', // Scheme + '.hx', // Haxe + '.as', // ActionScript + '.pde', // Processing + '.f90', + '.f', + '.f03', + '.for', + '.f95', // Fortran + '.adb', + '.ads', // Ada + '.pro', // Prolog + '.m', + '.mm', // Objective-C/MATLAB + '.rpy', // Ren'Py + '.ets', // OpenHarmony, + '.uniswap', // DeFi + '.vy', // Vyper + '.shader', + '.glsl', + '.frag', + '.vert', + '.gd' // Godot + ] + ], + [ + 'script', + [ + '.sh', // Shell + '.bat', + '.cmd', // Windows批处理 + '.ps1', // PowerShell + '.tcl', + '.do', // Tcl + '.ahk', // AutoHotkey + '.zsh', // Zsh + '.fish', // Fish shell + '.csh', // C shell + '.vbs', // VBScript + '.applescript', // AppleScript + '.au3', // AutoIt + '.bash', + '.nu' + ] + ], + [ + 'style', + [ + '.css', // CSS + '.less', // Less + '.scss', + '.sass', // Sass + '.styl', // Stylus + '.pcss', // PostCSS + '.postcss' // PostCSS + ] + ], + [ + 'template', + [ + '.vue', // Vue.js + '.pug', + '.jade', // Pug/Jade + '.haml', // Haml + '.slim', // Slim + '.tpl', // 通用模板 + '.ejs', // EJS + '.hbs', // Handlebars + '.mustache', // Mustache + '.twig', // Twig + '.blade', // Blade (Laravel) + '.liquid', // Liquid + '.jinja', + '.jinja2', + '.j2', // Jinja + '.erb', // ERB + '.vm', // Velocity + '.ftl', // FreeMarker + '.svelte', // Svelte + '.astro' // Astro + ] + ], + [ + 'config', + [ + '.ini', // INI配置 + '.conf', + '.config', // 通用配置 + '.env', // 环境变量 + '.toml', // TOML + '.cfg', // 通用配置 + '.properties', // Java属性 + '.desktop', // Linux桌面文件 + '.service', // systemd服务 + '.rc', + '.bashrc', + '.zshrc', // Shell配置 + '.fishrc', // Fish shell配置 + '.vimrc', // Vim配置 + '.htaccess', // Apache配置 + '.robots', // robots.txt + '.editorconfig', // EditorConfig + '.eslintrc', // ESLint + '.prettierrc', // Prettier + '.babelrc', // Babel + '.npmrc', // npm + '.dockerignore', // Docker ignore + '.npmignore', + '.yarnrc', + '.prettierignore', + '.eslintignore', + '.browserslistrc', + '.json5', + '.tfvars' + ] + ], + [ + 'document', + [ + '.txt', + '.text', // 纯文本 + '.md', + '.mdx', // Markdown + '.html', + '.htm', + '.xhtml', // HTML + '.xml', // XML + '.org', // Org-mode + '.wiki', // Wiki + '.tex', + '.bib', // LaTeX + '.rst', // reStructuredText + '.rtf', // 富文本 + '.nfo', // 信息文件 + '.adoc', + '.asciidoc', // AsciiDoc + '.pod', // Perl文档 + '.1', + '.2', + '.3', + '.4', + '.5', + '.6', + '.7', + '.8', + '.9', // man页面 + '.man', // man页面 + '.texi', + '.texinfo', // Texinfo + '.readme', + '.me', // README + '.changelog', // 变更日志 + '.license', // 许可证 + '.authors', // 作者文件 + '.po', + '.pot' + ] + ], + [ + 'data', + [ + '.json', // JSON + '.jsonc', // JSON with comments + '.yaml', + '.yml', // YAML + '.csv', + '.tsv', // 分隔值文件 + '.edn', // Clojure数据 + '.jsonl', + '.ndjson', // 换行分隔JSON + '.geojson', // GeoJSON + '.gpx', // GPS Exchange + '.kml', // Keyhole Markup + '.rss', + '.atom', // Feed格式 + '.vcf', // vCard + '.ics', // iCalendar + '.ldif', // LDAP数据交换 + '.pbtxt', + '.map' + ] + ], + [ + 'build', + [ + '.gradle', // Gradle + '.make', + '.mk', // Make + '.cmake', // CMake + '.sbt', // SBT + '.rake', // Rake + '.spec', // RPM spec + '.pom', + '.build', // Meson + '.bazel' // Bazel + ] + ], + [ + 'database', + [ + '.sql', // SQL + '.ddl', + '.dml', // DDL/DML + '.plsql', // PL/SQL + '.psql', // PostgreSQL + '.cypher', // Cypher + '.sparql' // SPARQL + ] + ], + [ + 'web', + [ + '.graphql', + '.gql', // GraphQL + '.proto', // Protocol Buffers + '.thrift', // Thrift + '.wsdl', // WSDL + '.raml', // RAML + '.swagger', + '.openapi' // API文档 + ] + ], + [ + 'version', + [ + '.gitignore', // Git ignore + '.gitattributes', // Git attributes + '.gitconfig', // Git config + '.hgignore', // Mercurial ignore + '.bzrignore', // Bazaar ignore + '.svnignore', // SVN ignore + '.githistory' // Git history + ] + ], + [ + 'subtitle', + [ + '.srt', + '.sub', + '.ass' // 字幕格式 + ] + ], + [ + 'log', + [ + '.log', + '.rpt' // 日志和报告 (移除了.out,因为通常是二进制可执行文件) + ] + ], + [ + 'eda', + [ + '.v', + '.sv', + '.svh', // Verilog/SystemVerilog + '.vhd', + '.vhdl', // VHDL + '.lef', + '.def', // LEF/DEF + '.edif', // EDIF + '.sdf', // SDF + '.sdc', + '.xdc', // 约束文件 + '.sp', + '.spi', + '.cir', + '.net', // SPICE + '.scs', // Spectre + '.asc', // LTspice + '.tf', // Technology File + '.il', + '.ils' // SKILL + ] + ], + [ + 'game', + [ + '.mtl', // Material Template Library + '.x3d', // X3D文件 + '.gltf', // glTF JSON + '.prefab', // Unity预制体 (YAML格式) + '.meta' // Unity元数据文件 (YAML格式) + ] + ], + [ + 'other', + [ + '.mcfunction', // Minecraft函数 + '.jsp', // JSP + '.aspx', // ASP.NET + '.ipynb', // Jupyter Notebook + '.cake', + '.ctp', // CakePHP + '.cfm', + '.cfc' // ColdFusion + ] + ] +]) + +export const textExts = Array.from(textExtsByCategory.values()).flat() export const ZOOM_LEVELS = [0.25, 0.33, 0.5, 0.67, 0.75, 0.8, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2, 2.5, 3, 4, 5] diff --git a/src/main/services/SelectionService.ts b/src/main/services/SelectionService.ts index e4f33c2cf..f785683b5 100644 --- a/src/main/services/SelectionService.ts +++ b/src/main/services/SelectionService.ts @@ -304,7 +304,12 @@ export class SelectionService { if (!this.selectionHook) return false this.selectionHook.stop() - this.selectionHook.cleanup() + this.selectionHook.cleanup() //already remove all listeners + + //reset the listener states + this.isCtrlkeyListenerActive = false + this.isHideByMouseKeyListenerActive = false + if (this.toolbarWindow) { this.toolbarWindow.close() this.toolbarWindow = null @@ -836,6 +841,8 @@ export class SelectionService { //ctrlkey pressed if (this.lastCtrlkeyDownTime === 0) { this.lastCtrlkeyDownTime = Date.now() + //add the mouse-wheel listener, detect if user is zooming in/out + this.selectionHook!.on('mouse-wheel', this.handleMouseWheelCtrlkeyMode) return } @@ -859,9 +866,20 @@ export class SelectionService { */ private handleKeyUpCtrlkeyMode = (data: KeyboardEventData) => { if (!this.isCtrlkey(data.vkCode)) return + //remove the mouse-wheel listener + this.selectionHook!.off('mouse-wheel', this.handleMouseWheelCtrlkeyMode) this.lastCtrlkeyDownTime = 0 } + /** + * Handle mouse wheel events in ctrlkey trigger mode + * ignore CtrlKey pressing when mouse wheel is used + * because user is zooming in/out + */ + private handleMouseWheelCtrlkeyMode = () => { + this.lastCtrlkeyDownTime = -1 + } + //check if the key is ctrl key private isCtrlkey(vkCode: number) { return vkCode === 162 || vkCode === 163 diff --git a/src/renderer/src/components/ObsidianExportDialog.tsx b/src/renderer/src/components/ObsidianExportDialog.tsx index 267473f48..631c085ca 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 23a7da2dc..49dc320c7 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/context/AntdProvider.tsx b/src/renderer/src/context/AntdProvider.tsx index 737b26044..570da9fc4 100644 --- a/src/renderer/src/context/AntdProvider.tsx +++ b/src/renderer/src/context/AntdProvider.tsx @@ -45,6 +45,9 @@ const AntdProvider: FC = ({ children }) => { }, Tooltip: { fontSize: 13 + }, + ColorPicker: { + fontFamily: 'var(--code-font-family)' } }, token: { diff --git a/src/renderer/src/hooks/useSettings.ts b/src/renderer/src/hooks/useSettings.ts index 66d9615ee..c058a9523 100644 --- a/src/renderer/src/hooks/useSettings.ts +++ b/src/renderer/src/hooks/useSettings.ts @@ -8,6 +8,7 @@ import { setLaunchToTray, setPinTopicsToTop, setSendMessageShortcut as _setSendMessageShortcut, + setShowTokens, setSidebarIcons, setTargetLanguage, setTheme, @@ -83,6 +84,9 @@ export function useSettings() { }, setAssistantIconType(assistantIconType: AssistantIconType) { dispatch(setAssistantIconType(assistantIconType)) + }, + setShowTokens(showTokens: boolean) { + dispatch(setShowTokens(showTokens)) } } } diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 15dd4ede3..aeac3e754 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...", @@ -1112,7 +1113,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", @@ -1121,12 +1124,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", @@ -1140,10 +1145,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", @@ -1506,6 +1510,7 @@ "advancedSettings": "Advanced Settings" }, "messages.prompt": "Show prompt", + "messages.tokens": "Show token usage", "messages.divider": "Show divider between messages", "messages.grid_columns": "Message grid display columns", "messages.grid_popover_trigger": "Grid detail trigger", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 71046d1df..3c05bfb16 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": "翻訳中...", @@ -1110,7 +1111,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数式に$$を強制使用", @@ -1119,29 +1122,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": "自動バックアップ", @@ -1502,6 +1508,7 @@ "advancedSettings": "詳細設定" }, "messages.prompt": "プロンプト表示", + "messages.tokens": "トークン使用量を表示", "messages.divider": "メッセージ間に区切り線を表示", "messages.grid_columns": "メッセージグリッドの表示列数", "messages.grid_popover_trigger": "グリッド詳細トリガー", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 20c94c520..a79d4454b 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": "Перевод...", @@ -1110,7 +1111,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", @@ -1119,12 +1122,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", @@ -1138,10 +1143,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": "Автоматическое резервное копирование", @@ -1502,6 +1506,7 @@ "advancedSettings": "Расширенные настройки" }, "messages.prompt": "Показывать подсказки", + "messages.tokens": "Показать использование токенов", "messages.divider": "Показывать разделитель между сообщениями", "messages.grid_columns": "Количество столбцов сетки сообщений", "messages.grid_popover_trigger": "Триггер для отображения подробной информации в сетке", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 47d907eb2..f891a95f2 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": "导出到语雀", @@ -662,7 +663,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": "切换模型回答", @@ -1112,7 +1113,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公式", @@ -1121,14 +1124,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", @@ -1142,10 +1147,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": "自动备份", @@ -1506,6 +1510,7 @@ "advancedSettings": "高级设置" }, "messages.prompt": "显示提示词", + "messages.tokens": "显示Token用量", "messages.divider": "消息分割线", "messages.grid_columns": "消息网格展示列数", "messages.grid_popover_trigger": "网格详情触发", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index ddb8d31a2..b4bc2a3b1 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": "翻譯中...", @@ -1112,7 +1113,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公式強制使用$$", @@ -1121,12 +1124,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", @@ -1140,10 +1145,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": "自動備份", @@ -1505,6 +1509,7 @@ "advancedSettings": "高級設定" }, "messages.prompt": "提示詞顯示", + "messages.tokens": "Token用量顯示", "messages.divider": "訊息間顯示分隔線", "messages.grid_columns": "訊息網格展示列數", "messages.grid_popover_trigger": "網格詳細資訊觸發", diff --git a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx index 7df542588..0073eee79 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/Messages/MessageTokens.tsx b/src/renderer/src/pages/home/Messages/MessageTokens.tsx index 2695767f4..26c5cdc0c 100644 --- a/src/renderer/src/pages/home/Messages/MessageTokens.tsx +++ b/src/renderer/src/pages/home/Messages/MessageTokens.tsx @@ -1,4 +1,5 @@ // import { useRuntime } from '@renderer/hooks/useRuntime' +import { useSettings } from '@renderer/hooks/useSettings' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import type { Message } from '@renderer/types/newMessage' import { Popover } from 'antd' @@ -11,6 +12,7 @@ interface MessageTokensProps { } const MessgeTokens: React.FC = ({ message }) => { + const { showTokens } = useSettings() // const { generating } = useRuntime() const locateMessage = () => { EventEmitter.emit(EVENT_NAMES.LOCATE_MESSAGE + ':' + message.id, false) @@ -23,7 +25,7 @@ const MessgeTokens: React.FC = ({ message }) => { if (message.role === 'user') { return ( - Tokens: {message?.usage?.total_tokens} + {showTokens && `Tokens: ${message?.usage?.total_tokens}`} ) } @@ -54,7 +56,7 @@ const MessgeTokens: React.FC = ({ message }) => { {hasMetrics ? ( - {tokensInfo} + {showTokens && tokensInfo} ) : ( tokensInfo diff --git a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx index 06792f789..e3f2f72cd 100644 --- a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx @@ -46,6 +46,7 @@ import { setShowInputEstimatedTokens, setShowMessageDivider, setShowPrompt, + setShowTokens, setShowTranslateConfirm, setThoughtAutoCollapse } from '@renderer/store/settings' @@ -113,7 +114,8 @@ const SettingsTab: FC = (props) => { messageNavigation, enableQuickPanelTriggers, enableBackspaceDeleteModel, - showTranslateConfirm + showTranslateConfirm, + showTokens } = useSettings() const onUpdateAssistantSettings = (settings: Partial) => { @@ -336,6 +338,11 @@ const SettingsTab: FC = (props) => { dispatch(setShowPrompt(checked))} /> + + {t('settings.messages.tokens')} + dispatch(setShowTokens(checked))} /> + + {t('settings.messages.divider')} = ({ assistant: _assistant, activeTopic, setActiveTopic if (messages.length >= 2) { const summaryText = await fetchMessagesSummary({ messages, assistant }) if (summaryText) { - updateTopic({ ...topic, name: summaryText, isNameManuallyEdited: false }) + const updatedTopic = { ...topic, name: summaryText, isNameManuallyEdited: false } + updateTopic(updatedTopic) + topic.id === activeTopic.id && setActiveTopic(updatedTopic) } else { window.message?.error(t('message.error.fetchTopicName')) } @@ -192,7 +194,9 @@ const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic defaultValue: topic?.name || '' }) if (name && topic?.name !== name) { - updateTopic({ ...topic, name, isNameManuallyEdited: true }) + const updatedTopic = { ...topic, name, isNameManuallyEdited: true } + updateTopic(updatedTopic) + topic.id === activeTopic.id && setActiveTopic(updatedTopic) } } }, @@ -308,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 322fdd883..3574c808d 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 e4c816927..2ed7cb7f4 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 de670ad43..719a2363d 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/pages/settings/MCPSettings/InstallNpxUv.tsx b/src/renderer/src/pages/settings/MCPSettings/InstallNpxUv.tsx index d5c4389c4..8154132ae 100644 --- a/src/renderer/src/pages/settings/MCPSettings/InstallNpxUv.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/InstallNpxUv.tsx @@ -3,7 +3,7 @@ import { Center, VStack } from '@renderer/components/Layout' import { useAppDispatch, useAppSelector } from '@renderer/store' import { setIsBunInstalled, setIsUvInstalled } from '@renderer/store/mcp' import { Alert, Button } from 'antd' -import { FC, useEffect, useState } from 'react' +import { FC, useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router' import styled from 'styled-components' @@ -26,7 +26,7 @@ const InstallNpxUv: FC = ({ mini = false }) => { const [binariesDir, setBinariesDir] = useState(null) const { t } = useTranslation() const navigate = useNavigate() - const checkBinaries = async () => { + const checkBinaries = useCallback(async () => { const uvExists = await window.api.isBinaryExist('uv') const bunExists = await window.api.isBinaryExist('bun') const { uvPath, bunPath, dir } = await window.api.mcp.getInstallInfo() @@ -36,7 +36,7 @@ const InstallNpxUv: FC = ({ mini = false }) => { setUvPath(uvPath) setBunPath(bunPath) setBinariesDir(dir) - } + }, [dispatch]) const installUV = async () => { try { @@ -69,7 +69,7 @@ const InstallNpxUv: FC = ({ mini = false }) => { useEffect(() => { checkBinaries() - }, []) + }, [checkBinaries]) if (mini) { const installed = isUvInstalled && isBunInstalled diff --git a/src/renderer/src/providers/AiProvider/AnthropicProvider.ts b/src/renderer/src/providers/AiProvider/AnthropicProvider.ts index 1f53f5d05..855c9adc6 100644 --- a/src/renderer/src/providers/AiProvider/AnthropicProvider.ts +++ b/src/renderer/src/providers/AiProvider/AnthropicProvider.ts @@ -445,6 +445,14 @@ export default class AnthropicProvider extends BaseProvider { ) } + if (thinking_content) { + onChunk({ + type: ChunkType.THINKING_COMPLETE, + text: thinking_content, + thinking_millsec: new Date().getTime() - time_first_token_millsec + }) + } + userMessages.push({ role: message.role, content: message.content @@ -464,18 +472,31 @@ export default class AnthropicProvider extends BaseProvider { } } - finalUsage.prompt_tokens += message.usage?.input_tokens || 0 - finalUsage.completion_tokens += message.usage?.output_tokens || 0 - finalUsage.total_tokens += finalUsage.prompt_tokens + finalUsage.completion_tokens - finalMetrics.completion_tokens = finalUsage.completion_tokens - finalMetrics.time_completion_millsec += new Date().getTime() - start_time_millsec - finalMetrics.time_first_token_millsec = time_first_token_millsec - start_time_millsec + // 直接修改finalUsage对象会报错,TypeError: Cannot assign to read only property 'prompt_tokens' of object '#' + // 暂未找到原因 + const updatedUsage: Usage = { + ...finalUsage, + prompt_tokens: finalUsage.prompt_tokens + (message.usage?.input_tokens || 0), + completion_tokens: finalUsage.completion_tokens + (message.usage?.output_tokens || 0) + } + updatedUsage.total_tokens = updatedUsage.prompt_tokens + updatedUsage.completion_tokens + + const updatedMetrics: Metrics = { + ...finalMetrics, + completion_tokens: updatedUsage.completion_tokens, + time_completion_millsec: + finalMetrics.time_completion_millsec + (new Date().getTime() - start_time_millsec), + time_first_token_millsec: time_first_token_millsec - start_time_millsec + } + + Object.assign(finalUsage, updatedUsage) + Object.assign(finalMetrics, updatedMetrics) onChunk({ type: ChunkType.BLOCK_COMPLETE, response: { - usage: finalUsage, - metrics: finalMetrics + usage: updatedUsage, + metrics: updatedMetrics } }) resolve() @@ -488,7 +509,9 @@ export default class AnthropicProvider extends BaseProvider { } onChunk({ type: ChunkType.LLM_RESPONSE_CREATED }) const start_time_millsec = new Date().getTime() - await processStream(body, 0).finally(cleanup) + await processStream(body, 0).finally(() => { + cleanup() + }) } /** diff --git a/src/renderer/src/providers/AiProvider/OpenAIResponseProvider.ts b/src/renderer/src/providers/AiProvider/OpenAIResponseProvider.ts index 6b2896533..e59b3fabe 100644 --- a/src/renderer/src/providers/AiProvider/OpenAIResponseProvider.ts +++ b/src/renderer/src/providers/AiProvider/OpenAIResponseProvider.ts @@ -571,6 +571,16 @@ export abstract class BaseOpenAIProvider extends BaseProvider { if (time_first_token_millsec === 0) { time_first_token_millsec = new Date().getTime() } + // Insert separation between summary parts + if (thinkContent.length > 0) { + const separator = '\n\n' + onChunk({ + type: ChunkType.THINKING_DELTA, + text: separator, + thinking_millsec: new Date().getTime() - time_first_token_millsec + }) + thinkContent += separator + } break case 'response.reasoning_summary_text.delta': onChunk({ @@ -944,28 +954,32 @@ export abstract class BaseOpenAIProvider extends BaseProvider { if (!model) { return { valid: false, error: new Error('No model found') } } - if (stream) { - const response = await this.sdk.responses.create({ - model: model.id, - input: [{ role: 'user', content: 'hi' }], - stream: true - }) - for await (const chunk of response) { - if (chunk.type === 'response.output_text.delta') { - return { valid: true, error: null } + try { + if (stream) { + const response = await this.sdk.responses.create({ + model: model.id, + input: [{ role: 'user', content: 'hi' }], + stream: true + }) + for await (const chunk of response) { + if (chunk.type === 'response.output_text.delta') { + return { valid: true, error: null } + } } + return { valid: false, error: new Error('No streaming response') } + } else { + const response = await this.sdk.responses.create({ + model: model.id, + input: [{ role: 'user', content: 'hi' }], + stream: false + }) + if (!response.output_text) { + return { valid: false, error: new Error('No response') } + } + return { valid: true, error: null } } - throw new Error('Empty streaming response') - } else { - const response = await this.sdk.responses.create({ - model: model.id, - input: [{ role: 'user', content: 'hi' }], - stream: false - }) - if (!response.output_text) { - throw new Error('Empty response') - } - return { valid: true, error: null } + } catch (error: any) { + return { valid: false, error: error } } } diff --git a/src/renderer/src/services/ApiService.ts b/src/renderer/src/services/ApiService.ts index 57f1e104b..d0ebdb4c2 100644 --- a/src/renderer/src/services/ApiService.ts +++ b/src/renderer/src/services/ApiService.ts @@ -471,7 +471,7 @@ export function checkApiProvider(provider: Provider): { } } -export async function checkApi(provider: Provider, model: Model) { +export async function checkApi(provider: Provider, model: Model): Promise<{ valid: boolean; error: Error | null }> { const validation = checkApiProvider(provider) if (!validation.valid) { return { @@ -484,9 +484,16 @@ export async function checkApi(provider: Provider, model: Model) { // Try streaming check first const result = await ai.check(model, true) + if (result.valid && !result.error) { return result } - return ai.check(model, false) + // 不应该假设错误由流式引发。多次发起检测请求可能触发429,掩盖了真正的问题。 + // 但这里错误类型做的很粗糙,暂时先这样 + if (result.error && result.error.message.includes('stream')) { + return ai.check(model, false) + } else { + return result + } } diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index 1b65749f0..f68dcc021 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -1521,6 +1521,7 @@ const migrateConfig = { provider.type = 'mistral' } }) + state.settings.showTokens = true return state } catch (error) { return state diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index 0df32b583..2bcd57e3d 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -47,6 +47,7 @@ export interface SettingsState { proxyUrl?: string userName: string showPrompt: boolean + showTokens: boolean showMessageDivider: boolean messageFont: 'system' | 'serif' showInputEstimatedTokens: boolean @@ -128,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 // 思源笔记配置 @@ -191,6 +194,7 @@ export const initialState: SettingsState = { proxyUrl: undefined, userName: '', showPrompt: true, + showTokens: true, showMessageDivider: true, messageFont: 'system', showInputEstimatedTokens: false, @@ -269,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, @@ -355,6 +361,9 @@ const settingsSlice = createSlice({ setShowPrompt: (state, action: PayloadAction) => { state.showPrompt = action.payload }, + setShowTokens: (state, action: PayloadAction) => { + state.showTokens = action.payload + }, setShowMessageDivider: (state, action: PayloadAction) => { state.showMessageDivider = action.payload }, @@ -575,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 @@ -599,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 }, @@ -660,6 +675,8 @@ const settingsSlice = createSlice({ }) export const { + setShowModelNameInMarkdown, + setShowModelProviderInMarkdown, setShowAssistants, toggleShowAssistants, setShowTopics, @@ -672,6 +689,7 @@ export const { setProxyUrl, setUserName, setShowPrompt, + setShowTokens, setShowMessageDivider, setMessageFont, setShowInputEstimatedTokens, @@ -731,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 ef52156fb..0c858647b 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 110820709..66e56dc51 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') }