Merge branch 'main' into feat-knowlege-ocr

This commit is contained in:
eeee0717
2025-06-05 15:40:54 +08:00
26 changed files with 917 additions and 469 deletions
+362 -129
View File
@@ -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]
+19 -1
View File
@@ -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
@@ -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<ObsidianExportDialogProps> = ({
const PopupContainer: React.FC<PopupContainerProps> = ({
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<ObsidianExportDialogProps> = ({
processingMethod: processingMethod,
folder: ''
})
// 是否手动编辑过标题
const [hasTitleBeenManuallyEdited, setHasTitleBeenManuallyEdited] = useState(false)
const [vaults, setVaults] = useState<Array<{ path: string; name: string }>>([])
const [files, setFiles] = useState<FileInfo[]>([])
@@ -139,8 +149,8 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
const [selectedVault, setSelectedVault] = useState<string>('')
const [loading, setLoading] = useState<boolean>(false)
const [error, setError] = useState<string | null>(null)
const [exportReasoning, setExportReasoning] = useState(false)
// 处理文件数据转为树形结构
useEffect(() => {
if (files.length > 0) {
const treeData = convertToTreeData(files)
@@ -157,28 +167,21 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
}
}, [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<ObsidianExportDialogProps> = ({
setLoading(false)
}
}
fetchVaults()
}, [defaultObsidianVault])
// 当选择的vault变化时,获取其文件和文件夹
useEffect(() => {
if (selectedVault) {
const fetchFiles = async () => {
@@ -209,7 +210,6 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
setLoading(false)
}
}
fetchFiles()
}
}, [selectedVault])
@@ -219,82 +219,71 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
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<ObsidianExportDialogProps> = ({
return (
<Modal
title={i18n.t('chat.topics.export.obsidian_atributes')}
open={open}
open={openState}
onOk={handleOk}
onCancel={handleCancel}
width={600}
@@ -317,9 +306,9 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
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 && <Alert message={error} type="error" showIcon style={{ marginBottom: 16 }} />}
<Form layout="horizontal" labelCol={{ span: 6 }} wrapperCol={{ span: 18 }} labelAlign="left">
<Form.Item label={i18n.t('chat.topics.export.obsidian_title')}>
<Input
@@ -328,7 +317,6 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
placeholder={i18n.t('chat.topics.export.obsidian_title_placeholder')}
/>
</Form.Item>
<Form.Item label={i18n.t('chat.topics.export.obsidian_vault')}>
{vaults.length > 0 ? (
<Select
@@ -354,7 +342,6 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
/>
)}
</Form.Item>
<Form.Item label={i18n.t('chat.topics.export.obsidian_path')}>
<Spin spinning={loading}>
{selectedVault ? (
@@ -376,7 +363,6 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
)}
</Spin>
</Form.Item>
<Form.Item label={i18n.t('chat.topics.export.obsidian_tags')}>
<Input
value={state.tags}
@@ -398,7 +384,6 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
placeholder={i18n.t('chat.topics.export.obsidian_source_placeholder')}
/>
</Form.Item>
<Form.Item label={i18n.t('chat.topics.export.obsidian_operate')}>
<Select
value={state.processingMethod}
@@ -410,9 +395,12 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
<Option value="3">{i18n.t('chat.topics.export.obsidian_operate_new_or_overwrite')}</Option>
</Select>
</Form.Item>
<Form.Item label={i18n.t('chat.topics.export.obsidian_reasoning')}>
<Switch checked={exportReasoning} onChange={setExportReasoning} />
</Form.Item>
</Form>
</Modal>
)
}
export default ObsidianExportDialog
export { PopupContainer }
@@ -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<boolean> => {
return new Promise<boolean>((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(
<ObsidianExportDialog
title={options.title}
markdown={options.markdown}
obsidianTags=""
processingMethod={options.processingMethod}
open={true}
onClose={handleClose}
/>
)
})
}
export default {
show: showObsidianExportDialog
export default class ObsidianExportPopup {
static hide() {
TopView.hide('ObsidianExportPopup')
}
static show(options: ObsidianExportOptions): Promise<boolean> {
return new Promise((resolve) => {
TopView.show(
<PopupContainer
title={options.title}
processingMethod={options.processingMethod}
topic={options.topic}
message={options.message}
messages={options.messages}
obsidianTags={''}
open={true}
resolve={(v) => {
resolve(v)
ObsidianExportPopup.hide()
}}
/>,
'ObsidianExportPopup'
)
})
}
}
@@ -45,6 +45,9 @@ const AntdProvider: FC<PropsWithChildren> = ({ children }) => {
},
Tooltip: {
fontSize: 13
},
ColorPicker: {
fontFamily: 'var(--code-font-family)'
}
},
token: {
+4
View File
@@ -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))
}
}
}
+12 -7
View File
@@ -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",
+28 -21
View File
@@ -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": "グリッド詳細トリガー",
+11 -6
View File
@@ -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": "Триггер для отображения подробной информации в сетке",
+13 -8
View File
@@ -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": "网格详情触发",
+11 -6
View File
@@ -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": "網格詳細資訊觸發",
@@ -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> = (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> = (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> = (props) => {
key: 'joplin',
onClick: async () => {
const title = await getMessageTitle(message)
const markdown = messageToMarkdown(message)
exportMarkdownToJoplin(title, markdown)
exportMarkdownToJoplin(title, message)
}
},
exportMenuOptions.siyuan && {
@@ -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<MessageTokensProps> = ({ 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<MessageTokensProps> = ({ message }) => {
if (message.role === 'user') {
return (
<MessageMetadata className="message-tokens" onClick={locateMessage}>
Tokens: {message?.usage?.total_tokens}
{showTokens && `Tokens: ${message?.usage?.total_tokens}`}
</MessageMetadata>
)
}
@@ -54,7 +56,7 @@ const MessgeTokens: React.FC<MessageTokensProps> = ({ message }) => {
<MessageMetadata className="message-tokens" onClick={locateMessage}>
{hasMetrics ? (
<Popover content={metrixs} placement="top" trigger="hover" styles={{ root: { fontSize: 11 } }}>
{tokensInfo}
{showTokens && tokensInfo}
</Popover>
) : (
tokensInfo
@@ -46,6 +46,7 @@ import {
setShowInputEstimatedTokens,
setShowMessageDivider,
setShowPrompt,
setShowTokens,
setShowTranslateConfirm,
setThoughtAutoCollapse
} from '@renderer/store/settings'
@@ -113,7 +114,8 @@ const SettingsTab: FC<Props> = (props) => {
messageNavigation,
enableQuickPanelTriggers,
enableBackspaceDeleteModel,
showTranslateConfirm
showTranslateConfirm,
showTokens
} = useSettings()
const onUpdateAssistantSettings = (settings: Partial<AssistantSettings>) => {
@@ -336,6 +338,11 @@ const SettingsTab: FC<Props> = (props) => {
<Switch size="small" checked={showPrompt} onChange={(checked) => dispatch(setShowPrompt(checked))} />
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('settings.messages.tokens')}</SettingRowTitleSmall>
<Switch size="small" checked={showTokens} onChange={(checked) => dispatch(setShowTokens(checked))} />
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('settings.messages.divider')}</SettingRowTitleSmall>
<Switch
@@ -174,7 +174,9 @@ const Topics: FC<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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 && {
@@ -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<HTMLInputElement>) => {
dispatch(setJoplinToken(e.target.value))
@@ -72,6 +73,10 @@ const JoplinSettings: FC = () => {
})
}
const handleToggleJoplinExportReasoning = (checked: boolean) => {
dispatch(setJoplinExportReasoning(checked))
}
return (
<SettingGroup theme={theme}>
<SettingTitle>{t('settings.data.joplin.title')}</SettingTitle>
@@ -111,6 +116,14 @@ const JoplinSettings: FC = () => {
<Button onClick={handleJoplinConnectionCheck}>{t('settings.data.joplin.check.button')}</Button>
</HStack>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.joplin.export_reasoning.title')}</SettingRowTitle>
<Switch checked={joplinExportReasoning} onChange={handleToggleJoplinExportReasoning} />
</SettingRow>
<SettingRow>
<SettingHelpText>{t('settings.data.joplin.export_reasoning.help')}</SettingHelpText>
</SettingRow>
</SettingGroup>
)
}
@@ -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 (
<SettingGroup theme={theme}>
<SettingTitle>{t('settings.data.markdown_export.title')}</SettingTitle>
@@ -86,6 +98,22 @@ const MarkdownExportSettings: FC = () => {
<SettingRow>
<SettingHelpText>{t('settings.data.message_title.use_topic_naming.help')}</SettingHelpText>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.markdown_export.show_model_name.title')}</SettingRowTitle>
<Switch checked={showModelNameInExport} onChange={handleToggleShowModelName} />
</SettingRow>
<SettingRow>
<SettingHelpText>{t('settings.data.markdown_export.show_model_name.help')}</SettingHelpText>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.markdown_export.show_model_provider.title')}</SettingRowTitle>
<Switch checked={showModelProviderInMarkdown} onChange={handleToggleShowModelProvider} />
</SettingRow>
<SettingRow>
<SettingHelpText>{t('settings.data.markdown_export.show_model_provider.help')}</SettingHelpText>
</SettingRow>
</SettingGroup>
)
}
@@ -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<HTMLInputElement>) => {
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 = () => {
<Button onClick={handleNotionConnectionCheck}>{t('settings.data.notion.check.button')}</Button>
</HStack>
</SettingRow>
<SettingDivider /> {/* 添加分割线 */}
<SettingDivider />
<SettingRow>
<SettingRowTitle>
<Tooltip title={t('settings.data.notion.auto_split_tip')} placement="right">
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
{t('settings.data.notion.auto_split')}
<InfoCircleOutlined style={{ cursor: 'pointer' }} />
</span>
</Tooltip>
</SettingRowTitle>
<Switch checked={notionAutoSplit} onChange={handleNotionAutoSplitChange} />
<SettingRowTitle>{t('settings.data.notion.export_reasoning.title')}</SettingRowTitle>
<Switch checked={notionExportReasoning} onChange={handleNotionExportReasoningChange} />
</SettingRow>
<SettingRow>
<SettingHelpText>{t('settings.data.notion.export_reasoning.help')}</SettingHelpText>
</SettingRow>
{notionAutoSplit && (
<>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.notion.split_size')}</SettingRowTitle>
<InputNumber
min={30}
max={25000}
value={notionSplitSize}
onChange={handleNotionSplitSizeChange}
keyboard={true}
controls={true}
style={{ width: 120 }}
/>
</SettingRow>
<SettingRow>
<SettingHelpText style={{ marginLeft: 10 }}>{t('settings.data.notion.split_size_help')}</SettingHelpText>
</SettingRow>
</>
)}
</SettingGroup>
)
}
@@ -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<Props> = ({ mini = false }) => {
const [binariesDir, setBinariesDir] = useState<string | null>(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<Props> = ({ mini = false }) => {
setUvPath(uvPath)
setBunPath(bunPath)
setBinariesDir(dir)
}
}, [dispatch])
const installUV = async () => {
try {
@@ -69,7 +69,7 @@ const InstallNpxUv: FC<Props> = ({ mini = false }) => {
useEffect(() => {
checkBinaries()
}, [])
}, [checkBinaries])
if (mini) {
const installed = isUvInstalled && isBunInstalled
@@ -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 '#<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()
})
}
/**
@@ -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 }
}
}
+9 -2
View File
@@ -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
}
}
+1
View File
@@ -1521,6 +1521,7 @@ const migrateConfig = {
provider.type = 'mistral'
}
})
state.settings.showTokens = true
return state
} catch (error) {
return state
+29 -11
View File
@@ -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<boolean>) => {
state.showPrompt = action.payload
},
setShowTokens: (state, action: PayloadAction<boolean>) => {
state.showTokens = action.payload
},
setShowMessageDivider: (state, action: PayloadAction<boolean>) => {
state.showMessageDivider = action.payload
},
@@ -575,14 +584,17 @@ const settingsSlice = createSlice({
setUseTopicNamingForMessageTitle: (state, action: PayloadAction<boolean>) => {
state.useTopicNamingForMessageTitle = action.payload
},
setShowModelNameInMarkdown: (state, action: PayloadAction<boolean>) => {
state.showModelNameInMarkdown = action.payload
},
setShowModelProviderInMarkdown: (state, action: PayloadAction<boolean>) => {
state.showModelProviderInMarkdown = action.payload
},
setThoughtAutoCollapse: (state, action: PayloadAction<boolean>) => {
state.thoughtAutoCollapse = action.payload
},
setNotionAutoSplit: (state, action: PayloadAction<boolean>) => {
state.notionAutoSplit = action.payload
},
setNotionSplitSize: (state, action: PayloadAction<number>) => {
state.notionSplitSize = action.payload
setNotionExportReasoning: (state, action: PayloadAction<boolean>) => {
state.notionExportReasoning = action.payload
},
setYuqueToken: (state, action: PayloadAction<string>) => {
state.yuqueToken = action.payload
@@ -599,6 +611,9 @@ const settingsSlice = createSlice({
setJoplinUrl: (state, action: PayloadAction<string>) => {
state.joplinUrl = action.payload
},
setJoplinExportReasoning: (state, action: PayloadAction<boolean>) => {
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,
+174 -75
View File
@@ -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('<think>')) {
reasoningContent = reasoningContent.substring(7)
}
reasoningContent = reasoningContent.replace(/\n/g, '<br>')
reasoningContent = reasoningContent
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/\n/g, '<br>')
if (forceDollarMathInMarkdown) {
reasoningContent = convertMathFormula(reasoningContent)
}
reasoningSection = `<details style="background-color: #f5f5f5; padding: 5px; border-radius: 10px; margin-bottom: 10px;">
<summary>${i18n.t('common.reasoning_content')}</summary><hr>
reasoningSection = `<div style="border: 2px solid #dddddd; border-radius: 10px;">
<details style="padding: 5px;">
<summary>${i18n.t('common.reasoning_content')}</summary>
${reasoningContent}
</details>`
</details>
</div>`
}
}
@@ -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<any[]> => {
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<any> => {
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}`, {
+1 -1
View File
@@ -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')
}