mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-06 05:09:09 +08:00
Merge branch 'main' of github.com:CherryHQ/cherry-studio into v2
This commit is contained in:
commit
e5a3363021
4
.github/workflows/auto-i18n.yml
vendored
4
.github/workflows/auto-i18n.yml
vendored
@ -2,8 +2,8 @@ name: Auto I18N
|
||||
|
||||
env:
|
||||
API_KEY: ${{ secrets.TRANSLATE_API_KEY }}
|
||||
MODEL: ${{ vars.MODEL || 'deepseek/deepseek-v3.1'}}
|
||||
BASE_URL: ${{ vars.BASE_URL || 'https://api.ppinfra.com/openai'}}
|
||||
MODEL: ${{ vars.AUTO_I18N_MODEL || 'deepseek/deepseek-v3.1'}}
|
||||
BASE_URL: ${{ vars.AUTO_I18N_BASE_URL || 'https://api.ppinfra.com/openai'}}
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
24
.github/workflows/nightly-build.yml
vendored
24
.github/workflows/nightly-build.yml
vendored
@ -99,9 +99,9 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
|
||||
- name: Build Mac
|
||||
if: matrix.os == 'macos-latest'
|
||||
@ -110,15 +110,15 @@ jobs:
|
||||
env:
|
||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||
APPLE_ID: ${{ vars.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ vars.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
|
||||
- name: Build Windows
|
||||
if: matrix.os == 'windows-latest'
|
||||
@ -128,9 +128,9 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
|
||||
- name: Rename artifacts with nightly format
|
||||
shell: bash
|
||||
|
||||
2
.github/workflows/pr-ci.yml
vendored
2
.github/workflows/pr-ci.yml
vendored
@ -10,12 +10,14 @@ on:
|
||||
- main
|
||||
- develop
|
||||
- v2
|
||||
types: [ready_for_review, synchronize, opened]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PRCI: true
|
||||
if: github.event.pull_request.draft == false
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
|
||||
24
.github/workflows/release.yml
vendored
24
.github/workflows/release.yml
vendored
@ -86,9 +86,9 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
|
||||
- name: Build Mac
|
||||
if: matrix.os == 'macos-latest'
|
||||
@ -98,15 +98,15 @@ jobs:
|
||||
env:
|
||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||
APPLE_ID: ${{ vars.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ vars.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
|
||||
- name: Build Windows
|
||||
if: matrix.os == 'windows-latest'
|
||||
@ -116,9 +116,9 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
|
||||
- name: Release
|
||||
uses: ncipollo/release-action@v1
|
||||
|
||||
13
.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch
vendored
Normal file
13
.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
diff --git a/dist/index.mjs b/dist/index.mjs
|
||||
index 110f37ec18c98b1d55ae2b73cc716194e6f9094d..17e109b7778cbebb904f1919e768d21a2833d965 100644
|
||||
--- a/dist/index.mjs
|
||||
+++ b/dist/index.mjs
|
||||
@@ -448,7 +448,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
|
||||
|
||||
// src/get-model-path.ts
|
||||
function getModelPath(modelId) {
|
||||
- return modelId.includes("/") ? modelId : `models/${modelId}`;
|
||||
+ return modelId?.includes("models/") ? modelId : `models/${modelId}`;
|
||||
}
|
||||
|
||||
// src/google-generative-ai-options.ts
|
||||
@ -128,13 +128,7 @@ afterSign: scripts/notarize.js
|
||||
artifactBuildCompleted: scripts/artifact-build-completed.js
|
||||
releaseInfo:
|
||||
releaseNotes: |
|
||||
🎨 界面优化:
|
||||
- 优化了多个组件的布局和间距,提升视觉体验
|
||||
- 改进了导航栏和标签栏的样式显示
|
||||
- MCP 服务器卡片宽度调整为 100%,提高响应式布局效果
|
||||
- 优化了笔记侧边栏的滚动行为
|
||||
|
||||
🐛 问题修复:
|
||||
- 修复了小应用打开功能无法正常工作的问题
|
||||
- 修复了助手更新时 ID 丢失导致更新失败的问题
|
||||
- 确保助手更新时 ID 字段为必填项,防止数据错误
|
||||
Optimized note-taking feature, now able to quickly rename by modifying the title
|
||||
Fixed issue where CherryAI free model could not be used
|
||||
Fixed issue where VertexAI proxy address could not be called normally
|
||||
Fixed issue where built-in tools from service providers could not be called normally
|
||||
|
||||
@ -35,6 +35,10 @@ export default defineConfig({
|
||||
output: {
|
||||
manualChunks: undefined, // 彻底禁用代码分割 - 返回 null 强制单文件打包
|
||||
inlineDynamicImports: true // 内联所有动态导入,这是关键配置
|
||||
},
|
||||
onwarn(warning, warn) {
|
||||
if (warning.code === 'COMMONJS_VARIABLE_IN_ESM') return
|
||||
warn(warning)
|
||||
}
|
||||
},
|
||||
sourcemap: isDev
|
||||
@ -128,6 +132,10 @@ export default defineConfig({
|
||||
selectionAction: resolve(__dirname, 'src/renderer/selectionAction.html'),
|
||||
traceWindow: resolve(__dirname, 'src/renderer/traceWindow.html'),
|
||||
dataRefactorMigrate: resolve(__dirname, 'src/renderer/dataRefactorMigrate.html')
|
||||
},
|
||||
onwarn(warning, warn) {
|
||||
if (warning.code === 'COMMONJS_VARIABLE_IN_ESM') return
|
||||
warn(warning)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -373,7 +373,8 @@
|
||||
"pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch",
|
||||
"undici": "6.21.2",
|
||||
"vite": "npm:rolldown-vite@latest",
|
||||
"tesseract.js@npm:*": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch"
|
||||
"tesseract.js@npm:*": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
|
||||
"@ai-sdk/google@npm:2.0.14": "patch:@ai-sdk/google@npm%3A2.0.14#~/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch"
|
||||
},
|
||||
"packageManager": "yarn@4.9.1",
|
||||
"lint-staged": {
|
||||
|
||||
@ -39,7 +39,6 @@
|
||||
"@ai-sdk/anthropic": "^2.0.17",
|
||||
"@ai-sdk/azure": "^2.0.30",
|
||||
"@ai-sdk/deepseek": "^1.0.17",
|
||||
"@ai-sdk/google": "^2.0.14",
|
||||
"@ai-sdk/openai": "^2.0.30",
|
||||
"@ai-sdk/openai-compatible": "^1.0.17",
|
||||
"@ai-sdk/provider": "^2.0.0",
|
||||
|
||||
@ -261,22 +261,39 @@ export const createPromptToolUsePlugin = (config: PromptToolUseConfig = {}) => {
|
||||
return params
|
||||
}
|
||||
|
||||
context.mcpTools = params.tools
|
||||
// 分离 provider-defined 和其他类型的工具
|
||||
const providerDefinedTools: ToolSet = {}
|
||||
const promptTools: ToolSet = {}
|
||||
|
||||
// 构建系统提示符
|
||||
for (const [toolName, tool] of Object.entries(params.tools as ToolSet)) {
|
||||
if (tool.type === 'provider-defined') {
|
||||
// provider-defined 类型的工具保留在 tools 参数中
|
||||
providerDefinedTools[toolName] = tool
|
||||
} else {
|
||||
// 其他工具转换为 prompt 模式
|
||||
promptTools[toolName] = tool
|
||||
}
|
||||
}
|
||||
|
||||
// 只有当有非 provider-defined 工具时才保存到 context
|
||||
if (Object.keys(promptTools).length > 0) {
|
||||
context.mcpTools = promptTools
|
||||
}
|
||||
|
||||
// 构建系统提示符(只包含非 provider-defined 工具)
|
||||
const userSystemPrompt = typeof params.system === 'string' ? params.system : ''
|
||||
const systemPrompt = buildSystemPrompt(userSystemPrompt, params.tools)
|
||||
const systemPrompt = buildSystemPrompt(userSystemPrompt, promptTools)
|
||||
let systemMessage: string | null = systemPrompt
|
||||
if (config.createSystemMessage) {
|
||||
// 🎯 如果用户提供了自定义处理函数,使用它
|
||||
systemMessage = config.createSystemMessage(systemPrompt, params, context)
|
||||
}
|
||||
|
||||
// 移除 tools,改为 prompt 模式
|
||||
// 保留 provider-defined tools,移除其他 tools
|
||||
const transformedParams = {
|
||||
...params,
|
||||
...(systemMessage ? { system: systemMessage } : {}),
|
||||
tools: undefined
|
||||
tools: Object.keys(providerDefinedTools).length > 0 ? providerDefinedTools : undefined
|
||||
}
|
||||
context.originalParams = transformedParams
|
||||
return transformedParams
|
||||
@ -285,8 +302,9 @@ export const createPromptToolUsePlugin = (config: PromptToolUseConfig = {}) => {
|
||||
let textBuffer = ''
|
||||
// let stepId = ''
|
||||
|
||||
// 如果没有需要 prompt 模式处理的工具,直接返回原始流
|
||||
if (!context.mcpTools) {
|
||||
throw new Error('No tools available')
|
||||
return new TransformStream()
|
||||
}
|
||||
|
||||
// 从 context 中获取或初始化 usage 累加器
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { anthropic } from '@ai-sdk/anthropic'
|
||||
import type { google } from '@ai-sdk/google'
|
||||
import type { openai } from '@ai-sdk/openai'
|
||||
import type { InferToolInput, InferToolOutput } from 'ai'
|
||||
|
||||
import type { ProviderOptionsMap } from '../../../options/types'
|
||||
import type { OpenRouterSearchConfig } from './openrouter'
|
||||
@ -58,24 +59,31 @@ export const DEFAULT_WEB_SEARCH_CONFIG: WebSearchPluginConfig = {
|
||||
|
||||
export type WebSearchToolOutputSchema = {
|
||||
// Anthropic 工具 - 手动定义
|
||||
anthropicWebSearch: Array<{
|
||||
url: string
|
||||
title: string
|
||||
pageAge: string | null
|
||||
encryptedContent: string
|
||||
type: string
|
||||
}>
|
||||
anthropic: InferToolOutput<ReturnType<typeof anthropic.tools.webSearch_20250305>>
|
||||
|
||||
// OpenAI 工具 - 基于实际输出
|
||||
openaiWebSearch: {
|
||||
// TODO: 上游定义不规范,是unknown
|
||||
// openai: InferToolOutput<ReturnType<typeof openai.tools.webSearch>>
|
||||
openai: {
|
||||
status: 'completed' | 'failed'
|
||||
}
|
||||
'openai-chat': {
|
||||
status: 'completed' | 'failed'
|
||||
}
|
||||
|
||||
// Google 工具
|
||||
googleSearch: {
|
||||
// TODO: 上游定义不规范,是unknown
|
||||
// google: InferToolOutput<ReturnType<typeof google.tools.googleSearch>>
|
||||
google: {
|
||||
webSearchQueries?: string[]
|
||||
groundingChunks?: Array<{
|
||||
web?: { uri: string; title: string }
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
export type WebSearchToolInputSchema = {
|
||||
anthropic: InferToolInput<ReturnType<typeof anthropic.tools.webSearch_20250305>>
|
||||
openai: InferToolInput<ReturnType<typeof openai.tools.webSearch>>
|
||||
google: InferToolInput<ReturnType<typeof google.tools.googleSearch>>
|
||||
'openai-chat': InferToolInput<ReturnType<typeof openai.tools.webSearchPreview>>
|
||||
}
|
||||
|
||||
@ -368,16 +368,27 @@ export const WINDOWS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [
|
||||
}
|
||||
]
|
||||
|
||||
// Helper function to escape strings for AppleScript
|
||||
const escapeForAppleScript = (str: string): string => {
|
||||
// In AppleScript strings, backslashes and double quotes need to be escaped
|
||||
// When passed through osascript -e with single quotes, we need:
|
||||
// 1. Backslash: \ -> \\
|
||||
// 2. Double quote: " -> \"
|
||||
return str
|
||||
.replace(/\\/g, '\\\\') // Escape backslashes first
|
||||
.replace(/"/g, '\\"') // Then escape double quotes
|
||||
}
|
||||
|
||||
export const MACOS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [
|
||||
{
|
||||
id: terminalApps.systemDefault,
|
||||
name: 'Terminal',
|
||||
bundleId: 'com.apple.Terminal',
|
||||
command: (directory: string, fullCommand: string) => ({
|
||||
command: (_directory: string, fullCommand: string) => ({
|
||||
command: 'sh',
|
||||
args: [
|
||||
'-c',
|
||||
`open -na Terminal && sleep 0.5 && osascript -e 'tell application "Terminal" to activate' -e 'tell application "Terminal" to do script "cd '${directory.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}' && clear && ${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}" in front window'`
|
||||
`open -na Terminal && sleep 0.5 && osascript -e 'tell application "Terminal" to activate' -e 'tell application "Terminal" to do script "${escapeForAppleScript(fullCommand)}" in front window'`
|
||||
]
|
||||
})
|
||||
},
|
||||
@ -385,11 +396,11 @@ export const MACOS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [
|
||||
id: terminalApps.iterm2,
|
||||
name: 'iTerm2',
|
||||
bundleId: 'com.googlecode.iterm2',
|
||||
command: (directory: string, fullCommand: string) => ({
|
||||
command: (_directory: string, fullCommand: string) => ({
|
||||
command: 'sh',
|
||||
args: [
|
||||
'-c',
|
||||
`open -na iTerm && sleep 0.8 && osascript -e 'on waitUntilRunning()\n repeat 50 times\n tell application "System Events"\n if (exists process "iTerm2") then exit repeat\n end tell\n delay 0.1\n end repeat\nend waitUntilRunning\n\nwaitUntilRunning()\n\ntell application "iTerm2"\n if (count of windows) = 0 then\n create window with default profile\n delay 0.3\n else\n tell current window\n create tab with default profile\n end tell\n delay 0.3\n end if\n tell current session of current window to write text "cd '${directory.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}' && clear && ${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"\n activate\nend tell'`
|
||||
`open -na iTerm && sleep 0.8 && osascript -e 'on waitUntilRunning()\n repeat 50 times\n tell application "System Events"\n if (exists process "iTerm2") then exit repeat\n end tell\n delay 0.1\n end repeat\nend waitUntilRunning\n\nwaitUntilRunning()\n\ntell application "iTerm2"\n if (count of windows) = 0 then\n create window with default profile\n delay 0.3\n else\n tell current window\n create tab with default profile\n end tell\n delay 0.3\n end if\n tell current session of current window to write text "${escapeForAppleScript(fullCommand)}"\n activate\nend tell'`
|
||||
]
|
||||
})
|
||||
},
|
||||
@ -397,11 +408,11 @@ export const MACOS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [
|
||||
id: terminalApps.kitty,
|
||||
name: 'kitty',
|
||||
bundleId: 'net.kovidgoyal.kitty',
|
||||
command: (directory: string, fullCommand: string) => ({
|
||||
command: (_directory: string, fullCommand: string) => ({
|
||||
command: 'sh',
|
||||
args: [
|
||||
'-c',
|
||||
`cd "${directory}" && open -na kitty --args --directory="${directory}" sh -c "${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}; exec \\$SHELL" && sleep 0.5 && osascript -e 'tell application "kitty" to activate'`
|
||||
`cd "${_directory}" && open -na kitty --args --directory="${_directory}" sh -c "${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}; exec \\$SHELL" && sleep 0.5 && osascript -e 'tell application "kitty" to activate'`
|
||||
]
|
||||
})
|
||||
},
|
||||
@ -409,11 +420,11 @@ export const MACOS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [
|
||||
id: terminalApps.alacritty,
|
||||
name: 'Alacritty',
|
||||
bundleId: 'org.alacritty',
|
||||
command: (directory: string, fullCommand: string) => ({
|
||||
command: (_directory: string, fullCommand: string) => ({
|
||||
command: 'sh',
|
||||
args: [
|
||||
'-c',
|
||||
`open -na Alacritty --args --working-directory "${directory}" -e sh -c "${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}; exec \\$SHELL" && sleep 0.5 && osascript -e 'tell application "Alacritty" to activate'`
|
||||
`open -na Alacritty --args --working-directory "${_directory}" -e sh -c "${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}; exec \\$SHELL" && sleep 0.5 && osascript -e 'tell application "Alacritty" to activate'`
|
||||
]
|
||||
})
|
||||
},
|
||||
@ -421,11 +432,11 @@ export const MACOS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [
|
||||
id: terminalApps.wezterm,
|
||||
name: 'WezTerm',
|
||||
bundleId: 'com.github.wez.wezterm',
|
||||
command: (directory: string, fullCommand: string) => ({
|
||||
command: (_directory: string, fullCommand: string) => ({
|
||||
command: 'sh',
|
||||
args: [
|
||||
'-c',
|
||||
`open -na WezTerm --args start --new-tab --cwd "${directory}" -- sh -c "${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}; exec \\$SHELL" && sleep 0.5 && osascript -e 'tell application "WezTerm" to activate'`
|
||||
`open -na WezTerm --args start --new-tab --cwd "${_directory}" -- sh -c "${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}; exec \\$SHELL" && sleep 0.5 && osascript -e 'tell application "WezTerm" to activate'`
|
||||
]
|
||||
})
|
||||
},
|
||||
@ -433,11 +444,11 @@ export const MACOS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [
|
||||
id: terminalApps.ghostty,
|
||||
name: 'Ghostty',
|
||||
bundleId: 'com.mitchellh.ghostty',
|
||||
command: (directory: string, fullCommand: string) => ({
|
||||
command: (_directory: string, fullCommand: string) => ({
|
||||
command: 'sh',
|
||||
args: [
|
||||
'-c',
|
||||
`cd "${directory}" && open -na Ghostty --args --working-directory="${directory}" -e sh -c "${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}; exec \\$SHELL" && sleep 0.5 && osascript -e 'tell application "Ghostty" to activate'`
|
||||
`cd "${_directory}" && open -na Ghostty --args --working-directory="${_directory}" -e sh -c "${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}; exec \\$SHELL" && sleep 0.5 && osascript -e 'tell application "Ghostty" to activate'`
|
||||
]
|
||||
})
|
||||
},
|
||||
@ -445,7 +456,7 @@ export const MACOS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [
|
||||
id: terminalApps.tabby,
|
||||
name: 'Tabby',
|
||||
bundleId: 'org.tabby',
|
||||
command: (directory: string, fullCommand: string) => ({
|
||||
command: (_directory: string, fullCommand: string) => ({
|
||||
command: 'sh',
|
||||
args: [
|
||||
'-c',
|
||||
@ -453,7 +464,7 @@ export const MACOS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [
|
||||
open -na Tabby --args open && sleep 0.3
|
||||
else
|
||||
open -na Tabby --args open && sleep 2
|
||||
fi && osascript -e 'tell application "Tabby" to activate' -e 'set the clipboard to "cd \\"${directory.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}\\" && clear && ${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"' -e 'tell application "System Events" to tell process "Tabby" to keystroke "v" using {command down}' -e 'tell application "System Events" to key code 36'`
|
||||
fi && osascript -e 'tell application "Tabby" to activate' -e 'set the clipboard to "${escapeForAppleScript(fullCommand)}"' -e 'tell application "System Events" to tell process "Tabby" to keystroke "v" using {command down}' -e 'tell application "System Events" to key code 36'`
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
@ -22,6 +22,13 @@ import { windowService } from './WindowService'
|
||||
|
||||
const logger = loggerService.withContext('AppUpdater')
|
||||
|
||||
// Language markers constants for multi-language release notes
|
||||
const LANG_MARKERS = {
|
||||
EN_START: '<!--LANG:en-->',
|
||||
ZH_CN_START: '<!--LANG:zh-CN-->',
|
||||
END: '<!--LANG:END-->'
|
||||
} as const
|
||||
|
||||
export default class AppUpdater {
|
||||
autoUpdater: _AppUpdater = autoUpdater
|
||||
private releaseInfo: UpdateInfo | undefined
|
||||
@ -46,7 +53,8 @@ export default class AppUpdater {
|
||||
|
||||
autoUpdater.on('update-available', (releaseInfo: UpdateInfo) => {
|
||||
logger.info('update available', releaseInfo)
|
||||
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateAvailable, releaseInfo)
|
||||
const processedReleaseInfo = this.processReleaseInfo(releaseInfo)
|
||||
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateAvailable, processedReleaseInfo)
|
||||
})
|
||||
|
||||
// 检测到不需要更新时
|
||||
@ -61,9 +69,10 @@ export default class AppUpdater {
|
||||
|
||||
// 当需要更新的内容下载完成后
|
||||
autoUpdater.on('update-downloaded', (releaseInfo: UpdateInfo) => {
|
||||
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateDownloaded, releaseInfo)
|
||||
this.releaseInfo = releaseInfo
|
||||
logger.info('update downloaded', releaseInfo)
|
||||
const processedReleaseInfo = this.processReleaseInfo(releaseInfo)
|
||||
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateDownloaded, processedReleaseInfo)
|
||||
this.releaseInfo = processedReleaseInfo
|
||||
logger.info('update downloaded', processedReleaseInfo)
|
||||
})
|
||||
|
||||
if (isWin) {
|
||||
@ -276,16 +285,99 @@ export default class AppUpdater {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if release notes contain multi-language markers
|
||||
*/
|
||||
private hasMultiLanguageMarkers(releaseNotes: string): boolean {
|
||||
return releaseNotes.includes(LANG_MARKERS.EN_START)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse multi-language release notes and return the appropriate language version
|
||||
* @param releaseNotes - Release notes string with language markers
|
||||
* @returns Parsed release notes for the user's language
|
||||
*
|
||||
* Expected format:
|
||||
* <!--LANG:en-->English content<!--LANG:zh-CN-->Chinese content<!--LANG:END-->
|
||||
*/
|
||||
private parseMultiLangReleaseNotes(releaseNotes: string): string {
|
||||
try {
|
||||
const language = configManager.getLanguage()
|
||||
const isChineseUser = language === 'zh-CN' || language === 'zh-TW'
|
||||
|
||||
// Create regex patterns using constants
|
||||
const enPattern = new RegExp(
|
||||
`${LANG_MARKERS.EN_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([\\s\\S]*?)${LANG_MARKERS.ZH_CN_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`
|
||||
)
|
||||
const zhPattern = new RegExp(
|
||||
`${LANG_MARKERS.ZH_CN_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([\\s\\S]*?)${LANG_MARKERS.END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`
|
||||
)
|
||||
|
||||
// Extract language sections
|
||||
const enMatch = releaseNotes.match(enPattern)
|
||||
const zhMatch = releaseNotes.match(zhPattern)
|
||||
|
||||
// Return appropriate language version with proper fallback
|
||||
if (isChineseUser && zhMatch) {
|
||||
return zhMatch[1].trim()
|
||||
} else if (enMatch) {
|
||||
return enMatch[1].trim()
|
||||
} else {
|
||||
// Clean fallback: remove all language markers
|
||||
logger.warn('Failed to extract language-specific release notes, using cleaned fallback')
|
||||
return releaseNotes
|
||||
.replace(new RegExp(`${LANG_MARKERS.EN_START}|${LANG_MARKERS.ZH_CN_START}|${LANG_MARKERS.END}`, 'g'), '')
|
||||
.trim()
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse multi-language release notes', error as Error)
|
||||
// Return original notes as safe fallback
|
||||
return releaseNotes
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process release info to handle multi-language release notes
|
||||
* @param releaseInfo - Original release info from updater
|
||||
* @returns Processed release info with localized release notes
|
||||
*/
|
||||
private processReleaseInfo(releaseInfo: UpdateInfo): UpdateInfo {
|
||||
const processedInfo = { ...releaseInfo }
|
||||
|
||||
// Handle multi-language release notes in string format
|
||||
if (releaseInfo.releaseNotes && typeof releaseInfo.releaseNotes === 'string') {
|
||||
// Check if it contains multi-language markers
|
||||
if (this.hasMultiLanguageMarkers(releaseInfo.releaseNotes)) {
|
||||
processedInfo.releaseNotes = this.parseMultiLangReleaseNotes(releaseInfo.releaseNotes)
|
||||
}
|
||||
}
|
||||
|
||||
return processedInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* Format release notes for display
|
||||
* @param releaseNotes - Release notes in various formats
|
||||
* @returns Formatted string for display
|
||||
*/
|
||||
private formatReleaseNotes(releaseNotes: string | ReleaseNoteInfo[] | null | undefined): string {
|
||||
if (!releaseNotes) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (typeof releaseNotes === 'string') {
|
||||
// Check if it contains multi-language markers
|
||||
if (this.hasMultiLanguageMarkers(releaseNotes)) {
|
||||
return this.parseMultiLangReleaseNotes(releaseNotes)
|
||||
}
|
||||
return releaseNotes
|
||||
}
|
||||
|
||||
return releaseNotes.map((note) => note.note).join('\n')
|
||||
if (Array.isArray(releaseNotes)) {
|
||||
return releaseNotes.map((note) => note.note).join('\n')
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
}
|
||||
interface GithubReleaseInfo {
|
||||
|
||||
@ -665,7 +665,7 @@ class CodeToolsService {
|
||||
const command = envPrefix ? `${envPrefix} && ${baseCommand}` : baseCommand
|
||||
|
||||
// Combine directory change with the main command to ensure they execute in the same shell session
|
||||
const fullCommand = `cd '${directory.replace(/'/g, "\\'")}' && clear && ${command}`
|
||||
const fullCommand = `cd "${directory.replace(/"/g, '\\"')}" && clear && ${command}`
|
||||
|
||||
const terminalConfig = await this.getTerminalConfig(options.terminal)
|
||||
logger.info(`Using terminal: ${terminalConfig.name} (${terminalConfig.id})`)
|
||||
|
||||
319
src/main/services/__tests__/AppUpdater.test.ts
Normal file
319
src/main/services/__tests__/AppUpdater.test.ts
Normal file
@ -0,0 +1,319 @@
|
||||
import { UpdateInfo } from 'builder-util-runtime'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@logger', () => ({
|
||||
loggerService: {
|
||||
withContext: () => ({
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn()
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../ConfigManager', () => ({
|
||||
configManager: {
|
||||
getLanguage: vi.fn(),
|
||||
getAutoUpdate: vi.fn(() => false),
|
||||
getTestPlan: vi.fn(() => false),
|
||||
getTestChannel: vi.fn(),
|
||||
getClientId: vi.fn(() => 'test-client-id')
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../WindowService', () => ({
|
||||
windowService: {
|
||||
getMainWindow: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@main/constant', () => ({
|
||||
isWin: false
|
||||
}))
|
||||
|
||||
vi.mock('@main/utils/ipService', () => ({
|
||||
getIpCountry: vi.fn(() => 'US')
|
||||
}))
|
||||
|
||||
vi.mock('@main/utils/locales', () => ({
|
||||
locales: {
|
||||
en: { translation: { update: {} } },
|
||||
'zh-CN': { translation: { update: {} } }
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@main/utils/systemInfo', () => ({
|
||||
generateUserAgent: vi.fn(() => 'test-user-agent')
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
isPackaged: true,
|
||||
getVersion: vi.fn(() => '1.0.0'),
|
||||
getPath: vi.fn(() => '/test/path')
|
||||
},
|
||||
dialog: {
|
||||
showMessageBox: vi.fn()
|
||||
},
|
||||
BrowserWindow: vi.fn(),
|
||||
net: {
|
||||
fetch: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('electron-updater', () => ({
|
||||
autoUpdater: {
|
||||
logger: null,
|
||||
forceDevUpdateConfig: false,
|
||||
autoDownload: false,
|
||||
autoInstallOnAppQuit: false,
|
||||
requestHeaders: {},
|
||||
on: vi.fn(),
|
||||
setFeedURL: vi.fn(),
|
||||
checkForUpdates: vi.fn(),
|
||||
downloadUpdate: vi.fn(),
|
||||
quitAndInstall: vi.fn(),
|
||||
channel: '',
|
||||
allowDowngrade: false,
|
||||
disableDifferentialDownload: false,
|
||||
currentVersion: '1.0.0'
|
||||
},
|
||||
Logger: vi.fn(),
|
||||
NsisUpdater: vi.fn(),
|
||||
AppUpdater: vi.fn()
|
||||
}))
|
||||
|
||||
// Import after mocks
|
||||
import AppUpdater from '../AppUpdater'
|
||||
import { configManager } from '../ConfigManager'
|
||||
|
||||
describe('AppUpdater', () => {
|
||||
let appUpdater: AppUpdater
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
appUpdater = new AppUpdater()
|
||||
})
|
||||
|
||||
describe('parseMultiLangReleaseNotes', () => {
|
||||
const sampleReleaseNotes = `<!--LANG:en-->
|
||||
🚀 New Features:
|
||||
- Feature A
|
||||
- Feature B
|
||||
|
||||
🎨 UI Improvements:
|
||||
- Improvement A
|
||||
<!--LANG:zh-CN-->
|
||||
🚀 新功能:
|
||||
- 功能 A
|
||||
- 功能 B
|
||||
|
||||
🎨 界面改进:
|
||||
- 改进 A
|
||||
<!--LANG:END-->`
|
||||
|
||||
it('should return Chinese notes for zh-CN users', () => {
|
||||
vi.mocked(configManager.getLanguage).mockReturnValue('zh-CN')
|
||||
|
||||
const result = (appUpdater as any).parseMultiLangReleaseNotes(sampleReleaseNotes)
|
||||
|
||||
expect(result).toContain('新功能')
|
||||
expect(result).toContain('功能 A')
|
||||
expect(result).not.toContain('New Features')
|
||||
})
|
||||
|
||||
it('should return Chinese notes for zh-TW users', () => {
|
||||
vi.mocked(configManager.getLanguage).mockReturnValue('zh-TW')
|
||||
|
||||
const result = (appUpdater as any).parseMultiLangReleaseNotes(sampleReleaseNotes)
|
||||
|
||||
expect(result).toContain('新功能')
|
||||
expect(result).toContain('功能 A')
|
||||
expect(result).not.toContain('New Features')
|
||||
})
|
||||
|
||||
it('should return English notes for non-Chinese users', () => {
|
||||
vi.mocked(configManager.getLanguage).mockReturnValue('en-US')
|
||||
|
||||
const result = (appUpdater as any).parseMultiLangReleaseNotes(sampleReleaseNotes)
|
||||
|
||||
expect(result).toContain('New Features')
|
||||
expect(result).toContain('Feature A')
|
||||
expect(result).not.toContain('新功能')
|
||||
})
|
||||
|
||||
it('should return English notes for other language users', () => {
|
||||
vi.mocked(configManager.getLanguage).mockReturnValue('ru-RU')
|
||||
|
||||
const result = (appUpdater as any).parseMultiLangReleaseNotes(sampleReleaseNotes)
|
||||
|
||||
expect(result).toContain('New Features')
|
||||
expect(result).not.toContain('新功能')
|
||||
})
|
||||
|
||||
it('should handle missing language sections gracefully', () => {
|
||||
const malformedNotes = 'Simple release notes without markers'
|
||||
|
||||
const result = (appUpdater as any).parseMultiLangReleaseNotes(malformedNotes)
|
||||
|
||||
expect(result).toBe('Simple release notes without markers')
|
||||
})
|
||||
|
||||
it('should handle malformed markers', () => {
|
||||
const malformedNotes = `<!--LANG:en-->English only`
|
||||
vi.mocked(configManager.getLanguage).mockReturnValue('zh-CN')
|
||||
|
||||
const result = (appUpdater as any).parseMultiLangReleaseNotes(malformedNotes)
|
||||
|
||||
// Should clean up markers and return cleaned content
|
||||
expect(result).toContain('English only')
|
||||
expect(result).not.toContain('<!--LANG:')
|
||||
})
|
||||
|
||||
it('should handle empty release notes', () => {
|
||||
const result = (appUpdater as any).parseMultiLangReleaseNotes('')
|
||||
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it('should handle errors gracefully', () => {
|
||||
// Force an error by mocking configManager to throw
|
||||
vi.mocked(configManager.getLanguage).mockImplementation(() => {
|
||||
throw new Error('Test error')
|
||||
})
|
||||
|
||||
const result = (appUpdater as any).parseMultiLangReleaseNotes(sampleReleaseNotes)
|
||||
|
||||
// Should return original notes as fallback
|
||||
expect(result).toBe(sampleReleaseNotes)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasMultiLanguageMarkers', () => {
|
||||
it('should return true when markers are present', () => {
|
||||
const notes = '<!--LANG:en-->Test'
|
||||
|
||||
const result = (appUpdater as any).hasMultiLanguageMarkers(notes)
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false when no markers are present', () => {
|
||||
const notes = 'Simple text without markers'
|
||||
|
||||
const result = (appUpdater as any).hasMultiLanguageMarkers(notes)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('processReleaseInfo', () => {
|
||||
it('should process multi-language release notes in string format', () => {
|
||||
vi.mocked(configManager.getLanguage).mockReturnValue('zh-CN')
|
||||
|
||||
const releaseInfo = {
|
||||
version: '1.0.0',
|
||||
files: [],
|
||||
path: '',
|
||||
sha512: '',
|
||||
releaseDate: new Date().toISOString(),
|
||||
releaseNotes: `<!--LANG:en-->English notes<!--LANG:zh-CN-->中文说明<!--LANG:END-->`
|
||||
} as UpdateInfo
|
||||
|
||||
const result = (appUpdater as any).processReleaseInfo(releaseInfo)
|
||||
|
||||
expect(result.releaseNotes).toBe('中文说明')
|
||||
})
|
||||
|
||||
it('should not process release notes without markers', () => {
|
||||
const releaseInfo = {
|
||||
version: '1.0.0',
|
||||
files: [],
|
||||
path: '',
|
||||
sha512: '',
|
||||
releaseDate: new Date().toISOString(),
|
||||
releaseNotes: 'Simple release notes'
|
||||
} as UpdateInfo
|
||||
|
||||
const result = (appUpdater as any).processReleaseInfo(releaseInfo)
|
||||
|
||||
expect(result.releaseNotes).toBe('Simple release notes')
|
||||
})
|
||||
|
||||
it('should handle array format release notes', () => {
|
||||
const releaseInfo = {
|
||||
version: '1.0.0',
|
||||
files: [],
|
||||
path: '',
|
||||
sha512: '',
|
||||
releaseDate: new Date().toISOString(),
|
||||
releaseNotes: [
|
||||
{ version: '1.0.0', note: 'Note 1' },
|
||||
{ version: '1.0.1', note: 'Note 2' }
|
||||
]
|
||||
} as UpdateInfo
|
||||
|
||||
const result = (appUpdater as any).processReleaseInfo(releaseInfo)
|
||||
|
||||
expect(result.releaseNotes).toEqual(releaseInfo.releaseNotes)
|
||||
})
|
||||
|
||||
it('should handle null release notes', () => {
|
||||
const releaseInfo = {
|
||||
version: '1.0.0',
|
||||
files: [],
|
||||
path: '',
|
||||
sha512: '',
|
||||
releaseDate: new Date().toISOString(),
|
||||
releaseNotes: null
|
||||
} as UpdateInfo
|
||||
|
||||
const result = (appUpdater as any).processReleaseInfo(releaseInfo)
|
||||
|
||||
expect(result.releaseNotes).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatReleaseNotes', () => {
|
||||
it('should format string release notes with markers', () => {
|
||||
vi.mocked(configManager.getLanguage).mockReturnValue('en-US')
|
||||
const notes = `<!--LANG:en-->English<!--LANG:zh-CN-->中文<!--LANG:END-->`
|
||||
|
||||
const result = (appUpdater as any).formatReleaseNotes(notes)
|
||||
|
||||
expect(result).toBe('English')
|
||||
})
|
||||
|
||||
it('should format string release notes without markers', () => {
|
||||
const notes = 'Simple notes'
|
||||
|
||||
const result = (appUpdater as any).formatReleaseNotes(notes)
|
||||
|
||||
expect(result).toBe('Simple notes')
|
||||
})
|
||||
|
||||
it('should format array release notes', () => {
|
||||
const notes = [
|
||||
{ version: '1.0.0', note: 'Note 1' },
|
||||
{ version: '1.0.1', note: 'Note 2' }
|
||||
]
|
||||
|
||||
const result = (appUpdater as any).formatReleaseNotes(notes)
|
||||
|
||||
expect(result).toBe('Note 1\nNote 2')
|
||||
})
|
||||
|
||||
it('should handle null release notes', () => {
|
||||
const result = (appUpdater as any).formatReleaseNotes(null)
|
||||
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it('should handle undefined release notes', () => {
|
||||
const result = (appUpdater as any).formatReleaseNotes(undefined)
|
||||
|
||||
expect(result).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,3 +1,4 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import * as fs from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
@ -265,11 +266,12 @@ export async function scanDir(dirPath: string, depth = 0, basePath?: string): Pr
|
||||
|
||||
if (entry.isDirectory() && options.includeDirectories) {
|
||||
const stats = await fs.promises.stat(entryPath)
|
||||
const externalDirPath = entryPath.replace(/\\/g, '/')
|
||||
const dirTreeNode: NotesTreeNode = {
|
||||
id: uuidv4(),
|
||||
id: createHash('sha1').update(externalDirPath).digest('hex'),
|
||||
name: entry.name,
|
||||
treePath: treePath,
|
||||
externalPath: entryPath,
|
||||
externalPath: externalDirPath,
|
||||
createdAt: stats.birthtime.toISOString(),
|
||||
updatedAt: stats.mtime.toISOString(),
|
||||
type: 'folder',
|
||||
@ -300,11 +302,12 @@ export async function scanDir(dirPath: string, depth = 0, basePath?: string): Pr
|
||||
? `/${dirRelativePath.replace(/\\/g, '/')}/${nameWithoutExt}`
|
||||
: `/${nameWithoutExt}`
|
||||
|
||||
const externalFilePath = entryPath.replace(/\\/g, '/')
|
||||
const fileTreeNode: NotesTreeNode = {
|
||||
id: uuidv4(),
|
||||
id: createHash('sha1').update(externalFilePath).digest('hex'),
|
||||
name: name,
|
||||
treePath: fileTreePath,
|
||||
externalPath: entryPath,
|
||||
externalPath: externalFilePath,
|
||||
createdAt: stats.birthtime.toISOString(),
|
||||
updatedAt: stats.mtime.toISOString(),
|
||||
type: 'file'
|
||||
|
||||
@ -165,15 +165,13 @@ export class AiSdkToChunkAdapter {
|
||||
final.reasoningContent += chunk.text || ''
|
||||
this.onChunk({
|
||||
type: ChunkType.THINKING_DELTA,
|
||||
text: final.reasoningContent || '',
|
||||
thinking_millsec: (chunk.providerMetadata?.metadata?.thinking_millsec as number) || 0
|
||||
text: final.reasoningContent || ''
|
||||
})
|
||||
break
|
||||
case 'reasoning-end':
|
||||
this.onChunk({
|
||||
type: ChunkType.THINKING_COMPLETE,
|
||||
text: (chunk.providerMetadata?.metadata?.thinking_content as string) || '',
|
||||
thinking_millsec: (chunk.providerMetadata?.metadata?.thinking_millsec as number) || 0
|
||||
text: final.reasoningContent || ''
|
||||
})
|
||||
final.reasoningContent = ''
|
||||
break
|
||||
|
||||
@ -144,12 +144,14 @@ export function buildAiSdkMiddlewares(config: AiSdkMiddlewareConfig): LanguageMo
|
||||
const tagName = {
|
||||
reasoning: 'reasoning',
|
||||
think: 'think',
|
||||
thought: 'thought'
|
||||
thought: 'thought',
|
||||
seedThink: 'seed:think'
|
||||
}
|
||||
|
||||
function getReasoningTagName(modelId: string | undefined): string {
|
||||
if (modelId?.includes('gpt-oss')) return tagName.reasoning
|
||||
if (modelId?.includes('gemini')) return tagName.thought
|
||||
if (modelId?.includes('seed-oss-36b')) return tagName.seedThink
|
||||
return tagName.think
|
||||
}
|
||||
|
||||
|
||||
@ -5,7 +5,6 @@ import { loggerService } from '@logger'
|
||||
import type { Assistant } from '@renderer/types'
|
||||
|
||||
import type { AiSdkMiddlewareConfig } from '../middleware/AiSdkMiddlewareBuilder'
|
||||
import reasoningTimePlugin from './reasoningTimePlugin'
|
||||
import { searchOrchestrationPlugin } from './searchOrchestrationPlugin'
|
||||
import { createTelemetryPlugin } from './telemetryPlugin'
|
||||
|
||||
@ -39,9 +38,9 @@ export async function buildPlugins(
|
||||
}
|
||||
|
||||
// 3. 推理模型时添加推理插件
|
||||
if (middlewareConfig.enableReasoning) {
|
||||
plugins.push(reasoningTimePlugin)
|
||||
}
|
||||
// if (middlewareConfig.enableReasoning) {
|
||||
// plugins.push(reasoningTimePlugin)
|
||||
// }
|
||||
|
||||
// 4. 启用Prompt工具调用时添加工具插件
|
||||
if (middlewareConfig.isPromptToolUse) {
|
||||
|
||||
@ -7,18 +7,14 @@ export default definePlugin({
|
||||
transformStream: () => () => {
|
||||
// === 时间跟踪状态 ===
|
||||
let thinkingStartTime = 0
|
||||
let hasStartedThinking = false
|
||||
let accumulatedThinkingContent = ''
|
||||
let reasoningBlockId = ''
|
||||
|
||||
return new TransformStream<TextStreamPart<ToolSet>, TextStreamPart<ToolSet>>({
|
||||
transform(chunk: TextStreamPart<ToolSet>, controller: TransformStreamDefaultController<TextStreamPart<ToolSet>>) {
|
||||
// === 处理 reasoning 类型 ===
|
||||
if (chunk.type === 'reasoning-start') {
|
||||
controller.enqueue(chunk)
|
||||
hasStartedThinking = true
|
||||
thinkingStartTime = performance.now()
|
||||
reasoningBlockId = chunk.id
|
||||
} else if (chunk.type === 'reasoning-delta') {
|
||||
accumulatedThinkingContent += chunk.text
|
||||
controller.enqueue({
|
||||
@ -32,21 +28,6 @@ export default definePlugin({
|
||||
}
|
||||
}
|
||||
})
|
||||
} else if (chunk.type === 'reasoning-end' && hasStartedThinking) {
|
||||
controller.enqueue({
|
||||
type: 'reasoning-end',
|
||||
id: reasoningBlockId,
|
||||
providerMetadata: {
|
||||
metadata: {
|
||||
thinking_millsec: performance.now() - thinkingStartTime,
|
||||
thinking_content: accumulatedThinkingContent
|
||||
}
|
||||
}
|
||||
})
|
||||
accumulatedThinkingContent = ''
|
||||
hasStartedThinking = false
|
||||
thinkingStartTime = 0
|
||||
reasoningBlockId = ''
|
||||
} else {
|
||||
controller.enqueue(chunk)
|
||||
}
|
||||
|
||||
@ -134,9 +134,10 @@ export async function buildStreamTextParams(
|
||||
if (aiSdkProviderId === 'google-vertex') {
|
||||
tools.google_search = vertex.tools.googleSearch({}) as ProviderDefinedTool
|
||||
} else if (aiSdkProviderId === 'google-vertex-anthropic') {
|
||||
const blockedDomains = mapRegexToPatterns(webSearchConfig.excludeDomains)
|
||||
tools.web_search = vertexAnthropic.tools.webSearch_20250305({
|
||||
maxUses: webSearchConfig.maxResults,
|
||||
blockedDomains: mapRegexToPatterns(webSearchConfig.excludeDomains)
|
||||
blockedDomains: blockedDomains.length > 0 ? blockedDomains : undefined
|
||||
}) as ProviderDefinedTool
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,7 +19,7 @@ import { loggerService } from '@renderer/services/LoggerService'
|
||||
import store from '@renderer/store'
|
||||
import { isSystemProvider, type Model, type Provider } from '@renderer/types'
|
||||
import { formatApiHost } from '@renderer/utils/api'
|
||||
import { cloneDeep, isEmpty } from 'lodash'
|
||||
import { cloneDeep, trim } from 'lodash'
|
||||
|
||||
import { aihubmixProviderCreator, newApiResolverCreator, vertexAnthropicProviderCreator } from './config'
|
||||
import { getAiSdkProviderId } from './factory'
|
||||
@ -120,7 +120,7 @@ export function providerToAiSdkConfig(
|
||||
|
||||
// 构建基础配置
|
||||
const baseConfig = {
|
||||
baseURL: actualProvider.apiHost,
|
||||
baseURL: trim(actualProvider.apiHost),
|
||||
apiKey: getRotatedApiKey(actualProvider)
|
||||
}
|
||||
// 处理OpenAI模式
|
||||
@ -195,7 +195,10 @@ export function providerToAiSdkConfig(
|
||||
} else if (baseConfig.baseURL.endsWith('/v1')) {
|
||||
baseConfig.baseURL = baseConfig.baseURL.slice(0, -3)
|
||||
}
|
||||
baseConfig.baseURL = isEmpty(baseConfig.baseURL) ? '' : baseConfig.baseURL
|
||||
|
||||
if (baseConfig.baseURL && !baseConfig.baseURL.includes('publishers/google')) {
|
||||
baseConfig.baseURL = `${baseConfig.baseURL}/v1/projects/${project}/locations/${location}/publishers/google`
|
||||
}
|
||||
}
|
||||
|
||||
// 如果AI SDK支持该provider,使用原生配置
|
||||
|
||||
@ -18,12 +18,13 @@ export const knowledgeSearchTool = (
|
||||
) => {
|
||||
return tool({
|
||||
name: 'builtin_knowledge_search',
|
||||
description: `Search the knowledge base for relevant information using pre-analyzed search intent.
|
||||
description: `Knowledge base search tool for retrieving information from user's private knowledge base. This searches your local collection of documents, web content, notes, and other materials you have stored.
|
||||
|
||||
Pre-extracted search queries: "${extractedKeywords.question.join(', ')}"
|
||||
Rewritten query: "${extractedKeywords.rewrite}"
|
||||
This tool has been configured with search parameters based on the conversation context:
|
||||
- Prepared queries: ${extractedKeywords.question.map((q) => `"${q}"`).join(', ')}
|
||||
- Query rewrite: "${extractedKeywords.rewrite}"
|
||||
|
||||
Call this tool to execute the search. You can optionally provide additional context to refine the search.`,
|
||||
You can use this tool as-is, or provide additionalContext to refine the search focus within the knowledge base.`,
|
||||
|
||||
inputSchema: z.object({
|
||||
additionalContext: z
|
||||
|
||||
@ -21,16 +21,17 @@ export const webSearchToolWithPreExtractedKeywords = (
|
||||
|
||||
return tool({
|
||||
name: 'builtin_web_search',
|
||||
description: `Search the web and return citable sources using pre-analyzed search intent.
|
||||
description: `Web search tool for finding current information, news, and real-time data from the internet.
|
||||
|
||||
Pre-extracted search keywords: "${extractedKeywords.question.join(', ')}"${
|
||||
extractedKeywords.links
|
||||
This tool has been configured with search parameters based on the conversation context:
|
||||
- Prepared queries: ${extractedKeywords.question.map((q) => `"${q}"`).join(', ')}${
|
||||
extractedKeywords.links?.length
|
||||
? `
|
||||
Relevant links: ${extractedKeywords.links.join(', ')}`
|
||||
- Relevant URLs: ${extractedKeywords.links.join(', ')}`
|
||||
: ''
|
||||
}
|
||||
|
||||
Call this tool to execute the search. You can optionally provide additional context to refine the search.`,
|
||||
You can use this tool as-is to search with the prepared queries, or provide additionalContext to refine or replace the search terms.`,
|
||||
|
||||
inputSchema: z.object({
|
||||
additionalContext: z
|
||||
|
||||
@ -53,7 +53,7 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
|
||||
return {}
|
||||
}
|
||||
// Don't disable reasoning for models that require it
|
||||
if (isGrokReasoningModel(model) || isOpenAIReasoningModel(model)) {
|
||||
if (isGrokReasoningModel(model) || isOpenAIReasoningModel(model) || model.id.includes('seed-oss')) {
|
||||
return {}
|
||||
}
|
||||
return { reasoning: { enabled: false, exclude: true } }
|
||||
|
||||
@ -61,9 +61,10 @@ export function buildProviderBuiltinWebSearchConfig(
|
||||
}
|
||||
}
|
||||
case 'anthropic': {
|
||||
const blockedDomains = mapRegexToPatterns(webSearchConfig.excludeDomains)
|
||||
const anthropicSearchOptions: AnthropicSearchConfig = {
|
||||
maxUses: webSearchConfig.maxResults,
|
||||
blockedDomains: mapRegexToPatterns(webSearchConfig.excludeDomains)
|
||||
blockedDomains: blockedDomains.length > 0 ? blockedDomains : undefined
|
||||
}
|
||||
return {
|
||||
anthropic: anthropicSearchOptions
|
||||
|
||||
BIN
src/renderer/src/assets/images/providers/longcat.png
Normal file
BIN
src/renderer/src/assets/images/providers/longcat.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
@ -252,12 +252,39 @@ const PopupContainer: React.FC<Props> = ({ source, title, resolve }) => {
|
||||
let savedCount = 0
|
||||
|
||||
try {
|
||||
// Validate knowledge base configuration before proceeding
|
||||
if (!selectedBaseId) {
|
||||
throw new Error('No knowledge base selected')
|
||||
}
|
||||
|
||||
const selectedBase = bases.find((base) => base.id === selectedBaseId)
|
||||
if (!selectedBase) {
|
||||
throw new Error('Selected knowledge base not found')
|
||||
}
|
||||
|
||||
if (!selectedBase.version) {
|
||||
throw new Error('Knowledge base is not properly configured. Please check the knowledge base settings.')
|
||||
}
|
||||
|
||||
if (isNoteMode) {
|
||||
const note = source.data as NotesTreeNode
|
||||
const content = note.externalPath
|
||||
? await window.api.file.readExternal(note.externalPath)
|
||||
: await window.api.file.read(note.id + '.md')
|
||||
logger.debug('Note content:', content)
|
||||
if (!note.externalPath) {
|
||||
throw new Error('Note external path is required for export')
|
||||
}
|
||||
|
||||
let content = ''
|
||||
try {
|
||||
content = await window.api.file.readExternal(note.externalPath)
|
||||
} catch (error) {
|
||||
logger.error('Failed to read note file:', error as Error)
|
||||
throw new Error('Failed to read note content. Please ensure the file exists and is accessible.')
|
||||
}
|
||||
|
||||
if (!content || content.trim() === '') {
|
||||
throw new Error('Note content is empty. Cannot export empty notes to knowledge base.')
|
||||
}
|
||||
|
||||
logger.debug('Note content loaded', { contentLength: content.length })
|
||||
await addNote(content)
|
||||
savedCount = 1
|
||||
} else {
|
||||
@ -282,9 +309,23 @@ const PopupContainer: React.FC<Props> = ({ source, title, resolve }) => {
|
||||
resolve({ success: true, savedCount })
|
||||
} catch (error) {
|
||||
logger.error('save failed:', error as Error)
|
||||
window.toast.error(
|
||||
t(isTopicMode ? 'chat.save.topic.knowledge.error.save_failed' : 'chat.save.knowledge.error.save_failed')
|
||||
|
||||
// Provide more specific error messages
|
||||
let errorMessage = t(
|
||||
isTopicMode ? 'chat.save.topic.knowledge.error.save_failed' : 'chat.save.knowledge.error.save_failed'
|
||||
)
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('not properly configured')) {
|
||||
errorMessage = error.message
|
||||
} else if (error.message.includes('empty')) {
|
||||
errorMessage = error.message
|
||||
} else if (error.message.includes('read note content')) {
|
||||
errorMessage = error.message
|
||||
}
|
||||
}
|
||||
|
||||
window.toast.error(errorMessage)
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,7 +18,8 @@ export function renderSvgInShadowHost(svgContent: string, hostElement: HTMLEleme
|
||||
// Sanitize the SVG content
|
||||
const sanitizedContent = DOMPurify.sanitize(svgContent, {
|
||||
ADD_TAGS: ['animate', 'foreignObject', 'use'],
|
||||
ADD_ATTR: ['from', 'to']
|
||||
ADD_ATTR: ['from', 'to'],
|
||||
HTML_INTEGRATION_POINTS: { foreignobject: true }
|
||||
})
|
||||
|
||||
const shadowRoot = hostElement.shadowRoot || hostElement.attachShadow({ mode: 'open' })
|
||||
@ -36,6 +37,7 @@ export function renderSvgInShadowHost(svgContent: string, hostElement: HTMLEleme
|
||||
border-radius: var(--shadow-host-border-radius);
|
||||
padding: 1em;
|
||||
overflow: hidden; /* Prevent scrollbars, as scaling is now handled */
|
||||
white-space: normal;
|
||||
display: block;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
@ -457,7 +457,13 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
|
||||
// 面板可见且未折叠时:拦截所有 Enter 变体;
|
||||
// 纯 Enter 选择项,带修饰键仅拦截不处理
|
||||
if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) {
|
||||
if (e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
// Don't prevent default or stop propagation - let it create a newline
|
||||
setIsMouseOver(false)
|
||||
break
|
||||
}
|
||||
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsMouseOver(false)
|
||||
|
||||
@ -87,6 +87,9 @@ const CommandListPopover = ({
|
||||
return true
|
||||
|
||||
case 'Enter':
|
||||
if (event.shiftKey) {
|
||||
return false
|
||||
}
|
||||
event.preventDefault()
|
||||
if (items[internalSelectedIndex]) {
|
||||
selectItem(internalSelectedIndex)
|
||||
|
||||
@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { commandSuggestion } from '../command'
|
||||
|
||||
describe('commandSuggestion render', () => {
|
||||
it('has render function', () => {
|
||||
expect(commandSuggestion.render).toBeDefined()
|
||||
expect(typeof commandSuggestion.render).toBe('function')
|
||||
})
|
||||
|
||||
it('render function returns object with onKeyDown', () => {
|
||||
const renderResult = commandSuggestion.render?.()
|
||||
expect(renderResult).toBeDefined()
|
||||
expect(renderResult?.onKeyDown).toBeDefined()
|
||||
expect(typeof renderResult?.onKeyDown).toBe('function')
|
||||
})
|
||||
})
|
||||
@ -628,13 +628,34 @@ export const commandSuggestion: Omit<SuggestionOptions<Command, MentionNodeAttrs
|
||||
},
|
||||
|
||||
onKeyDown: (props) => {
|
||||
// Let CommandListPopover handle events first
|
||||
const popoverHandled = component.ref?.onKeyDown?.(props.event)
|
||||
if (popoverHandled) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Handle Shift+Enter for newline when popover doesn't handle it
|
||||
if (props.event.key === 'Enter' && props.event.shiftKey) {
|
||||
props.event.preventDefault()
|
||||
// Close the suggestion menu
|
||||
if (cleanup) cleanup()
|
||||
component.destroy()
|
||||
// Use the view from SuggestionKeyDownProps to insert newline
|
||||
const { view } = props
|
||||
const { state, dispatch } = view
|
||||
const { tr } = state
|
||||
tr.insertText('\n')
|
||||
dispatch(tr)
|
||||
return true
|
||||
}
|
||||
|
||||
if (props.event.key === 'Escape') {
|
||||
if (cleanup) cleanup()
|
||||
component.destroy()
|
||||
return true
|
||||
}
|
||||
|
||||
return component.ref?.onKeyDown(props.event)
|
||||
return false
|
||||
},
|
||||
|
||||
onExit: () => {
|
||||
|
||||
@ -25,7 +25,7 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
|
||||
// Default quick assistant model
|
||||
glm45FlashModel
|
||||
],
|
||||
cherryin: [],
|
||||
// cherryin: [],
|
||||
vertexai: [],
|
||||
'302ai': [
|
||||
{
|
||||
@ -1804,5 +1804,19 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
|
||||
provider: 'aionly',
|
||||
group: 'gemini'
|
||||
}
|
||||
],
|
||||
longcat: [
|
||||
{
|
||||
id: 'LongCat-Flash-Chat',
|
||||
name: 'LongCat Flash Chat',
|
||||
provider: 'longcat',
|
||||
group: 'LongCat'
|
||||
},
|
||||
{
|
||||
id: 'LongCat-Flash-Thinking',
|
||||
name: 'LongCat Flash Thinking',
|
||||
provider: 'longcat',
|
||||
group: 'LongCat'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -335,7 +335,7 @@ export const isDeepSeekHybridInferenceModel = (model: Model) => {
|
||||
const modelId = getLowerBaseModelName(model.id)
|
||||
// deepseek官方使用chat和reasoner做推理控制,其他provider需要单独判断,id可能会有所差别
|
||||
// openrouter: deepseek/deepseek-chat-v3.1 不知道会不会有其他provider仿照ds官方分出一个同id的作为非思考模式的模型,这里有风险
|
||||
return /deepseek-v3(?:\.1|-1-\d+)?/.test(modelId) || modelId.includes('deepseek-chat-v3.1')
|
||||
return /deepseek-v3(?:\.1|-1-\d+)/.test(modelId) || modelId.includes('deepseek-chat-v3.1')
|
||||
}
|
||||
|
||||
export const isSupportedThinkingTokenDeepSeekModel = isDeepSeekHybridInferenceModel
|
||||
@ -391,7 +391,8 @@ export function isReasoningModel(model?: Model): boolean {
|
||||
isDeepSeekHybridInferenceModel(model) ||
|
||||
modelId.includes('magistral') ||
|
||||
modelId.includes('minimax-m1') ||
|
||||
modelId.includes('pangu-pro-moe')
|
||||
modelId.includes('pangu-pro-moe') ||
|
||||
modelId.includes('seed-oss')
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
@ -27,6 +27,7 @@ import InfiniProviderLogo from '@renderer/assets/images/providers/infini.png'
|
||||
import JinaProviderLogo from '@renderer/assets/images/providers/jina.png'
|
||||
import LanyunProviderLogo from '@renderer/assets/images/providers/lanyun.png'
|
||||
import LMStudioProviderLogo from '@renderer/assets/images/providers/lmstudio.png'
|
||||
import LongCatProviderLogo from '@renderer/assets/images/providers/longcat.png'
|
||||
import MinimaxProviderLogo from '@renderer/assets/images/providers/minimax.png'
|
||||
import MistralProviderLogo from '@renderer/assets/images/providers/mistral.png'
|
||||
import ModelScopeProviderLogo from '@renderer/assets/images/providers/modelscope.png'
|
||||
@ -71,16 +72,16 @@ export const CHERRYAI_PROVIDER: SystemProvider = {
|
||||
}
|
||||
|
||||
export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> = {
|
||||
cherryin: {
|
||||
id: 'cherryin',
|
||||
name: 'CherryIN',
|
||||
type: 'openai',
|
||||
apiKey: '',
|
||||
apiHost: 'https://open.cherryin.ai',
|
||||
models: [],
|
||||
isSystem: true,
|
||||
enabled: true
|
||||
},
|
||||
// cherryin: {
|
||||
// id: 'cherryin',
|
||||
// name: 'CherryIN',
|
||||
// type: 'openai',
|
||||
// apiKey: '',
|
||||
// apiHost: 'https://open.cherryin.ai',
|
||||
// models: [],
|
||||
// isSystem: true,
|
||||
// enabled: true
|
||||
// },
|
||||
silicon: {
|
||||
id: 'silicon',
|
||||
name: 'Silicon',
|
||||
@ -615,6 +616,16 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
|
||||
models: SYSTEM_MODELS['poe'],
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
},
|
||||
longcat: {
|
||||
id: 'longcat',
|
||||
name: 'LongCat',
|
||||
type: 'openai',
|
||||
apiKey: '',
|
||||
apiHost: 'https://api.longcat.chat/openai',
|
||||
models: SYSTEM_MODELS.longcat,
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
}
|
||||
} as const
|
||||
|
||||
@ -677,7 +688,8 @@ export const PROVIDER_LOGO_MAP: AtLeast<SystemProviderId, string> = {
|
||||
'new-api': NewAPIProviderLogo,
|
||||
'aws-bedrock': AwsProviderLogo,
|
||||
poe: 'poe', // use svg icon component
|
||||
aionly: AiOnlyProviderLogo
|
||||
aionly: AiOnlyProviderLogo,
|
||||
longcat: LongCatProviderLogo
|
||||
} as const
|
||||
|
||||
export function getProviderLogo(providerId: string) {
|
||||
@ -701,17 +713,17 @@ type ProviderUrls = {
|
||||
}
|
||||
|
||||
export const PROVIDER_URLS: Record<SystemProviderId, ProviderUrls> = {
|
||||
cherryin: {
|
||||
api: {
|
||||
url: 'https://open.cherryin.ai'
|
||||
},
|
||||
websites: {
|
||||
official: 'https://open.cherryin.ai',
|
||||
apiKey: 'https://open.cherryin.ai/console/token',
|
||||
docs: 'https://open.cherryin.ai',
|
||||
models: 'https://open.cherryin.ai/pricing'
|
||||
}
|
||||
},
|
||||
// cherryin: {
|
||||
// api: {
|
||||
// url: 'https://open.cherryin.ai'
|
||||
// },
|
||||
// websites: {
|
||||
// official: 'https://open.cherryin.ai',
|
||||
// apiKey: 'https://open.cherryin.ai/console/token',
|
||||
// docs: 'https://open.cherryin.ai',
|
||||
// models: 'https://open.cherryin.ai/pricing'
|
||||
// }
|
||||
// },
|
||||
ph8: {
|
||||
api: {
|
||||
url: 'https://ph8.co'
|
||||
@ -1283,6 +1295,17 @@ export const PROVIDER_URLS: Record<SystemProviderId, ProviderUrls> = {
|
||||
docs: 'https://www.aiionly.com/document',
|
||||
models: 'https://www.aiionly.com'
|
||||
}
|
||||
},
|
||||
longcat: {
|
||||
api: {
|
||||
url: 'https://api.longcat.chat/openai'
|
||||
},
|
||||
websites: {
|
||||
official: 'https://longcat.chat',
|
||||
apiKey: 'https://longcat.chat/platform/api_keys',
|
||||
docs: 'https://longcat.chat/platform/docs/zh/',
|
||||
models: 'https://longcat.chat/platform/docs/zh/APIDocs.html'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -7,7 +7,6 @@ import type {
|
||||
} from '@renderer/types'
|
||||
// Import necessary types for blocks and new message structure
|
||||
import type { Message as NewMessage, MessageBlock } from '@renderer/types/newMessage'
|
||||
import type { NotesTreeNode } from '@renderer/types/note'
|
||||
import { Dexie, type EntityTable } from 'dexie'
|
||||
|
||||
import { upgradeToV5, upgradeToV7, upgradeToV8 } from './upgrades'
|
||||
@ -24,7 +23,6 @@ export const db = new Dexie('CherryStudio', {
|
||||
quick_phrases: EntityTable<QuickPhrase, 'id'>
|
||||
message_blocks: EntityTable<MessageBlock, 'id'> // Correct type for message_blocks
|
||||
translate_languages: EntityTable<CustomTranslateLanguage, 'id'>
|
||||
notes_tree: EntityTable<{ id: string; tree: NotesTreeNode[] }, 'id'>
|
||||
}
|
||||
|
||||
db.version(1).stores({
|
||||
@ -118,8 +116,7 @@ db.version(10).stores({
|
||||
translate_history: '&id, sourceText, targetText, sourceLanguage, targetLanguage, createdAt',
|
||||
translate_languages: '&id, langCode',
|
||||
quick_phrases: 'id',
|
||||
message_blocks: 'id, messageId, file.id',
|
||||
notes_tree: '&id'
|
||||
message_blocks: 'id, messageId, file.id'
|
||||
})
|
||||
|
||||
export default db
|
||||
|
||||
@ -13,7 +13,7 @@ import { loggerService } from '@renderer/services/LoggerService'
|
||||
import { getModelUniqId } from '@renderer/services/ModelService'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { setIsBunInstalled } from '@renderer/store/mcp'
|
||||
import type { Model } from '@renderer/types'
|
||||
import type { EndpointType, Model } from '@renderer/types'
|
||||
import type { TerminalConfig } from '@shared/config/constant'
|
||||
import { codeTools, terminalApps } from '@shared/config/constant'
|
||||
import { Alert, Avatar, Button, Checkbox, Input, Popover, Select, Space, Tooltip } from 'antd'
|
||||
@ -72,18 +72,43 @@ const CodeToolsPage: FC = () => {
|
||||
if (isEmbeddingModel(m) || isRerankModel(m) || isTextToImageModel(m)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (m.provider === 'cherryai') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (selectedCliTool === codeTools.claudeCode) {
|
||||
if (m.supported_endpoint_types) {
|
||||
return m.supported_endpoint_types.includes('anthropic')
|
||||
}
|
||||
return m.id.includes('claude') || CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS.includes(m.provider)
|
||||
}
|
||||
|
||||
if (selectedCliTool === codeTools.geminiCli) {
|
||||
if (m.supported_endpoint_types) {
|
||||
return m.supported_endpoint_types.includes('gemini')
|
||||
}
|
||||
return m.id.includes('gemini')
|
||||
}
|
||||
|
||||
if (selectedCliTool === codeTools.openaiCodex) {
|
||||
if (m.supported_endpoint_types) {
|
||||
return ['openai', 'openai-response'].some((type) =>
|
||||
m.supported_endpoint_types?.includes(type as EndpointType)
|
||||
)
|
||||
}
|
||||
return m.id.includes('openai') || OPENAI_CODEX_SUPPORTED_PROVIDERS.includes(m.provider)
|
||||
}
|
||||
|
||||
if (selectedCliTool === codeTools.qwenCode || selectedCliTool === codeTools.iFlowCli) {
|
||||
if (m.supported_endpoint_types) {
|
||||
return ['openai', 'openai-response'].some((type) =>
|
||||
m.supported_endpoint_types?.includes(type as EndpointType)
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
[selectedCliTool]
|
||||
|
||||
@ -23,10 +23,16 @@ export const CLI_TOOLS = [
|
||||
{ value: codeTools.iFlowCli, label: 'iFlow CLI' }
|
||||
]
|
||||
|
||||
export const GEMINI_SUPPORTED_PROVIDERS = ['aihubmix', 'dmxapi', 'new-api']
|
||||
export const GEMINI_SUPPORTED_PROVIDERS = ['aihubmix', 'dmxapi', 'new-api', 'cherryin']
|
||||
export const CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS = ['deepseek', 'moonshot', 'zhipu', 'dashscope', 'modelscope']
|
||||
export const CLAUDE_SUPPORTED_PROVIDERS = ['aihubmix', 'dmxapi', 'new-api', ...CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS]
|
||||
export const OPENAI_CODEX_SUPPORTED_PROVIDERS = ['openai', 'openrouter', 'aihubmix', 'new-api']
|
||||
export const CLAUDE_SUPPORTED_PROVIDERS = [
|
||||
'aihubmix',
|
||||
'dmxapi',
|
||||
'new-api',
|
||||
'cherryin',
|
||||
...CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS
|
||||
]
|
||||
export const OPENAI_CODEX_SUPPORTED_PROVIDERS = ['openai', 'openrouter', 'aihubmix', 'new-api', 'cherryin']
|
||||
|
||||
// Provider 过滤映射
|
||||
export const CLI_TOOL_PROVIDER_MAP: Record<string, (providers: Provider[]) => Provider[]> = {
|
||||
|
||||
@ -251,21 +251,19 @@ const MentionModelsButton: FC<Props> = ({
|
||||
// ESC关闭时的处理:删除 @ 和搜索文本
|
||||
if (action === 'esc') {
|
||||
// 只有在输入触发且有模型选择动作时才删除@字符和搜索文本
|
||||
if (
|
||||
hasModelActionRef.current &&
|
||||
ctx.triggerInfo?.type === 'input' &&
|
||||
ctx.triggerInfo?.position !== undefined
|
||||
) {
|
||||
const triggerInfo = ctx?.triggerInfo ?? triggerInfoRef.current
|
||||
if (hasModelActionRef.current && triggerInfo?.type === 'input' && triggerInfo?.position !== undefined) {
|
||||
// 基于当前光标 + 搜索词精确定位并删除,position 仅作兜底
|
||||
setText((currentText) => {
|
||||
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement | null
|
||||
const caret = textArea ? (textArea.selectionStart ?? currentText.length) : currentText.length
|
||||
return removeAtSymbolAndText(currentText, caret, searchText || '', ctx.triggerInfo?.position!)
|
||||
return removeAtSymbolAndText(currentText, caret, searchText || '', triggerInfo.position!)
|
||||
})
|
||||
}
|
||||
}
|
||||
// Backspace删除@的情况(delete-symbol):
|
||||
// @ 已经被Backspace自然删除,面板关闭,不需要额外操作
|
||||
triggerInfoRef.current = undefined
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
@ -101,7 +101,8 @@ const MessageErrorInfo: React.FC<{ block: ErrorMessageBlock; message: Message }>
|
||||
const [showDetailModal, setShowDetailModal] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const onRemoveBlock = () => {
|
||||
const onRemoveBlock = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setTimeoutTimer('onRemoveBlock', () => dispatch(removeBlocksThunk(message.topicId, message.id, [block.id])), 350)
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { LoadingIcon } from '@renderer/components/Icons'
|
||||
import { Spinner } from '@heroui/react'
|
||||
import { MessageBlockStatus, MessageBlockType, type PlaceholderMessageBlock } from '@renderer/types/newMessage'
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
@ -10,7 +10,7 @@ const PlaceholderBlock: React.FC<PlaceholderBlockProps> = ({ block }) => {
|
||||
if (block.status === MessageBlockStatus.PROCESSING && block.type === MessageBlockType.UNKNOWN) {
|
||||
return (
|
||||
<MessageContentLoading>
|
||||
<LoadingIcon />
|
||||
<Spinner color="current" variant="dots" />
|
||||
</MessageContentLoading>
|
||||
)
|
||||
}
|
||||
|
||||
@ -5,7 +5,7 @@ import ThinkingEffect from '@renderer/components/ThinkingEffect'
|
||||
import { useTemporaryValue } from '@renderer/hooks/useTemporaryValue'
|
||||
import { MessageBlockStatus, type ThinkingMessageBlock } from '@renderer/types/newMessage'
|
||||
import { Collapse, Tooltip } from 'antd'
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@ -107,30 +107,37 @@ const ThinkingBlock: React.FC<Props> = ({ block }) => {
|
||||
const ThinkingTimeSeconds = memo(
|
||||
({ blockThinkingTime, isThinking }: { blockThinkingTime: number; isThinking: boolean }) => {
|
||||
const { t } = useTranslation()
|
||||
// const [thinkingTime, setThinkingTime] = useState(blockThinkingTime || 0)
|
||||
const [displayTime, setDisplayTime] = useState(blockThinkingTime)
|
||||
|
||||
// FIXME: 这里统计的和请求处统计的有一定误差
|
||||
// useEffect(() => {
|
||||
// let timer: NodeJS.Timeout | null = null
|
||||
// if (isThinking) {
|
||||
// timer = setInterval(() => {
|
||||
// setThinkingTime((prev) => prev + 100)
|
||||
// }, 100)
|
||||
// } else if (timer) {
|
||||
// // 立即清除计时器
|
||||
// clearInterval(timer)
|
||||
// timer = null
|
||||
// }
|
||||
const timer = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
// return () => {
|
||||
// if (timer) {
|
||||
// clearInterval(timer)
|
||||
// timer = null
|
||||
// }
|
||||
// }
|
||||
// }, [isThinking])
|
||||
useEffect(() => {
|
||||
if (isThinking) {
|
||||
if (!timer.current) {
|
||||
timer.current = setInterval(() => {
|
||||
setDisplayTime((prev) => prev + 100)
|
||||
}, 100)
|
||||
}
|
||||
} else {
|
||||
if (timer.current) {
|
||||
clearInterval(timer.current)
|
||||
timer.current = null
|
||||
}
|
||||
setDisplayTime(blockThinkingTime)
|
||||
}
|
||||
|
||||
const thinkingTimeSeconds = useMemo(() => (blockThinkingTime / 1000).toFixed(1), [blockThinkingTime])
|
||||
return () => {
|
||||
if (timer.current) {
|
||||
clearInterval(timer.current)
|
||||
timer.current = null
|
||||
}
|
||||
}
|
||||
}, [isThinking, blockThinkingTime])
|
||||
|
||||
const thinkingTimeSeconds = useMemo(
|
||||
() => ((displayTime < 1000 ? 100 : displayTime) / 1000).toFixed(1),
|
||||
[displayTime]
|
||||
)
|
||||
|
||||
return isThinking
|
||||
? t('chat.thinking', {
|
||||
|
||||
@ -261,13 +261,12 @@ describe('ThinkingBlock', () => {
|
||||
renderThinkingBlock(thinkingBlock)
|
||||
|
||||
const activeTimeText = getThinkingTimeText()
|
||||
expect(activeTimeText).toHaveTextContent('1.0s')
|
||||
expect(activeTimeText).toHaveTextContent('Thinking...')
|
||||
})
|
||||
|
||||
it('should handle extreme thinking times correctly', () => {
|
||||
const testCases = [
|
||||
{ thinking_millsec: 0, expectedTime: '0.0s' },
|
||||
{ thinking_millsec: 0, expectedTime: '0.1s' }, // New logic: values < 1000ms display as 0.1s
|
||||
{ thinking_millsec: 86400000, expectedTime: '86400.0s' }, // 1 day
|
||||
{ thinking_millsec: 259200000, expectedTime: '259200.0s' } // 3 days
|
||||
]
|
||||
|
||||
@ -3,7 +3,7 @@ import type { RootState } from '@renderer/store'
|
||||
import { messageBlocksSelectors } from '@renderer/store/messageBlock'
|
||||
import type { ImageMessageBlock, Message, MessageBlock } from '@renderer/types/newMessage'
|
||||
import { MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
|
||||
import { isMainTextBlock, isVideoBlock } from '@renderer/utils/messageUtils/is'
|
||||
import { isMainTextBlock, isMessageProcessing, isVideoBlock } from '@renderer/utils/messageUtils/is'
|
||||
import { AnimatePresence, motion, type Variants } from 'motion/react'
|
||||
import React, { useMemo } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
@ -107,6 +107,9 @@ const MessageBlockRenderer: React.FC<Props> = ({ blocks, message }) => {
|
||||
const renderedBlocks = blocks.map((blockId) => blockEntities[blockId]).filter(Boolean)
|
||||
const groupedBlocks = useMemo(() => groupSimilarBlocks(renderedBlocks), [renderedBlocks])
|
||||
|
||||
// Check if message is still processing
|
||||
const isProcessing = isMessageProcessing(message)
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="sync">
|
||||
{groupedBlocks.map((block) => {
|
||||
@ -151,9 +154,6 @@ const MessageBlockRenderer: React.FC<Props> = ({ blocks, message }) => {
|
||||
|
||||
switch (block.type) {
|
||||
case MessageBlockType.UNKNOWN:
|
||||
if (block.status === MessageBlockStatus.PROCESSING) {
|
||||
blockComponent = <PlaceholderBlock key={block.id} block={block} />
|
||||
}
|
||||
break
|
||||
case MessageBlockType.MAIN_TEXT:
|
||||
case MessageBlockType.CODE: {
|
||||
@ -213,6 +213,19 @@ const MessageBlockRenderer: React.FC<Props> = ({ blocks, message }) => {
|
||||
</AnimatedBlockWrapper>
|
||||
)
|
||||
})}
|
||||
{isProcessing && (
|
||||
<AnimatedBlockWrapper key="message-loading-placeholder" enableAnimation={true}>
|
||||
<PlaceholderBlock
|
||||
block={{
|
||||
id: `loading-${message.id}`,
|
||||
messageId: message.id,
|
||||
type: MessageBlockType.UNKNOWN,
|
||||
status: MessageBlockStatus.PROCESSING,
|
||||
createdAt: new Date().toISOString()
|
||||
}}
|
||||
/>
|
||||
</AnimatedBlockWrapper>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
import type { KnowledgeSearchToolInput, KnowledgeSearchToolOutput } from '@renderer/aiCore/tools/KnowledgeSearchTool'
|
||||
import Spinner from '@renderer/components/Spinner'
|
||||
import i18n from '@renderer/i18n'
|
||||
import type { MCPToolResponse } from '@renderer/types'
|
||||
import type { NormalToolResponse } from '@renderer/types'
|
||||
import { Typography } from 'antd'
|
||||
import { FileSearch } from 'lucide-react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const { Text } = Typography
|
||||
export function MessageKnowledgeSearchToolTitle({ toolResponse }: { toolResponse: MCPToolResponse }) {
|
||||
export function MessageKnowledgeSearchToolTitle({ toolResponse }: { toolResponse: NormalToolResponse }) {
|
||||
const toolInput = toolResponse.arguments as KnowledgeSearchToolInput
|
||||
const toolOutput = toolResponse.response as KnowledgeSearchToolOutput
|
||||
|
||||
@ -28,7 +28,7 @@ export function MessageKnowledgeSearchToolTitle({ toolResponse }: { toolResponse
|
||||
)
|
||||
}
|
||||
|
||||
export function MessageKnowledgeSearchToolBody({ toolResponse }: { toolResponse: MCPToolResponse }) {
|
||||
export function MessageKnowledgeSearchToolBody({ toolResponse }: { toolResponse: NormalToolResponse }) {
|
||||
const toolOutput = toolResponse.response as KnowledgeSearchToolOutput
|
||||
|
||||
return toolResponse.status === 'done' ? (
|
||||
|
||||
@ -5,6 +5,7 @@ import { CopyIcon, LoadingIcon } from '@renderer/components/Icons'
|
||||
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
||||
import { useMCPServers } from '@renderer/hooks/useMCPServers'
|
||||
import { useTimer } from '@renderer/hooks/useTimer'
|
||||
import { MCPToolResponse } from '@renderer/types'
|
||||
import type { ToolMessageBlock } from '@renderer/types/newMessage'
|
||||
import { isToolAutoApproved } from '@renderer/utils/mcp-tools'
|
||||
import { cancelToolAction, confirmToolAction } from '@renderer/utils/userConfirmation'
|
||||
@ -48,7 +49,7 @@ const MessageMcpTool: FC<Props> = ({ block }) => {
|
||||
const [progress, setProgress] = useState<number>(0)
|
||||
const { setTimeoutTimer } = useTimer()
|
||||
|
||||
const toolResponse = block.metadata?.rawMcpToolResponse
|
||||
const toolResponse = block.metadata?.rawMcpToolResponse as MCPToolResponse
|
||||
|
||||
const { id, tool, status, response } = toolResponse!
|
||||
const isPending = status === 'pending'
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { MemorySearchToolInput, MemorySearchToolOutput } from '@renderer/aiCore/tools/MemorySearchTool'
|
||||
import Spinner from '@renderer/components/Spinner'
|
||||
import type { MCPToolResponse } from '@renderer/types'
|
||||
import type { NormalToolResponse } from '@renderer/types'
|
||||
import { Typography } from 'antd'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -8,7 +8,7 @@ import styled from 'styled-components'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
export const MessageMemorySearchToolTitle = ({ toolResponse }: { toolResponse: MCPToolResponse }) => {
|
||||
export const MessageMemorySearchToolTitle = ({ toolResponse }: { toolResponse: NormalToolResponse }) => {
|
||||
const { t } = useTranslation()
|
||||
const toolInput = toolResponse.arguments as MemorySearchToolInput
|
||||
const toolOutput = toolResponse.response as MemorySearchToolOutput
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { MCPToolResponse } from '@renderer/types'
|
||||
import type { NormalToolResponse } from '@renderer/types'
|
||||
import type { ToolMessageBlock } from '@renderer/types/newMessage'
|
||||
import { Collapse } from 'antd'
|
||||
|
||||
@ -11,8 +11,9 @@ interface Props {
|
||||
}
|
||||
const prefix = 'builtin_'
|
||||
|
||||
const ChooseTool = (toolResponse: MCPToolResponse): { label: React.ReactNode; body: React.ReactNode } | null => {
|
||||
const ChooseTool = (toolResponse: NormalToolResponse): { label: React.ReactNode; body: React.ReactNode } | null => {
|
||||
let toolName = toolResponse.tool.name
|
||||
const toolType = toolResponse.tool.type
|
||||
if (toolName.startsWith(prefix)) {
|
||||
toolName = toolName.slice(prefix.length)
|
||||
}
|
||||
@ -20,10 +21,12 @@ const ChooseTool = (toolResponse: MCPToolResponse): { label: React.ReactNode; bo
|
||||
switch (toolName) {
|
||||
case 'web_search':
|
||||
case 'web_search_preview':
|
||||
return {
|
||||
label: <MessageWebSearchToolTitle toolResponse={toolResponse} />,
|
||||
body: null
|
||||
}
|
||||
return toolType === 'provider'
|
||||
? null
|
||||
: {
|
||||
label: <MessageWebSearchToolTitle toolResponse={toolResponse} />,
|
||||
body: null
|
||||
}
|
||||
case 'knowledge_search':
|
||||
return {
|
||||
label: <MessageKnowledgeSearchToolTitle toolResponse={toolResponse} />,
|
||||
@ -41,7 +44,7 @@ const ChooseTool = (toolResponse: MCPToolResponse): { label: React.ReactNode; bo
|
||||
|
||||
export default function MessageTool({ block }: Props) {
|
||||
// FIXME: 语义错误,这里已经不是 MCP tool 了,更改rawMcpToolResponse需要改用户数据, 所以暂时保留
|
||||
const toolResponse = block.metadata?.rawMcpToolResponse
|
||||
const toolResponse = block.metadata?.rawMcpToolResponse as NormalToolResponse
|
||||
|
||||
if (!toolResponse) return null
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { WebSearchToolInput, WebSearchToolOutput } from '@renderer/aiCore/tools/WebSearchTool'
|
||||
import Spinner from '@renderer/components/Spinner'
|
||||
import type { MCPToolResponse } from '@renderer/types'
|
||||
import type { NormalToolResponse } from '@renderer/types'
|
||||
import { Typography } from 'antd'
|
||||
import { Search } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -8,7 +8,7 @@ import styled from 'styled-components'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
export const MessageWebSearchToolTitle = ({ toolResponse }: { toolResponse: MCPToolResponse }) => {
|
||||
export const MessageWebSearchToolTitle = ({ toolResponse }: { toolResponse: NormalToolResponse }) => {
|
||||
const { t } = useTranslation()
|
||||
const toolInput = toolResponse.arguments as WebSearchToolInput
|
||||
const toolOutput = toolResponse.response as WebSearchToolOutput
|
||||
|
||||
@ -5,24 +5,25 @@ import { NavbarCenter, NavbarHeader, NavbarRight } from '@renderer/components/ap
|
||||
import { useActiveNode } from '@renderer/hooks/useNotesQuery'
|
||||
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
|
||||
import { useShowWorkspace } from '@renderer/hooks/useShowWorkspace'
|
||||
import { findNodeByPath, findNodeInTree, updateNodeInTree } from '@renderer/services/NotesTreeService'
|
||||
import type { NotesTreeNode } from '@types'
|
||||
import { Dropdown, Tooltip } from 'antd'
|
||||
import { findNode } from '@renderer/services/NotesTreeService'
|
||||
import { Dropdown, Input, Tooltip } from 'antd'
|
||||
import { t } from 'i18next'
|
||||
import { MoreHorizontal, PanelLeftClose, PanelRightClose, Star } from 'lucide-react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { menuItems } from './MenuConfig'
|
||||
|
||||
const logger = loggerService.withContext('HeaderNavbar')
|
||||
|
||||
const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar }) => {
|
||||
const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar, onExpandPath, onRenameNode }) => {
|
||||
const { showWorkspace, toggleShowWorkspace } = useShowWorkspace()
|
||||
const { activeNode } = useActiveNode(notesTree)
|
||||
const [breadcrumbItems, setBreadcrumbItems] = useState<
|
||||
Array<{ key: string; title: string; treePath: string; isFolder: boolean }>
|
||||
>([])
|
||||
const [titleValue, setTitleValue] = useState('')
|
||||
const titleInputRef = useRef<any>(null)
|
||||
const { settings, updateSettings } = useNotesSettings()
|
||||
const canShowStarButton = activeNode?.type === 'file' && onToggleStar
|
||||
|
||||
@ -52,37 +53,41 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar }) => {
|
||||
}, [getCurrentNoteContent])
|
||||
|
||||
const handleBreadcrumbClick = useCallback(
|
||||
async (item: { treePath: string; isFolder: boolean }) => {
|
||||
if (item.isFolder && notesTree) {
|
||||
try {
|
||||
// 获取从根目录到点击目录的所有路径片段
|
||||
const pathParts = item.treePath.split('/').filter(Boolean)
|
||||
const expandPromises: Promise<NotesTreeNode>[] = []
|
||||
|
||||
// 逐级展开从根到目标路径的所有文件夹
|
||||
for (let i = 0; i < pathParts.length; i++) {
|
||||
const currentPath = '/' + pathParts.slice(0, i + 1).join('/')
|
||||
const folderNode = findNodeByPath(notesTree, currentPath)
|
||||
|
||||
if (folderNode && folderNode.type === 'folder' && !folderNode.expanded) {
|
||||
expandPromises.push(updateNodeInTree(notesTree, folderNode.id, { expanded: true }))
|
||||
}
|
||||
}
|
||||
|
||||
// 并行执行所有展开操作
|
||||
if (expandPromises.length > 0) {
|
||||
await Promise.all(expandPromises)
|
||||
logger.info('Expanded folder path from breadcrumb:', {
|
||||
targetPath: item.treePath,
|
||||
expandedCount: expandPromises.length
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to expand folder path from breadcrumb:', error as Error)
|
||||
}
|
||||
(item: { treePath: string; isFolder: boolean }) => {
|
||||
if (item.isFolder && onExpandPath) {
|
||||
onExpandPath(item.treePath)
|
||||
}
|
||||
},
|
||||
[notesTree]
|
||||
[onExpandPath]
|
||||
)
|
||||
|
||||
const handleTitleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setTitleValue(e.target.value)
|
||||
}, [])
|
||||
|
||||
const handleTitleBlur = useCallback(() => {
|
||||
if (activeNode && titleValue.trim() && titleValue.trim() !== activeNode.name.replace('.md', '')) {
|
||||
onRenameNode?.(activeNode.id, titleValue.trim())
|
||||
} else if (activeNode) {
|
||||
// 如果没有更改或为空,恢复原始值
|
||||
setTitleValue(activeNode.name.replace('.md', ''))
|
||||
}
|
||||
}, [activeNode, titleValue, onRenameNode])
|
||||
|
||||
const handleTitleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
titleInputRef.current?.blur()
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
if (activeNode) {
|
||||
setTitleValue(activeNode.name.replace('.md', ''))
|
||||
}
|
||||
titleInputRef.current?.blur()
|
||||
}
|
||||
},
|
||||
[activeNode]
|
||||
)
|
||||
|
||||
const buildMenuItem = (item: any) => {
|
||||
@ -133,13 +138,20 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar }) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 同步标题值
|
||||
useEffect(() => {
|
||||
if (activeNode?.type === 'file') {
|
||||
setTitleValue(activeNode.name.replace('.md', ''))
|
||||
}
|
||||
}, [activeNode])
|
||||
|
||||
// 构建面包屑路径
|
||||
useEffect(() => {
|
||||
if (!activeNode || !notesTree) {
|
||||
setBreadcrumbItems([])
|
||||
return
|
||||
}
|
||||
const node = findNodeInTree(notesTree, activeNode.id)
|
||||
const node = findNode(notesTree, activeNode.id)
|
||||
if (!node) return
|
||||
|
||||
const pathParts = node.treePath.split('/').filter(Boolean)
|
||||
@ -179,16 +191,41 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar }) => {
|
||||
</RowFlex>
|
||||
<NavbarCenter style={{ flex: 1, minWidth: 0 }}>
|
||||
<BreadcrumbsContainer>
|
||||
<Breadcrumbs>
|
||||
{breadcrumbItems.map((item, index) => (
|
||||
<BreadcrumbItem key={item.key} isCurrent={index === breadcrumbItems.length - 1}>
|
||||
<BreadcrumbTitle
|
||||
onClick={() => handleBreadcrumbClick(item)}
|
||||
$clickable={item.isFolder && index < breadcrumbItems.length - 1}>
|
||||
{item.title}
|
||||
</BreadcrumbTitle>
|
||||
</BreadcrumbItem>
|
||||
))}
|
||||
<Breadcrumbs style={{ borderRadius: 0 }}>
|
||||
{breadcrumbItems.map((item, index) => {
|
||||
const isLastItem = index === breadcrumbItems.length - 1
|
||||
const isCurrentNote = isLastItem && !item.isFolder
|
||||
|
||||
return (
|
||||
<BreadcrumbItem key={item.key} isCurrent={isLastItem}>
|
||||
{isCurrentNote ? (
|
||||
<TitleInputWrapper>
|
||||
<TitleInput
|
||||
ref={titleInputRef}
|
||||
value={titleValue}
|
||||
onChange={handleTitleChange}
|
||||
onBlur={handleTitleBlur}
|
||||
onKeyDown={handleTitleKeyDown}
|
||||
size="small"
|
||||
variant="borderless"
|
||||
style={{
|
||||
fontSize: 'inherit',
|
||||
padding: 0,
|
||||
height: 'auto',
|
||||
lineHeight: 'inherit'
|
||||
}}
|
||||
/>
|
||||
</TitleInputWrapper>
|
||||
) : (
|
||||
<BreadcrumbTitle
|
||||
onClick={() => handleBreadcrumbClick(item)}
|
||||
$clickable={item.isFolder && !isLastItem}>
|
||||
{item.title}
|
||||
</BreadcrumbTitle>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
)
|
||||
})}
|
||||
</Breadcrumbs>
|
||||
</BreadcrumbsContainer>
|
||||
</NavbarCenter>
|
||||
@ -303,6 +340,30 @@ export const BreadcrumbsContainer = styled.div`
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 最后一个面包屑项(当前笔记)可以扩展 */
|
||||
& li:last-child {
|
||||
flex: 1 !important;
|
||||
min-width: 0 !important;
|
||||
max-width: none !important;
|
||||
}
|
||||
|
||||
/* 覆盖 HeroUI BreadcrumbItem 的样式 */
|
||||
& li:last-child [data-slot="item"] {
|
||||
flex: 1 !important;
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
}
|
||||
|
||||
/* 更强的样式覆盖 */
|
||||
& li:last-child * {
|
||||
max-width: none !important;
|
||||
}
|
||||
|
||||
& li:last-child > * {
|
||||
flex: 1 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* 确保分隔符不会与标题重叠 */
|
||||
& li:not(:last-child)::after {
|
||||
flex-shrink: 0;
|
||||
@ -330,4 +391,64 @@ export const BreadcrumbTitle = styled.span<{ $clickable?: boolean }>`
|
||||
`}
|
||||
`
|
||||
|
||||
export const TitleInputWrapper = styled.div`
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`
|
||||
|
||||
export const TitleInput = styled(Input)`
|
||||
&&& {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
color: inherit !important;
|
||||
font-size: inherit !important;
|
||||
font-weight: inherit !important;
|
||||
font-family: inherit !important;
|
||||
padding: 0 !important;
|
||||
height: auto !important;
|
||||
line-height: inherit !important;
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
max-width: none !important;
|
||||
flex: 1 !important;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-text-3) !important;
|
||||
}
|
||||
|
||||
input {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
color: inherit !important;
|
||||
font-size: inherit !important;
|
||||
font-weight: inherit !important;
|
||||
font-family: inherit !important;
|
||||
padding: 0 !important;
|
||||
height: auto !important;
|
||||
line-height: inherit !important;
|
||||
width: 100% !important;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export default HeaderNavbar
|
||||
|
||||
@ -6,7 +6,7 @@ import Selector from '@renderer/components/Selector'
|
||||
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
||||
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
|
||||
import type { EditorView } from '@renderer/types'
|
||||
import { Empty, Spin } from 'antd'
|
||||
import { Empty } from 'antd'
|
||||
import type { FC, RefObject } from 'react'
|
||||
import { memo, useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -16,13 +16,12 @@ interface NotesEditorProps {
|
||||
activeNodeId?: string
|
||||
currentContent: string
|
||||
tokenCount: number
|
||||
isLoading: boolean
|
||||
editorRef: RefObject<RichEditorRef | null>
|
||||
onMarkdownChange: (content: string) => void
|
||||
}
|
||||
|
||||
const NotesEditor: FC<NotesEditorProps> = memo(
|
||||
({ activeNodeId, currentContent, tokenCount, isLoading, onMarkdownChange, editorRef }) => {
|
||||
({ activeNodeId, currentContent, tokenCount, onMarkdownChange, editorRef }) => {
|
||||
const { t } = useTranslation()
|
||||
const { settings } = useNotesSettings()
|
||||
const { activeCmTheme } = useCodeStyle()
|
||||
@ -50,14 +49,6 @@ const NotesEditor: FC<NotesEditorProps> = memo(
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<LoadingContainer>
|
||||
<Spin tip={t('common.loading')} />
|
||||
</LoadingContainer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<RichEditorContainer>
|
||||
@ -126,14 +117,6 @@ const NotesEditor: FC<NotesEditorProps> = memo(
|
||||
|
||||
NotesEditor.displayName = 'NotesEditor'
|
||||
|
||||
const LoadingContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`
|
||||
|
||||
const EmptyContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@ -5,21 +5,38 @@ import { useActiveNode, useFileContent, useFileContentSync } from '@renderer/hoo
|
||||
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
|
||||
import { useShowWorkspace } from '@renderer/hooks/useShowWorkspace'
|
||||
import {
|
||||
createFolder,
|
||||
createNote,
|
||||
deleteNode,
|
||||
initWorkSpace,
|
||||
moveNode,
|
||||
renameNode,
|
||||
sortAllLevels,
|
||||
uploadFiles
|
||||
addDir,
|
||||
addNote,
|
||||
delNode,
|
||||
loadTree,
|
||||
renameNode as renameEntry,
|
||||
sortTree,
|
||||
uploadNotes
|
||||
} from '@renderer/services/NotesService'
|
||||
import { getNotesTree, isParentNode, updateNodeInTree } from '@renderer/services/NotesTreeService'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { selectActiveFilePath, selectSortType, setActiveFilePath, setSortType } from '@renderer/store/note'
|
||||
import {
|
||||
addUniquePath,
|
||||
findNode,
|
||||
findNodeByPath,
|
||||
findParent,
|
||||
normalizePathValue,
|
||||
removePathEntries,
|
||||
reorderTreeNodes,
|
||||
replacePathEntries,
|
||||
updateTreeNode
|
||||
} from '@renderer/services/NotesTreeService'
|
||||
import { useAppDispatch, useAppSelector, useAppStore } from '@renderer/store'
|
||||
import {
|
||||
selectActiveFilePath,
|
||||
selectExpandedPaths,
|
||||
selectSortType,
|
||||
selectStarredPaths,
|
||||
setActiveFilePath,
|
||||
setExpandedPaths,
|
||||
setSortType,
|
||||
setStarredPaths
|
||||
} from '@renderer/store/note'
|
||||
import type { NotesSortType, NotesTreeNode } from '@renderer/types/note'
|
||||
import type { FileChangeEvent } from '@shared/config/types'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
import { debounce } from 'lodash'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import type { FC } from 'react'
|
||||
@ -38,27 +55,98 @@ const NotesPage: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { showWorkspace } = useShowWorkspace()
|
||||
const dispatch = useAppDispatch()
|
||||
const store = useAppStore()
|
||||
const activeFilePath = useAppSelector(selectActiveFilePath)
|
||||
const sortType = useAppSelector(selectSortType)
|
||||
const starredPaths = useAppSelector(selectStarredPaths)
|
||||
const expandedPaths = useAppSelector(selectExpandedPaths)
|
||||
const { settings, notesPath, updateNotesPath } = useNotesSettings()
|
||||
|
||||
// 混合策略:useLiveQuery用于笔记树,React Query用于文件内容
|
||||
const notesTreeQuery = useLiveQuery(() => getNotesTree(), [])
|
||||
const notesTree = useMemo(() => notesTreeQuery || [], [notesTreeQuery])
|
||||
const [notesTree, setNotesTree] = useState<NotesTreeNode[]>([])
|
||||
const starredSet = useMemo(() => new Set(starredPaths), [starredPaths])
|
||||
const expandedSet = useMemo(() => new Set(expandedPaths), [expandedPaths])
|
||||
const { activeNode } = useActiveNode(notesTree)
|
||||
const { invalidateFileContent } = useFileContentSync()
|
||||
const { data: currentContent = '', isLoading: isContentLoading } = useFileContent(activeFilePath)
|
||||
const { data: currentContent = '' } = useFileContent(activeFilePath)
|
||||
|
||||
const [tokenCount, setTokenCount] = useState(0)
|
||||
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null)
|
||||
const watcherRef = useRef<(() => void) | null>(null)
|
||||
const isSyncingTreeRef = useRef(false)
|
||||
const lastContentRef = useRef<string>('')
|
||||
const lastFilePathRef = useRef<string | undefined>(undefined)
|
||||
const isInitialSortApplied = useRef(false)
|
||||
const isRenamingRef = useRef(false)
|
||||
const isCreatingNoteRef = useRef(false)
|
||||
|
||||
const activeFilePathRef = useRef<string | undefined>(activeFilePath)
|
||||
const currentContentRef = useRef(currentContent)
|
||||
|
||||
const updateStarredPaths = useCallback(
|
||||
(updater: (paths: string[]) => string[]) => {
|
||||
const current = store.getState().note.starredPaths
|
||||
const safeCurrent = Array.isArray(current) ? current : []
|
||||
const next = updater(safeCurrent) ?? []
|
||||
if (!Array.isArray(next)) {
|
||||
return
|
||||
}
|
||||
if (next !== safeCurrent) {
|
||||
dispatch(setStarredPaths(next))
|
||||
}
|
||||
},
|
||||
[dispatch, store]
|
||||
)
|
||||
|
||||
const updateExpandedPaths = useCallback(
|
||||
(updater: (paths: string[]) => string[]) => {
|
||||
const current = store.getState().note.expandedPaths
|
||||
const safeCurrent = Array.isArray(current) ? current : []
|
||||
const next = updater(safeCurrent) ?? []
|
||||
if (!Array.isArray(next)) {
|
||||
return
|
||||
}
|
||||
if (next !== safeCurrent) {
|
||||
dispatch(setExpandedPaths(next))
|
||||
}
|
||||
},
|
||||
[dispatch, store]
|
||||
)
|
||||
|
||||
const mergeTreeState = useCallback(
|
||||
(nodes: NotesTreeNode[]): NotesTreeNode[] => {
|
||||
return nodes.map((node) => {
|
||||
const normalizedPath = normalizePathValue(node.externalPath)
|
||||
const merged: NotesTreeNode = {
|
||||
...node,
|
||||
externalPath: normalizedPath,
|
||||
isStarred: starredSet.has(normalizedPath)
|
||||
}
|
||||
|
||||
if (node.type === 'folder') {
|
||||
merged.expanded = expandedSet.has(normalizedPath)
|
||||
merged.children = node.children ? mergeTreeState(node.children) : []
|
||||
}
|
||||
|
||||
return merged
|
||||
})
|
||||
},
|
||||
[starredSet, expandedSet]
|
||||
)
|
||||
|
||||
const refreshTree = useCallback(async () => {
|
||||
if (!notesPath) {
|
||||
setNotesTree([])
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const rawTree = await loadTree(notesPath)
|
||||
const sortedTree = sortTree(rawTree, sortType)
|
||||
setNotesTree(mergeTreeState(sortedTree))
|
||||
} catch (error) {
|
||||
logger.error('Failed to refresh notes tree:', error as Error)
|
||||
}
|
||||
}, [mergeTreeState, notesPath, sortType])
|
||||
|
||||
useEffect(() => {
|
||||
const updateCharCount = () => {
|
||||
const textContent = editorRef.current?.getContent() || currentContent
|
||||
@ -68,19 +156,16 @@ const NotesPage: FC = () => {
|
||||
updateCharCount()
|
||||
}, [currentContent])
|
||||
|
||||
// 查找树节点 by ID
|
||||
const findNodeById = useCallback((tree: NotesTreeNode[], nodeId: string): NotesTreeNode | null => {
|
||||
for (const node of tree) {
|
||||
if (node.id === nodeId) {
|
||||
return node
|
||||
}
|
||||
if (node.children) {
|
||||
const found = findNodeById(node.children, nodeId)
|
||||
if (found) return found
|
||||
}
|
||||
useEffect(() => {
|
||||
refreshTree()
|
||||
}, [refreshTree])
|
||||
|
||||
// Re-merge tree state when starred or expanded paths change
|
||||
useEffect(() => {
|
||||
if (notesTree.length > 0) {
|
||||
setNotesTree((prev) => mergeTreeState(prev))
|
||||
}
|
||||
return null
|
||||
}, [])
|
||||
}, [starredPaths, expandedPaths, mergeTreeState, notesTree.length])
|
||||
|
||||
// 保存当前笔记内容
|
||||
const saveCurrentNote = useCallback(
|
||||
@ -108,6 +193,11 @@ const NotesPage: FC = () => {
|
||||
[saveCurrentNote]
|
||||
)
|
||||
|
||||
const saveCurrentNoteRef = useRef(saveCurrentNote)
|
||||
const debouncedSaveRef = useRef(debouncedSave)
|
||||
const invalidateFileContentRef = useRef(invalidateFileContent)
|
||||
const refreshTreeRef = useRef(refreshTree)
|
||||
|
||||
const handleMarkdownChange = useCallback(
|
||||
(newMarkdown: string) => {
|
||||
// 记录最新内容和文件路径,用于兜底保存
|
||||
@ -119,6 +209,30 @@ const NotesPage: FC = () => {
|
||||
[debouncedSave, activeFilePath]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
activeFilePathRef.current = activeFilePath
|
||||
}, [activeFilePath])
|
||||
|
||||
useEffect(() => {
|
||||
currentContentRef.current = currentContent
|
||||
}, [currentContent])
|
||||
|
||||
useEffect(() => {
|
||||
saveCurrentNoteRef.current = saveCurrentNote
|
||||
}, [saveCurrentNote])
|
||||
|
||||
useEffect(() => {
|
||||
debouncedSaveRef.current = debouncedSave
|
||||
}, [debouncedSave])
|
||||
|
||||
useEffect(() => {
|
||||
invalidateFileContentRef.current = invalidateFileContent
|
||||
}, [invalidateFileContent])
|
||||
|
||||
useEffect(() => {
|
||||
refreshTreeRef.current = refreshTree
|
||||
}, [refreshTree])
|
||||
|
||||
useEffect(() => {
|
||||
async function initialize() {
|
||||
if (!notesPath) {
|
||||
@ -134,29 +248,12 @@ const NotesPage: FC = () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [notesPath])
|
||||
|
||||
// 应用初始排序
|
||||
useEffect(() => {
|
||||
async function applyInitialSort() {
|
||||
if (notesTree.length > 0 && !isInitialSortApplied.current) {
|
||||
try {
|
||||
await sortAllLevels(sortType)
|
||||
isInitialSortApplied.current = true
|
||||
} catch (error) {
|
||||
logger.error('Failed to apply initial sorting:', error as Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
applyInitialSort()
|
||||
}, [notesTree.length, sortType])
|
||||
|
||||
// 处理树同步时的状态管理
|
||||
useEffect(() => {
|
||||
if (notesTree.length === 0) return
|
||||
// 如果有activeFilePath但找不到对应节点,清空选择
|
||||
// 但要排除正在同步树结构、重命名或创建笔记的情况,避免在这些操作中误清空
|
||||
const shouldClearPath =
|
||||
activeFilePath && !activeNode && !isSyncingTreeRef.current && !isRenamingRef.current && !isCreatingNoteRef.current
|
||||
const shouldClearPath = activeFilePath && !activeNode && !isRenamingRef.current && !isCreatingNoteRef.current
|
||||
|
||||
if (shouldClearPath) {
|
||||
logger.warn('Clearing activeFilePath - node not found in tree', {
|
||||
@ -168,7 +265,7 @@ const NotesPage: FC = () => {
|
||||
}, [notesTree, activeFilePath, activeNode, dispatch])
|
||||
|
||||
useEffect(() => {
|
||||
if (!notesPath || notesTree.length === 0) return
|
||||
if (!notesPath) return
|
||||
|
||||
async function startFileWatcher() {
|
||||
// 清理之前的监控
|
||||
@ -182,31 +279,14 @@ const NotesPage: FC = () => {
|
||||
try {
|
||||
if (!notesPath) return
|
||||
const { eventType, filePath } = data
|
||||
const normalizedEventPath = normalizePathValue(filePath)
|
||||
|
||||
switch (eventType) {
|
||||
case 'change': {
|
||||
// 处理文件内容变化 - 只有内容真正改变时才触发更新
|
||||
if (activeFilePath === filePath) {
|
||||
try {
|
||||
// 读取文件最新内容
|
||||
// const newFileContent = await window.api.file.readExternal(filePath)
|
||||
// // 获取当前编辑器/缓存中的内容
|
||||
// const currentEditorContent = editorRef.current?.getMarkdown()
|
||||
// // 如果编辑器还未初始化完成,忽略FileWatcher事件
|
||||
// if (!isEditorInitialized.current) {
|
||||
// return
|
||||
// }
|
||||
// // 比较内容是否真正发生变化
|
||||
// if (newFileContent.trim() !== currentEditorContent?.trim()) {
|
||||
// invalidateFileContent(filePath)
|
||||
// }
|
||||
} catch (error) {
|
||||
logger.error('Failed to read file for content comparison:', error as Error)
|
||||
// 读取失败时,还是执行原来的逻辑
|
||||
invalidateFileContent(filePath)
|
||||
}
|
||||
} else {
|
||||
await initWorkSpace(notesPath, sortType)
|
||||
const activePath = activeFilePathRef.current
|
||||
if (activePath && normalizePathValue(activePath) === normalizedEventPath) {
|
||||
invalidateFileContentRef.current?.(normalizedEventPath)
|
||||
}
|
||||
break
|
||||
}
|
||||
@ -216,20 +296,18 @@ const NotesPage: FC = () => {
|
||||
case 'unlink':
|
||||
case 'unlinkDir': {
|
||||
// 如果删除的是当前活动文件,清空选择
|
||||
if ((eventType === 'unlink' || eventType === 'unlinkDir') && activeFilePath === filePath) {
|
||||
if (
|
||||
(eventType === 'unlink' || eventType === 'unlinkDir') &&
|
||||
activeFilePathRef.current &&
|
||||
normalizePathValue(activeFilePathRef.current) === normalizedEventPath
|
||||
) {
|
||||
dispatch(setActiveFilePath(undefined))
|
||||
editorRef.current?.clear()
|
||||
}
|
||||
|
||||
// 设置同步标志,避免竞态条件
|
||||
isSyncingTreeRef.current = true
|
||||
|
||||
// 重新同步数据库,useLiveQuery会自动响应数据库变化
|
||||
try {
|
||||
await initWorkSpace(notesPath, sortType)
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync database:', error as Error)
|
||||
} finally {
|
||||
isSyncingTreeRef.current = false
|
||||
const refresh = refreshTreeRef.current
|
||||
if (refresh) {
|
||||
await refresh()
|
||||
}
|
||||
break
|
||||
}
|
||||
@ -262,26 +340,19 @@ const NotesPage: FC = () => {
|
||||
})
|
||||
|
||||
// 如果有未保存的内容,立即保存
|
||||
if (lastContentRef.current && lastContentRef.current !== currentContent && lastFilePathRef.current) {
|
||||
saveCurrentNote(lastContentRef.current, lastFilePathRef.current).catch((error) => {
|
||||
logger.error('Emergency save failed:', error as Error)
|
||||
})
|
||||
if (lastContentRef.current && lastFilePathRef.current && lastContentRef.current !== currentContentRef.current) {
|
||||
const saveFn = saveCurrentNoteRef.current
|
||||
if (saveFn) {
|
||||
saveFn(lastContentRef.current, lastFilePathRef.current).catch((error) => {
|
||||
logger.error('Emergency save failed:', error as Error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 清理防抖函数
|
||||
debouncedSave.cancel()
|
||||
debouncedSaveRef.current?.cancel()
|
||||
}
|
||||
}, [
|
||||
notesPath,
|
||||
notesTree.length,
|
||||
activeFilePath,
|
||||
invalidateFileContent,
|
||||
dispatch,
|
||||
currentContent,
|
||||
debouncedSave,
|
||||
saveCurrentNote,
|
||||
sortType
|
||||
])
|
||||
}, [dispatch, notesPath])
|
||||
|
||||
useEffect(() => {
|
||||
const editor = editorRef.current
|
||||
@ -317,13 +388,13 @@ const NotesPage: FC = () => {
|
||||
// 获取目标文件夹路径(选中文件夹或根目录)
|
||||
const getTargetFolderPath = useCallback(() => {
|
||||
if (selectedFolderId) {
|
||||
const selectedNode = findNodeById(notesTree, selectedFolderId)
|
||||
const selectedNode = findNode(notesTree, selectedFolderId)
|
||||
if (selectedNode && selectedNode.type === 'folder') {
|
||||
return selectedNode.externalPath
|
||||
}
|
||||
}
|
||||
return notesPath // 默认返回根目录
|
||||
}, [selectedFolderId, notesTree, notesPath, findNodeById])
|
||||
}, [selectedFolderId, notesTree, notesPath])
|
||||
|
||||
// 创建文件夹
|
||||
const handleCreateFolder = useCallback(
|
||||
@ -333,12 +404,14 @@ const NotesPage: FC = () => {
|
||||
if (!targetPath) {
|
||||
throw new Error('No folder path selected')
|
||||
}
|
||||
await createFolder(name, targetPath)
|
||||
await addDir(name, targetPath)
|
||||
updateExpandedPaths((prev) => addUniquePath(prev, normalizePathValue(targetPath)))
|
||||
await refreshTree()
|
||||
} catch (error) {
|
||||
logger.error('Failed to create folder:', error as Error)
|
||||
}
|
||||
},
|
||||
[getTargetFolderPath]
|
||||
[getTargetFolderPath, refreshTree, updateExpandedPaths]
|
||||
)
|
||||
|
||||
// 创建笔记
|
||||
@ -351,11 +424,13 @@ const NotesPage: FC = () => {
|
||||
if (!targetPath) {
|
||||
throw new Error('No folder path selected')
|
||||
}
|
||||
const newNote = await createNote(name, '', targetPath)
|
||||
dispatch(setActiveFilePath(newNote.externalPath))
|
||||
const { path: notePath } = await addNote(name, '', targetPath)
|
||||
const normalizedParent = normalizePathValue(targetPath)
|
||||
updateExpandedPaths((prev) => addUniquePath(prev, normalizedParent))
|
||||
dispatch(setActiveFilePath(notePath))
|
||||
setSelectedFolderId(null)
|
||||
|
||||
await sortAllLevels(sortType)
|
||||
await refreshTree()
|
||||
} catch (error) {
|
||||
logger.error('Failed to create note:', error as Error)
|
||||
} finally {
|
||||
@ -365,73 +440,41 @@ const NotesPage: FC = () => {
|
||||
}, 500)
|
||||
}
|
||||
},
|
||||
[dispatch, getTargetFolderPath, sortType]
|
||||
)
|
||||
|
||||
// 切换展开状态
|
||||
const toggleNodeExpanded = useCallback(
|
||||
async (nodeId: string) => {
|
||||
try {
|
||||
const tree = await getNotesTree()
|
||||
const node = findNodeById(tree, nodeId)
|
||||
|
||||
if (node && node.type === 'folder') {
|
||||
await updateNodeInTree(tree, nodeId, {
|
||||
expanded: !node.expanded
|
||||
})
|
||||
}
|
||||
|
||||
return tree
|
||||
} catch (error) {
|
||||
logger.error('Failed to toggle expanded:', error as Error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
[findNodeById]
|
||||
[dispatch, getTargetFolderPath, refreshTree, updateExpandedPaths]
|
||||
)
|
||||
|
||||
const handleToggleExpanded = useCallback(
|
||||
async (nodeId: string) => {
|
||||
try {
|
||||
await toggleNodeExpanded(nodeId)
|
||||
} catch (error) {
|
||||
logger.error('Failed to toggle expanded:', error as Error)
|
||||
(nodeId: string) => {
|
||||
const targetNode = findNode(notesTree, nodeId)
|
||||
if (!targetNode || targetNode.type !== 'folder') {
|
||||
return
|
||||
}
|
||||
|
||||
const nextExpanded = !targetNode.expanded
|
||||
// Update Redux state first, then let mergeTreeState handle the UI update
|
||||
updateExpandedPaths((prev) =>
|
||||
nextExpanded
|
||||
? addUniquePath(prev, targetNode.externalPath)
|
||||
: removePathEntries(prev, targetNode.externalPath, false)
|
||||
)
|
||||
},
|
||||
[toggleNodeExpanded]
|
||||
)
|
||||
|
||||
// 切换收藏状态
|
||||
const toggleStarred = useCallback(
|
||||
async (nodeId: string) => {
|
||||
try {
|
||||
const tree = await getNotesTree()
|
||||
const node = findNodeById(tree, nodeId)
|
||||
|
||||
if (node && node.type === 'file') {
|
||||
await updateNodeInTree(tree, nodeId, {
|
||||
isStarred: !node.isStarred
|
||||
})
|
||||
}
|
||||
|
||||
return tree
|
||||
} catch (error) {
|
||||
logger.error('Failed to toggle star:', error as Error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
[findNodeById]
|
||||
[notesTree, updateExpandedPaths]
|
||||
)
|
||||
|
||||
const handleToggleStar = useCallback(
|
||||
async (nodeId: string) => {
|
||||
try {
|
||||
await toggleStarred(nodeId)
|
||||
} catch (error) {
|
||||
logger.error('Failed to toggle star:', error as Error)
|
||||
(nodeId: string) => {
|
||||
const node = findNode(notesTree, nodeId)
|
||||
if (!node) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextStarred = !node.isStarred
|
||||
// Update Redux state first, then let mergeTreeState handle the UI update
|
||||
updateStarredPaths((prev) =>
|
||||
nextStarred ? addUniquePath(prev, node.externalPath) : removePathEntries(prev, node.externalPath, false)
|
||||
)
|
||||
},
|
||||
[toggleStarred]
|
||||
[notesTree, updateStarredPaths]
|
||||
)
|
||||
|
||||
// 选择节点
|
||||
@ -448,7 +491,7 @@ const NotesPage: FC = () => {
|
||||
}
|
||||
} else if (node.type === 'folder') {
|
||||
setSelectedFolderId(node.id)
|
||||
await handleToggleExpanded(node.id)
|
||||
handleToggleExpanded(node.id)
|
||||
}
|
||||
},
|
||||
[dispatch, handleToggleExpanded, invalidateFileContent]
|
||||
@ -458,28 +501,35 @@ const NotesPage: FC = () => {
|
||||
const handleDeleteNode = useCallback(
|
||||
async (nodeId: string) => {
|
||||
try {
|
||||
const nodeToDelete = findNodeById(notesTree, nodeId)
|
||||
const nodeToDelete = findNode(notesTree, nodeId)
|
||||
if (!nodeToDelete) return
|
||||
|
||||
const isActiveNodeOrParent =
|
||||
activeFilePath &&
|
||||
(nodeToDelete.externalPath === activeFilePath || isParentNode(notesTree, nodeId, activeNode?.id || ''))
|
||||
await delNode(nodeToDelete)
|
||||
|
||||
await deleteNode(nodeId)
|
||||
await sortAllLevels(sortType)
|
||||
updateStarredPaths((prev) => removePathEntries(prev, nodeToDelete.externalPath, nodeToDelete.type === 'folder'))
|
||||
updateExpandedPaths((prev) =>
|
||||
removePathEntries(prev, nodeToDelete.externalPath, nodeToDelete.type === 'folder')
|
||||
)
|
||||
|
||||
// 如果删除的是当前活动节点或其父节点,清空编辑器
|
||||
if (isActiveNodeOrParent) {
|
||||
const normalizedActivePath = activeFilePath ? normalizePathValue(activeFilePath) : undefined
|
||||
const normalizedDeletePath = normalizePathValue(nodeToDelete.externalPath)
|
||||
const isActiveNode = normalizedActivePath === normalizedDeletePath
|
||||
const isActiveDescendant =
|
||||
nodeToDelete.type === 'folder' &&
|
||||
normalizedActivePath &&
|
||||
normalizedActivePath.startsWith(`${normalizedDeletePath}/`)
|
||||
|
||||
if (isActiveNode || isActiveDescendant) {
|
||||
dispatch(setActiveFilePath(undefined))
|
||||
if (editorRef.current) {
|
||||
editorRef.current.clear()
|
||||
}
|
||||
editorRef.current?.clear()
|
||||
}
|
||||
|
||||
await refreshTree()
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete node:', error as Error)
|
||||
}
|
||||
},
|
||||
[findNodeById, notesTree, activeFilePath, activeNode?.id, sortType, dispatch]
|
||||
[notesTree, activeFilePath, dispatch, refreshTree, updateStarredPaths, updateExpandedPaths]
|
||||
)
|
||||
|
||||
// 重命名节点
|
||||
@ -488,29 +538,30 @@ const NotesPage: FC = () => {
|
||||
try {
|
||||
isRenamingRef.current = true
|
||||
|
||||
const tree = await getNotesTree()
|
||||
const node = findNodeById(tree, nodeId)
|
||||
|
||||
if (node && node.name !== newName) {
|
||||
const oldExternalPath = node.externalPath
|
||||
const renamedNode = await renameNode(nodeId, newName)
|
||||
|
||||
if (renamedNode.type === 'file' && activeFilePath === oldExternalPath) {
|
||||
dispatch(setActiveFilePath(renamedNode.externalPath))
|
||||
} else if (
|
||||
renamedNode.type === 'folder' &&
|
||||
activeFilePath &&
|
||||
activeFilePath.startsWith(oldExternalPath + '/')
|
||||
) {
|
||||
const relativePath = activeFilePath.substring(oldExternalPath.length)
|
||||
const newFilePath = renamedNode.externalPath + relativePath
|
||||
dispatch(setActiveFilePath(newFilePath))
|
||||
}
|
||||
await sortAllLevels(sortType)
|
||||
if (renamedNode.name !== newName) {
|
||||
window.toast.info(t('notes.rename_changed', { original: newName, final: renamedNode.name }))
|
||||
}
|
||||
const node = findNode(notesTree, nodeId)
|
||||
if (!node || node.name === newName) {
|
||||
return
|
||||
}
|
||||
|
||||
const oldPath = node.externalPath
|
||||
const renamed = await renameEntry(node, newName)
|
||||
|
||||
if (node.type === 'file' && activeFilePath === oldPath) {
|
||||
debouncedSaveRef.current?.cancel()
|
||||
lastFilePathRef.current = renamed.path
|
||||
dispatch(setActiveFilePath(renamed.path))
|
||||
} else if (node.type === 'folder' && activeFilePath && activeFilePath.startsWith(`${oldPath}/`)) {
|
||||
const suffix = activeFilePath.slice(oldPath.length)
|
||||
const nextActivePath = `${renamed.path}${suffix}`
|
||||
debouncedSaveRef.current?.cancel()
|
||||
lastFilePathRef.current = nextActivePath
|
||||
dispatch(setActiveFilePath(nextActivePath))
|
||||
}
|
||||
|
||||
updateStarredPaths((prev) => replacePathEntries(prev, oldPath, renamed.path, node.type === 'folder'))
|
||||
updateExpandedPaths((prev) => replacePathEntries(prev, oldPath, renamed.path, node.type === 'folder'))
|
||||
|
||||
await refreshTree()
|
||||
} catch (error) {
|
||||
logger.error('Failed to rename node:', error as Error)
|
||||
} finally {
|
||||
@ -519,7 +570,7 @@ const NotesPage: FC = () => {
|
||||
}, 500)
|
||||
}
|
||||
},
|
||||
[activeFilePath, dispatch, findNodeById, sortType, t]
|
||||
[activeFilePath, dispatch, notesTree, refreshTree, updateStarredPaths, updateExpandedPaths]
|
||||
)
|
||||
|
||||
// 处理文件上传
|
||||
@ -536,7 +587,7 @@ const NotesPage: FC = () => {
|
||||
throw new Error('No folder path selected')
|
||||
}
|
||||
|
||||
const result = await uploadFiles(files, targetFolderPath)
|
||||
const result = await uploadNotes(files, targetFolderPath)
|
||||
|
||||
// 检查上传结果
|
||||
if (result.fileCount === 0) {
|
||||
@ -545,7 +596,8 @@ const NotesPage: FC = () => {
|
||||
}
|
||||
|
||||
// 排序并显示成功信息
|
||||
await sortAllLevels(sortType)
|
||||
updateExpandedPaths((prev) => addUniquePath(prev, normalizePathValue(targetFolderPath)))
|
||||
await refreshTree()
|
||||
|
||||
const successMessage = t('notes.upload_success')
|
||||
|
||||
@ -555,37 +607,141 @@ const NotesPage: FC = () => {
|
||||
window.toast.error(t('notes.upload_failed'))
|
||||
}
|
||||
},
|
||||
[getTargetFolderPath, sortType, t]
|
||||
[getTargetFolderPath, refreshTree, t, updateExpandedPaths]
|
||||
)
|
||||
|
||||
// 处理节点移动
|
||||
const handleMoveNode = useCallback(
|
||||
async (sourceNodeId: string, targetNodeId: string, position: 'before' | 'after' | 'inside') => {
|
||||
if (!notesPath) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await moveNode(sourceNodeId, targetNodeId, position)
|
||||
if (result.success && result.type !== 'manual_reorder') {
|
||||
await sortAllLevels(sortType)
|
||||
const sourceNode = findNode(notesTree, sourceNodeId)
|
||||
const targetNode = findNode(notesTree, targetNodeId)
|
||||
|
||||
if (!sourceNode || !targetNode) {
|
||||
return
|
||||
}
|
||||
|
||||
if (position === 'inside' && targetNode.type !== 'folder') {
|
||||
return
|
||||
}
|
||||
|
||||
const rootPath = normalizePathValue(notesPath)
|
||||
const sourceParentNode = findParent(notesTree, sourceNodeId)
|
||||
const targetParentNode = position === 'inside' ? targetNode : findParent(notesTree, targetNodeId)
|
||||
|
||||
const sourceParentPath = sourceParentNode ? sourceParentNode.externalPath : rootPath
|
||||
const targetParentPath =
|
||||
position === 'inside' ? targetNode.externalPath : targetParentNode ? targetParentNode.externalPath : rootPath
|
||||
|
||||
const normalizedSourceParent = normalizePathValue(sourceParentPath)
|
||||
const normalizedTargetParent = normalizePathValue(targetParentPath)
|
||||
|
||||
const isManualReorder = position !== 'inside' && normalizedSourceParent === normalizedTargetParent
|
||||
|
||||
if (isManualReorder) {
|
||||
// For manual reordering within the same parent, we can optimize by only updating the affected parent
|
||||
setNotesTree((prev) =>
|
||||
reorderTreeNodes(prev, sourceNodeId, targetNodeId, position === 'before' ? 'before' : 'after')
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const { safeName } = await window.api.file.checkFileName(
|
||||
normalizedTargetParent,
|
||||
sourceNode.name,
|
||||
sourceNode.type === 'file'
|
||||
)
|
||||
|
||||
const destinationPath =
|
||||
sourceNode.type === 'file'
|
||||
? `${normalizedTargetParent}/${safeName}.md`
|
||||
: `${normalizedTargetParent}/${safeName}`
|
||||
|
||||
if (destinationPath === sourceNode.externalPath) {
|
||||
return
|
||||
}
|
||||
|
||||
if (sourceNode.type === 'file') {
|
||||
await window.api.file.move(sourceNode.externalPath, destinationPath)
|
||||
} else {
|
||||
await window.api.file.moveDir(sourceNode.externalPath, destinationPath)
|
||||
}
|
||||
|
||||
updateStarredPaths((prev) =>
|
||||
replacePathEntries(prev, sourceNode.externalPath, destinationPath, sourceNode.type === 'folder')
|
||||
)
|
||||
updateExpandedPaths((prev) => {
|
||||
let next = replacePathEntries(prev, sourceNode.externalPath, destinationPath, sourceNode.type === 'folder')
|
||||
next = addUniquePath(next, normalizedTargetParent)
|
||||
return next
|
||||
})
|
||||
|
||||
const normalizedActivePath = activeFilePath ? normalizePathValue(activeFilePath) : undefined
|
||||
if (normalizedActivePath) {
|
||||
if (normalizedActivePath === sourceNode.externalPath) {
|
||||
dispatch(setActiveFilePath(destinationPath))
|
||||
} else if (sourceNode.type === 'folder' && normalizedActivePath.startsWith(`${sourceNode.externalPath}/`)) {
|
||||
const suffix = normalizedActivePath.slice(sourceNode.externalPath.length)
|
||||
dispatch(setActiveFilePath(`${destinationPath}${suffix}`))
|
||||
}
|
||||
}
|
||||
|
||||
await refreshTree()
|
||||
} catch (error) {
|
||||
logger.error('Failed to move nodes:', error as Error)
|
||||
}
|
||||
},
|
||||
[sortType]
|
||||
[activeFilePath, dispatch, notesPath, notesTree, refreshTree, updateStarredPaths, updateExpandedPaths]
|
||||
)
|
||||
|
||||
// 处理节点排序
|
||||
const handleSortNodes = useCallback(
|
||||
async (newSortType: NotesSortType) => {
|
||||
try {
|
||||
// 更新Redux中的排序类型
|
||||
dispatch(setSortType(newSortType))
|
||||
await sortAllLevels(newSortType)
|
||||
} catch (error) {
|
||||
logger.error('Failed to sort notes:', error as Error)
|
||||
throw error
|
||||
dispatch(setSortType(newSortType))
|
||||
setNotesTree((prev) => mergeTreeState(sortTree(prev, newSortType)))
|
||||
},
|
||||
[dispatch, mergeTreeState]
|
||||
)
|
||||
|
||||
const handleExpandPath = useCallback(
|
||||
(treePath: string) => {
|
||||
if (!treePath) {
|
||||
return
|
||||
}
|
||||
|
||||
const segments = treePath.split('/').filter(Boolean)
|
||||
if (segments.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
let nextTree = notesTree
|
||||
const pathsToAdd: string[] = []
|
||||
|
||||
segments.forEach((_, index) => {
|
||||
const currentPath = '/' + segments.slice(0, index + 1).join('/')
|
||||
const node = findNodeByPath(nextTree, currentPath)
|
||||
if (node && node.type === 'folder' && !node.expanded) {
|
||||
pathsToAdd.push(node.externalPath)
|
||||
nextTree = updateTreeNode(nextTree, node.id, (current) => ({ ...current, expanded: true }))
|
||||
}
|
||||
})
|
||||
|
||||
if (pathsToAdd.length > 0) {
|
||||
setNotesTree(nextTree)
|
||||
updateExpandedPaths((prev) => {
|
||||
let updated = prev
|
||||
pathsToAdd.forEach((path) => {
|
||||
updated = addUniquePath(updated, path)
|
||||
})
|
||||
return updated
|
||||
})
|
||||
}
|
||||
},
|
||||
[dispatch]
|
||||
[notesTree, updateExpandedPaths]
|
||||
)
|
||||
|
||||
const getCurrentNoteContent = useCallback(() => {
|
||||
@ -632,12 +788,13 @@ const NotesPage: FC = () => {
|
||||
notesTree={notesTree}
|
||||
getCurrentNoteContent={getCurrentNoteContent}
|
||||
onToggleStar={handleToggleStar}
|
||||
onExpandPath={handleExpandPath}
|
||||
onRenameNode={handleRenameNode}
|
||||
/>
|
||||
<NotesEditor
|
||||
activeNodeId={activeNode?.id}
|
||||
currentContent={currentContent}
|
||||
tokenCount={tokenCount}
|
||||
isLoading={isContentLoading}
|
||||
onMarkdownChange={handleMarkdownChange}
|
||||
editorRef={editorRef}
|
||||
/>
|
||||
|
||||
@ -9,6 +9,7 @@ import NotesSidebarHeader from '@renderer/pages/notes/NotesSidebarHeader'
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import { selectSortType } from '@renderer/store/note'
|
||||
import type { NotesSortType, NotesTreeNode } from '@renderer/types/note'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import type { InputRef, MenuProps } from 'antd'
|
||||
import { Dropdown, Input } from 'antd'
|
||||
import {
|
||||
@ -24,7 +25,7 @@ import {
|
||||
StarOff
|
||||
} from 'lucide-react'
|
||||
import type { FC, Ref } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@ -45,6 +46,157 @@ interface NotesSidebarProps {
|
||||
|
||||
const logger = loggerService.withContext('NotesSidebar')
|
||||
|
||||
interface TreeNodeProps {
|
||||
node: NotesTreeNode
|
||||
depth: number
|
||||
selectedFolderId?: string | null
|
||||
activeNodeId?: string
|
||||
editingNodeId: string | null
|
||||
draggedNodeId: string | null
|
||||
dragOverNodeId: string | null
|
||||
dragPosition: 'before' | 'inside' | 'after'
|
||||
inPlaceEdit: any
|
||||
getMenuItems: (node: NotesTreeNode) => any[]
|
||||
onSelectNode: (node: NotesTreeNode) => void
|
||||
onToggleExpanded: (nodeId: string) => void
|
||||
onDragStart: (e: React.DragEvent, node: NotesTreeNode) => void
|
||||
onDragOver: (e: React.DragEvent, node: NotesTreeNode) => void
|
||||
onDragLeave: () => void
|
||||
onDrop: (e: React.DragEvent, node: NotesTreeNode) => void
|
||||
onDragEnd: () => void
|
||||
renderChildren?: boolean // 控制是否渲染子节点
|
||||
}
|
||||
|
||||
const TreeNode = memo<TreeNodeProps>(
|
||||
({
|
||||
node,
|
||||
depth,
|
||||
selectedFolderId,
|
||||
activeNodeId,
|
||||
editingNodeId,
|
||||
draggedNodeId,
|
||||
dragOverNodeId,
|
||||
dragPosition,
|
||||
inPlaceEdit,
|
||||
getMenuItems,
|
||||
onSelectNode,
|
||||
onToggleExpanded,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
onDragEnd,
|
||||
renderChildren = true
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const isActive = selectedFolderId
|
||||
? node.type === 'folder' && node.id === selectedFolderId
|
||||
: node.id === activeNodeId
|
||||
const isEditing = editingNodeId === node.id && inPlaceEdit.isEditing
|
||||
const hasChildren = node.children && node.children.length > 0
|
||||
const isDragging = draggedNodeId === node.id
|
||||
const isDragOver = dragOverNodeId === node.id
|
||||
const isDragBefore = isDragOver && dragPosition === 'before'
|
||||
const isDragInside = isDragOver && dragPosition === 'inside'
|
||||
const isDragAfter = isDragOver && dragPosition === 'after'
|
||||
|
||||
return (
|
||||
<div key={node.id}>
|
||||
<Dropdown menu={{ items: getMenuItems(node) }} trigger={['contextMenu']}>
|
||||
<div>
|
||||
<TreeNodeContainer
|
||||
active={isActive}
|
||||
depth={depth}
|
||||
isDragging={isDragging}
|
||||
isDragOver={isDragOver}
|
||||
isDragBefore={isDragBefore}
|
||||
isDragInside={isDragInside}
|
||||
isDragAfter={isDragAfter}
|
||||
draggable={!isEditing}
|
||||
data-node-id={node.id}
|
||||
onDragStart={(e) => onDragStart(e, node)}
|
||||
onDragOver={(e) => onDragOver(e, node)}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={(e) => onDrop(e, node)}
|
||||
onDragEnd={onDragEnd}>
|
||||
<TreeNodeContent onClick={() => onSelectNode(node)}>
|
||||
<NodeIndent depth={depth} />
|
||||
|
||||
{node.type === 'folder' && (
|
||||
<ExpandIcon
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggleExpanded(node.id)
|
||||
}}
|
||||
title={node.expanded ? t('notes.collapse') : t('notes.expand')}>
|
||||
{node.expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</ExpandIcon>
|
||||
)}
|
||||
|
||||
<NodeIcon>
|
||||
{node.type === 'folder' ? (
|
||||
node.expanded ? (
|
||||
<FolderOpen size={16} />
|
||||
) : (
|
||||
<Folder size={16} />
|
||||
)
|
||||
) : (
|
||||
<File size={16} />
|
||||
)}
|
||||
</NodeIcon>
|
||||
|
||||
{isEditing ? (
|
||||
<EditInput
|
||||
ref={inPlaceEdit.inputRef as Ref<InputRef>}
|
||||
value={inPlaceEdit.editValue}
|
||||
onChange={inPlaceEdit.handleInputChange}
|
||||
onBlur={inPlaceEdit.saveEdit}
|
||||
onKeyDown={inPlaceEdit.handleKeyDown}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
autoFocus
|
||||
size="small"
|
||||
/>
|
||||
) : (
|
||||
<NodeName>{node.name}</NodeName>
|
||||
)}
|
||||
</TreeNodeContent>
|
||||
</TreeNodeContainer>
|
||||
</div>
|
||||
</Dropdown>
|
||||
|
||||
{renderChildren && node.type === 'folder' && node.expanded && hasChildren && (
|
||||
<div>
|
||||
{node.children!.map((child) => (
|
||||
<TreeNode
|
||||
key={child.id}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
selectedFolderId={selectedFolderId}
|
||||
activeNodeId={activeNodeId}
|
||||
editingNodeId={editingNodeId}
|
||||
draggedNodeId={draggedNodeId}
|
||||
dragOverNodeId={dragOverNodeId}
|
||||
dragPosition={dragPosition}
|
||||
inPlaceEdit={inPlaceEdit}
|
||||
getMenuItems={getMenuItems}
|
||||
onSelectNode={onSelectNode}
|
||||
onToggleExpanded={onToggleExpanded}
|
||||
onDragStart={onDragStart}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
onDragEnd={onDragEnd}
|
||||
renderChildren={renderChildren}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
onCreateFolder,
|
||||
onCreateNote,
|
||||
@ -270,9 +422,26 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
setIsShowSearch(!isShowSearch)
|
||||
}, [isShowSearch])
|
||||
|
||||
const filteredTree = useMemo(() => {
|
||||
if (!isShowStarred && !isShowSearch) return notesTree
|
||||
const flattenNodes = (nodes: NotesTreeNode[]): NotesTreeNode[] => {
|
||||
// Flatten tree nodes for virtualization and filtering
|
||||
const flattenedNodes = useMemo(() => {
|
||||
const flattenForVirtualization = (
|
||||
nodes: NotesTreeNode[],
|
||||
depth: number = 0
|
||||
): Array<{ node: NotesTreeNode; depth: number }> => {
|
||||
let result: Array<{ node: NotesTreeNode; depth: number }> = []
|
||||
|
||||
for (const node of nodes) {
|
||||
result.push({ node, depth })
|
||||
|
||||
// Include children only if the folder is expanded
|
||||
if (node.type === 'folder' && node.expanded && node.children && node.children.length > 0) {
|
||||
result = [...result, ...flattenForVirtualization(node.children, depth + 1)]
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const flattenForFiltering = (nodes: NotesTreeNode[]): NotesTreeNode[] => {
|
||||
let result: NotesTreeNode[] = []
|
||||
|
||||
for (const node of nodes) {
|
||||
@ -286,15 +455,41 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
}
|
||||
}
|
||||
if (node.children && node.children.length > 0) {
|
||||
result = [...result, ...flattenNodes(node.children)]
|
||||
result = [...result, ...flattenForFiltering(node.children)]
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
return flattenNodes(notesTree)
|
||||
if (isShowStarred || isShowSearch) {
|
||||
// For filtered views, return flat list without virtualization for simplicity
|
||||
const filteredNodes = flattenForFiltering(notesTree)
|
||||
return filteredNodes.map((node) => ({ node, depth: 0 }))
|
||||
}
|
||||
|
||||
// For normal tree view, use hierarchical flattening for virtualization
|
||||
return flattenForVirtualization(notesTree)
|
||||
}, [notesTree, isShowStarred, isShowSearch, searchKeyword])
|
||||
|
||||
// Use virtualization only for normal tree view with many items
|
||||
const shouldUseVirtualization = !isShowStarred && !isShowSearch && flattenedNodes.length > 100
|
||||
|
||||
const parentRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: flattenedNodes.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 28, // Estimated height of each tree item
|
||||
overscan: 10
|
||||
})
|
||||
|
||||
const filteredTree = useMemo(() => {
|
||||
if (isShowStarred || isShowSearch) {
|
||||
return flattenedNodes.map(({ node }) => node)
|
||||
}
|
||||
return notesTree
|
||||
}, [flattenedNodes, isShowStarred, isShowSearch, notesTree])
|
||||
|
||||
const getMenuItems = useCallback(
|
||||
(node: NotesTreeNode) => {
|
||||
const baseMenuItems: MenuProps['items'] = [
|
||||
@ -353,115 +548,6 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
[t, handleStartEdit, onToggleStar, handleExportKnowledge, handleDeleteNode]
|
||||
)
|
||||
|
||||
const renderTreeNode = useCallback(
|
||||
(node: NotesTreeNode, depth: number = 0) => {
|
||||
const isActive = selectedFolderId
|
||||
? node.type === 'folder' && node.id === selectedFolderId
|
||||
: node.id === activeNode?.id
|
||||
const isEditing = editingNodeId === node.id && inPlaceEdit.isEditing
|
||||
const hasChildren = node.children && node.children.length > 0
|
||||
const isDragging = draggedNodeId === node.id
|
||||
const isDragOver = dragOverNodeId === node.id
|
||||
const isDragBefore = isDragOver && dragPosition === 'before'
|
||||
const isDragInside = isDragOver && dragPosition === 'inside'
|
||||
const isDragAfter = isDragOver && dragPosition === 'after'
|
||||
|
||||
return (
|
||||
<div key={node.id}>
|
||||
<Dropdown menu={{ items: getMenuItems(node) }} trigger={['contextMenu']}>
|
||||
<div>
|
||||
<TreeNodeContainer
|
||||
active={isActive}
|
||||
depth={depth}
|
||||
isDragging={isDragging}
|
||||
isDragOver={isDragOver}
|
||||
isDragBefore={isDragBefore}
|
||||
isDragInside={isDragInside}
|
||||
isDragAfter={isDragAfter}
|
||||
draggable={!isEditing}
|
||||
data-node-id={node.id}
|
||||
onDragStart={(e) => handleDragStart(e, node)}
|
||||
onDragOver={(e) => handleDragOver(e, node)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, node)}
|
||||
onDragEnd={handleDragEnd}>
|
||||
<TreeNodeContent onClick={() => onSelectNode(node)}>
|
||||
<NodeIndent depth={depth} />
|
||||
|
||||
{node.type === 'folder' && (
|
||||
<ExpandIcon
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggleExpanded(node.id)
|
||||
}}
|
||||
title={node.expanded ? t('notes.collapse') : t('notes.expand')}>
|
||||
{node.expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</ExpandIcon>
|
||||
)}
|
||||
|
||||
<NodeIcon>
|
||||
{node.type === 'folder' ? (
|
||||
node.expanded ? (
|
||||
<FolderOpen size={16} />
|
||||
) : (
|
||||
<Folder size={16} />
|
||||
)
|
||||
) : (
|
||||
<File size={16} />
|
||||
)}
|
||||
</NodeIcon>
|
||||
|
||||
{isEditing ? (
|
||||
<EditInput
|
||||
ref={inPlaceEdit.inputRef as Ref<InputRef>}
|
||||
value={inPlaceEdit.editValue}
|
||||
onChange={inPlaceEdit.handleInputChange}
|
||||
onPressEnter={inPlaceEdit.saveEdit}
|
||||
onBlur={inPlaceEdit.saveEdit}
|
||||
onKeyDown={inPlaceEdit.handleKeyDown}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
autoFocus
|
||||
size="small"
|
||||
/>
|
||||
) : (
|
||||
<NodeName>{node.name}</NodeName>
|
||||
)}
|
||||
</TreeNodeContent>
|
||||
</TreeNodeContainer>
|
||||
</div>
|
||||
</Dropdown>
|
||||
|
||||
{node.type === 'folder' && node.expanded && hasChildren && (
|
||||
<div>{node.children!.map((child) => renderTreeNode(child, depth + 1))}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
[
|
||||
selectedFolderId,
|
||||
activeNode?.id,
|
||||
editingNodeId,
|
||||
inPlaceEdit.isEditing,
|
||||
inPlaceEdit.inputRef,
|
||||
inPlaceEdit.editValue,
|
||||
inPlaceEdit.handleInputChange,
|
||||
inPlaceEdit.saveEdit,
|
||||
inPlaceEdit.handleKeyDown,
|
||||
draggedNodeId,
|
||||
dragOverNodeId,
|
||||
dragPosition,
|
||||
getMenuItems,
|
||||
handleDragLeave,
|
||||
handleDragEnd,
|
||||
t,
|
||||
handleDragStart,
|
||||
handleDragOver,
|
||||
handleDrop,
|
||||
onSelectNode,
|
||||
onToggleExpanded
|
||||
]
|
||||
)
|
||||
|
||||
const handleDropFiles = useCallback(
|
||||
async (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
@ -567,9 +653,54 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
/>
|
||||
|
||||
<NotesTreeContainer>
|
||||
<StyledScrollbar ref={scrollbarRef}>
|
||||
<TreeContent>
|
||||
{filteredTree.map((node) => renderTreeNode(node))}
|
||||
{shouldUseVirtualization ? (
|
||||
<VirtualizedTreeContainer ref={parentRef}>
|
||||
<div
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
width: '100%',
|
||||
position: 'relative'
|
||||
}}>
|
||||
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||
const { node, depth } = flattenedNodes[virtualItem.index]
|
||||
return (
|
||||
<div
|
||||
key={virtualItem.key}
|
||||
data-index={virtualItem.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${virtualItem.start}px)`
|
||||
}}>
|
||||
<div style={{ padding: '0 8px' }}>
|
||||
<TreeNode
|
||||
node={node}
|
||||
depth={depth}
|
||||
selectedFolderId={selectedFolderId}
|
||||
activeNodeId={activeNode?.id}
|
||||
editingNodeId={editingNodeId}
|
||||
draggedNodeId={draggedNodeId}
|
||||
dragOverNodeId={dragOverNodeId}
|
||||
dragPosition={dragPosition}
|
||||
inPlaceEdit={inPlaceEdit}
|
||||
getMenuItems={getMenuItems}
|
||||
onSelectNode={onSelectNode}
|
||||
onToggleExpanded={onToggleExpanded}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onDragEnd={handleDragEnd}
|
||||
renderChildren={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{!isShowStarred && !isShowSearch && (
|
||||
<DropHintNode>
|
||||
<TreeNodeContainer active={false} depth={0}>
|
||||
@ -582,8 +713,70 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
</TreeNodeContainer>
|
||||
</DropHintNode>
|
||||
)}
|
||||
</TreeContent>
|
||||
</StyledScrollbar>
|
||||
</VirtualizedTreeContainer>
|
||||
) : (
|
||||
<StyledScrollbar ref={scrollbarRef}>
|
||||
<TreeContent>
|
||||
{isShowStarred || isShowSearch
|
||||
? filteredTree.map((node) => (
|
||||
<TreeNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
depth={0}
|
||||
selectedFolderId={selectedFolderId}
|
||||
activeNodeId={activeNode?.id}
|
||||
editingNodeId={editingNodeId}
|
||||
draggedNodeId={draggedNodeId}
|
||||
dragOverNodeId={dragOverNodeId}
|
||||
dragPosition={dragPosition}
|
||||
inPlaceEdit={inPlaceEdit}
|
||||
getMenuItems={getMenuItems}
|
||||
onSelectNode={onSelectNode}
|
||||
onToggleExpanded={onToggleExpanded}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onDragEnd={handleDragEnd}
|
||||
/>
|
||||
))
|
||||
: notesTree.map((node) => (
|
||||
<TreeNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
depth={0}
|
||||
selectedFolderId={selectedFolderId}
|
||||
activeNodeId={activeNode?.id}
|
||||
editingNodeId={editingNodeId}
|
||||
draggedNodeId={draggedNodeId}
|
||||
dragOverNodeId={dragOverNodeId}
|
||||
dragPosition={dragPosition}
|
||||
inPlaceEdit={inPlaceEdit}
|
||||
getMenuItems={getMenuItems}
|
||||
onSelectNode={onSelectNode}
|
||||
onToggleExpanded={onToggleExpanded}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onDragEnd={handleDragEnd}
|
||||
/>
|
||||
))}
|
||||
{!isShowStarred && !isShowSearch && (
|
||||
<DropHintNode>
|
||||
<TreeNodeContainer active={false} depth={0}>
|
||||
<TreeNodeContent>
|
||||
<NodeIcon>
|
||||
<FilePlus size={16} />
|
||||
</NodeIcon>
|
||||
<DropHintText onClick={handleClickToSelectFiles}>{t('notes.drop_markdown_hint')}</DropHintText>
|
||||
</TreeNodeContent>
|
||||
</TreeNodeContainer>
|
||||
</DropHintNode>
|
||||
)}
|
||||
</TreeContent>
|
||||
</StyledScrollbar>
|
||||
)}
|
||||
</NotesTreeContainer>
|
||||
|
||||
{isDragOverSidebar && <DragOverIndicator />}
|
||||
@ -594,7 +787,7 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
const SidebarContainer = styled.div`
|
||||
width: 250px;
|
||||
min-width: 250px;
|
||||
height: 100vh;
|
||||
height: calc(100vh - var(--navbar-height));
|
||||
background-color: var(--color-background);
|
||||
border-right: 0.5px solid var(--color-border);
|
||||
border-top-left-radius: 10px;
|
||||
@ -608,7 +801,15 @@ const NotesTreeContainer = styled.div`
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 45px);
|
||||
height: calc(100vh - var(--navbar-height) - 45px);
|
||||
`
|
||||
|
||||
const VirtualizedTreeContainer = styled.div`
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
padding-top: 10px;
|
||||
`
|
||||
|
||||
const StyledScrollbar = styled(Scrollbar)`
|
||||
@ -754,7 +955,8 @@ const DragOverIndicator = styled.div`
|
||||
`
|
||||
|
||||
const DropHintNode = styled.div`
|
||||
margin-top: 8px;
|
||||
margin: 8px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
${TreeNodeContainer} {
|
||||
background-color: transparent;
|
||||
@ -775,4 +977,4 @@ const DropHintText = styled.div`
|
||||
font-style: italic;
|
||||
`
|
||||
|
||||
export default NotesSidebar
|
||||
export default memo(NotesSidebar)
|
||||
|
||||
@ -277,7 +277,7 @@ const AboutSettings: FC = () => {
|
||||
<IndicatorLight color="green" />
|
||||
</SettingRowTitle>
|
||||
</SettingRow>
|
||||
<UpdateNotesWrapper>
|
||||
<UpdateNotesWrapper className="markdown">
|
||||
<Markdown>
|
||||
{typeof appUpdateState.info.releaseNotes === 'string'
|
||||
? appUpdateState.info.releaseNotes.replace(/\n/g, '\n\n')
|
||||
|
||||
@ -3,7 +3,6 @@ import { loggerService } from '@logger'
|
||||
import Selector from '@renderer/components/Selector'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
|
||||
import { initWorkSpace } from '@renderer/services/NotesService'
|
||||
import type { EditorView } from '@renderer/types'
|
||||
import { Button, Input, Slider } from 'antd'
|
||||
import { FolderOpen } from 'lucide-react'
|
||||
@ -72,7 +71,6 @@ const NotesSettings: FC = () => {
|
||||
}
|
||||
|
||||
updateNotesPath(tempPath)
|
||||
initWorkSpace(tempPath, 'sort_a2z')
|
||||
window.toast.success(t('notes.settings.data.path_updated'))
|
||||
} catch (error) {
|
||||
logger.error('Failed to apply notes path:', error as Error)
|
||||
@ -85,7 +83,6 @@ const NotesSettings: FC = () => {
|
||||
const info = await window.api.getAppInfo()
|
||||
setTempPath(info.notesPath)
|
||||
updateNotesPath(info.notesPath)
|
||||
initWorkSpace(info.notesPath, 'sort_a2z')
|
||||
window.toast.success(t('notes.settings.data.reset_to_default'))
|
||||
} catch (error) {
|
||||
logger.error('Failed to reset to default:', error as Error)
|
||||
|
||||
@ -7,9 +7,11 @@ import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useAssistants, useDefaultAssistant, useDefaultModel } from '@renderer/hooks/useAssistant'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { setQuickAssistantId } from '@renderer/store/llm'
|
||||
import { matchKeywordsInString } from '@renderer/utils'
|
||||
import HomeWindow from '@renderer/windows/mini/home/HomeWindow'
|
||||
import { Button, Select, Tooltip } from 'antd'
|
||||
import type { FC } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@ -30,9 +32,15 @@ const QuickAssistantSettings: FC = () => {
|
||||
const dispatch = useAppDispatch()
|
||||
const { assistants } = useAssistants()
|
||||
const { quickAssistantId } = useAppSelector((state) => state.llm)
|
||||
const { defaultAssistant } = useDefaultAssistant()
|
||||
const { defaultAssistant: _defaultAssistant } = useDefaultAssistant()
|
||||
const { defaultModel } = useDefaultModel()
|
||||
|
||||
// Take the "default assistant" from the assistant list first.
|
||||
const defaultAssistant = useMemo(
|
||||
() => assistants.find((a) => a.id === _defaultAssistant.id) || _defaultAssistant,
|
||||
[assistants, _defaultAssistant]
|
||||
)
|
||||
|
||||
const handleEnableQuickAssistant = async (enable: boolean) => {
|
||||
await setEnableQuickAssistant(enable)
|
||||
|
||||
@ -111,27 +119,39 @@ const QuickAssistantSettings: FC = () => {
|
||||
value={quickAssistantId || defaultAssistant.id}
|
||||
style={{ width: 300, height: 34 }}
|
||||
onChange={(value) => dispatch(setQuickAssistantId(value))}
|
||||
placeholder={t('settings.models.quick_assistant_selection')}>
|
||||
<Select.Option key={defaultAssistant.id} value={defaultAssistant.id}>
|
||||
<AssistantItem>
|
||||
<ModelAvatar model={defaultAssistant.model || defaultModel} size={18} />
|
||||
<AssistantName>{defaultAssistant.name}</AssistantName>
|
||||
<Spacer />
|
||||
<DefaultTag isCurrent={true}>{t('settings.models.quick_assistant_default_tag')}</DefaultTag>
|
||||
</AssistantItem>
|
||||
</Select.Option>
|
||||
{assistants
|
||||
.filter((a) => a.id !== defaultAssistant.id)
|
||||
.map((a) => (
|
||||
<Select.Option key={a.id} value={a.id}>
|
||||
placeholder={t('settings.models.quick_assistant_selection')}
|
||||
showSearch
|
||||
options={[
|
||||
{
|
||||
key: defaultAssistant.id,
|
||||
value: defaultAssistant.id,
|
||||
title: defaultAssistant.name,
|
||||
label: (
|
||||
<AssistantItem>
|
||||
<ModelAvatar model={a.model || defaultModel} size={18} />
|
||||
<AssistantName>{a.name}</AssistantName>
|
||||
<ModelAvatar model={defaultAssistant.model || defaultModel} size={18} />
|
||||
<AssistantName>{defaultAssistant.name}</AssistantName>
|
||||
<Spacer />
|
||||
<DefaultTag isCurrent={true}>{t('settings.models.quick_assistant_default_tag')}</DefaultTag>
|
||||
</AssistantItem>
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
)
|
||||
},
|
||||
...assistants
|
||||
.filter((a) => a.id !== defaultAssistant.id)
|
||||
.map((a) => ({
|
||||
key: a.id,
|
||||
value: a.id,
|
||||
title: a.name,
|
||||
label: (
|
||||
<AssistantItem>
|
||||
<ModelAvatar model={a.model || defaultModel} size={18} />
|
||||
<AssistantName>{a.name}</AssistantName>
|
||||
<Spacer />
|
||||
</AssistantItem>
|
||||
)
|
||||
}))
|
||||
]}
|
||||
filterOption={(input, option) => matchKeywordsInString(input, option?.title || '')}
|
||||
/>
|
||||
</RowFlex>
|
||||
)}
|
||||
<RowFlex className="items-center gap-0">
|
||||
|
||||
@ -339,12 +339,6 @@ const TranslatePage: FC = () => {
|
||||
setTargetLanguage(source)
|
||||
}, [couldExchangeAuto, detectedLanguage, sourceLanguage, t, targetLanguage])
|
||||
|
||||
// Clear translation content when component mounts
|
||||
useEffect(() => {
|
||||
setText('')
|
||||
setTranslatedContent('')
|
||||
}, [setText, setTranslatedContent])
|
||||
|
||||
useEffect(() => {
|
||||
isEmpty(text) && setTranslatedContent('')
|
||||
}, [setTranslatedContent, text])
|
||||
|
||||
@ -1,100 +1,10 @@
|
||||
import { loggerService } from '@logger'
|
||||
import db from '@renderer/databases'
|
||||
import {
|
||||
findNodeInTree,
|
||||
findParentNode,
|
||||
getNotesTree,
|
||||
insertNodeIntoTree,
|
||||
isParentNode,
|
||||
moveNodeInTree,
|
||||
removeNodeFromTree,
|
||||
renameNodeFromTree
|
||||
} from '@renderer/services/NotesTreeService'
|
||||
import type { NotesSortType, NotesTreeNode } from '@renderer/types/note'
|
||||
import { getFileDirectory } from '@renderer/utils'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
const MARKDOWN_EXT = '.md'
|
||||
const NOTES_TREE_ID = 'notes-tree-structure'
|
||||
|
||||
const logger = loggerService.withContext('NotesService')
|
||||
|
||||
export type MoveNodeResult = { success: false } | { success: true; type: 'file_system_move' | 'manual_reorder' }
|
||||
|
||||
/**
|
||||
* 初始化/同步笔记树结构
|
||||
*/
|
||||
export async function initWorkSpace(folderPath: string, sortType: NotesSortType): Promise<void> {
|
||||
const tree = await window.api.file.getDirectoryStructure(folderPath)
|
||||
await sortAllLevels(sortType, tree)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新文件夹
|
||||
*/
|
||||
export async function createFolder(name: string, folderPath: string): Promise<NotesTreeNode> {
|
||||
const { safeName, exists } = await window.api.file.checkFileName(folderPath, name, false)
|
||||
if (exists) {
|
||||
logger.warn(`Folder already exists: ${safeName}`)
|
||||
}
|
||||
|
||||
const tree = await getNotesTree()
|
||||
const folderId = uuidv4()
|
||||
|
||||
const targetPath = await window.api.file.mkdir(`${folderPath}/${safeName}`)
|
||||
|
||||
// 查找父节点ID
|
||||
const parentNode = tree.find((node) => node.externalPath === folderPath) || findNodeByExternalPath(tree, folderPath)
|
||||
|
||||
const folder: NotesTreeNode = {
|
||||
id: folderId,
|
||||
name: safeName,
|
||||
treePath: parentNode ? `${parentNode.treePath}/${safeName}` : `/${safeName}`,
|
||||
externalPath: targetPath,
|
||||
type: 'folder',
|
||||
children: [],
|
||||
expanded: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
insertNodeIntoTree(tree, folder, parentNode?.id)
|
||||
|
||||
return folder
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新笔记文件
|
||||
*/
|
||||
export async function createNote(name: string, content: string = '', folderPath: string): Promise<NotesTreeNode> {
|
||||
const { safeName, exists } = await window.api.file.checkFileName(folderPath, name, true)
|
||||
if (exists) {
|
||||
logger.warn(`Note already exists: ${safeName}`)
|
||||
}
|
||||
|
||||
const tree = await getNotesTree()
|
||||
const noteId = uuidv4()
|
||||
const notePath = `${folderPath}/${safeName}${MARKDOWN_EXT}`
|
||||
|
||||
await window.api.file.write(notePath, content)
|
||||
|
||||
// 查找父节点ID
|
||||
const parentNode = tree.find((node) => node.externalPath === folderPath) || findNodeByExternalPath(tree, folderPath)
|
||||
|
||||
const note: NotesTreeNode = {
|
||||
id: noteId,
|
||||
name: safeName,
|
||||
treePath: parentNode ? `${parentNode.treePath}/${safeName}` : `/${safeName}`,
|
||||
externalPath: notePath,
|
||||
type: 'file',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
insertNodeIntoTree(tree, note, parentNode?.id)
|
||||
|
||||
return note
|
||||
}
|
||||
const MARKDOWN_EXT = '.md'
|
||||
|
||||
export interface UploadResult {
|
||||
uploadedNodes: NotesTreeNode[]
|
||||
@ -104,641 +14,195 @@ export interface UploadResult {
|
||||
folderCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件或文件夹,支持单个或批量上传,保持文件夹结构
|
||||
*/
|
||||
export async function uploadFiles(files: File[], targetFolderPath: string): Promise<UploadResult> {
|
||||
const tree = await getNotesTree()
|
||||
const uploadedNodes: NotesTreeNode[] = []
|
||||
let skippedFiles = 0
|
||||
|
||||
const markdownFiles = filterMarkdownFiles(files)
|
||||
skippedFiles = files.length - markdownFiles.length
|
||||
|
||||
if (markdownFiles.length === 0) {
|
||||
return createEmptyUploadResult(files.length, skippedFiles)
|
||||
}
|
||||
|
||||
// 处理重复的根文件夹名称
|
||||
const processedFiles = await processDuplicateRootFolders(markdownFiles, targetFolderPath)
|
||||
|
||||
const { filesByPath, foldersToCreate } = groupFilesByPath(processedFiles, targetFolderPath)
|
||||
|
||||
const createdFolders = await createFoldersSequentially(foldersToCreate, targetFolderPath, tree, uploadedNodes)
|
||||
|
||||
await uploadAllFiles(filesByPath, targetFolderPath, tree, createdFolders, uploadedNodes)
|
||||
|
||||
const fileCount = uploadedNodes.filter((node) => node.type === 'file').length
|
||||
const folderCount = uploadedNodes.filter((node) => node.type === 'folder').length
|
||||
|
||||
return {
|
||||
uploadedNodes,
|
||||
totalFiles: files.length,
|
||||
skippedFiles,
|
||||
fileCount,
|
||||
folderCount
|
||||
}
|
||||
export async function loadTree(rootPath: string): Promise<NotesTreeNode[]> {
|
||||
return window.api.file.getDirectoryStructure(normalizePath(rootPath))
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除笔记或文件夹
|
||||
*/
|
||||
export async function deleteNode(nodeId: string): Promise<void> {
|
||||
const tree = await getNotesTree()
|
||||
const node = findNodeInTree(tree, nodeId)
|
||||
if (!node) {
|
||||
throw new Error('Node not found')
|
||||
}
|
||||
export function sortTree(nodes: NotesTreeNode[], sortType: NotesSortType): NotesTreeNode[] {
|
||||
const cloned = nodes.map((node) => ({
|
||||
...node,
|
||||
children: node.children ? sortTree(node.children, sortType) : undefined
|
||||
}))
|
||||
|
||||
const sorter = getSorter(sortType)
|
||||
|
||||
cloned.sort((a, b) => {
|
||||
if (a.type === b.type) {
|
||||
return sorter(a, b)
|
||||
}
|
||||
return a.type === 'folder' ? -1 : 1
|
||||
})
|
||||
|
||||
return cloned
|
||||
}
|
||||
|
||||
export async function addDir(name: string, parentPath: string): Promise<{ path: string; name: string }> {
|
||||
const basePath = normalizePath(parentPath)
|
||||
const { safeName } = await window.api.file.checkFileName(basePath, name, false)
|
||||
const fullPath = `${basePath}/${safeName}`
|
||||
await window.api.file.mkdir(fullPath)
|
||||
return { path: fullPath, name: safeName }
|
||||
}
|
||||
|
||||
export async function addNote(
|
||||
name: string,
|
||||
content: string = '',
|
||||
parentPath: string
|
||||
): Promise<{ path: string; name: string }> {
|
||||
const basePath = normalizePath(parentPath)
|
||||
const { safeName } = await window.api.file.checkFileName(basePath, name, true)
|
||||
const notePath = `${basePath}/${safeName}${MARKDOWN_EXT}`
|
||||
await window.api.file.write(notePath, content)
|
||||
return { path: notePath, name: safeName }
|
||||
}
|
||||
|
||||
export async function delNode(node: NotesTreeNode): Promise<void> {
|
||||
if (node.type === 'folder') {
|
||||
await window.api.file.deleteExternalDir(node.externalPath)
|
||||
} else if (node.type === 'file') {
|
||||
} else {
|
||||
await window.api.file.deleteExternalFile(node.externalPath)
|
||||
}
|
||||
|
||||
await removeNodeFromTree(tree, nodeId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 重命名笔记或文件夹
|
||||
*/
|
||||
export async function renameNode(nodeId: string, newName: string): Promise<NotesTreeNode> {
|
||||
const tree = await getNotesTree()
|
||||
const node = findNodeInTree(tree, nodeId)
|
||||
if (!node) {
|
||||
throw new Error('Node not found')
|
||||
}
|
||||
|
||||
const dirPath = getFileDirectory(node.externalPath)
|
||||
const { safeName, exists } = await window.api.file.checkFileName(dirPath, newName, node.type === 'file')
|
||||
export async function renameNode(node: NotesTreeNode, newName: string): Promise<{ path: string; name: string }> {
|
||||
const isFile = node.type === 'file'
|
||||
const parentDir = normalizePath(getFileDirectory(node.externalPath))
|
||||
const { safeName, exists } = await window.api.file.checkFileName(parentDir, newName, isFile)
|
||||
|
||||
if (exists) {
|
||||
logger.warn(`Target name already exists: ${safeName}`)
|
||||
throw new Error(`Target name already exists: ${safeName}`)
|
||||
}
|
||||
|
||||
if (node.type === 'file') {
|
||||
if (isFile) {
|
||||
await window.api.file.rename(node.externalPath, safeName)
|
||||
} else if (node.type === 'folder') {
|
||||
await window.api.file.renameDir(node.externalPath, safeName)
|
||||
return { path: `${parentDir}/${safeName}${MARKDOWN_EXT}`, name: safeName }
|
||||
}
|
||||
return renameNodeFromTree(tree, nodeId, safeName)
|
||||
|
||||
await window.api.file.renameDir(node.externalPath, safeName)
|
||||
return { path: `${parentDir}/${safeName}`, name: safeName }
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动节点
|
||||
*/
|
||||
export async function moveNode(
|
||||
sourceNodeId: string,
|
||||
targetNodeId: string,
|
||||
position: 'before' | 'after' | 'inside'
|
||||
): Promise<MoveNodeResult> {
|
||||
try {
|
||||
const tree = await getNotesTree()
|
||||
export async function uploadNotes(files: File[], targetPath: string): Promise<UploadResult> {
|
||||
const basePath = normalizePath(targetPath)
|
||||
const markdownFiles = filterMarkdown(files)
|
||||
const skippedFiles = files.length - markdownFiles.length
|
||||
|
||||
// 找到源节点和目标节点
|
||||
const sourceNode = findNodeInTree(tree, sourceNodeId)
|
||||
const targetNode = findNodeInTree(tree, targetNodeId)
|
||||
|
||||
if (!sourceNode || !targetNode) {
|
||||
logger.error(`Move nodes failed: node not found (source: ${sourceNodeId}, target: ${targetNodeId})`)
|
||||
return { success: false }
|
||||
if (markdownFiles.length === 0) {
|
||||
return {
|
||||
uploadedNodes: [],
|
||||
totalFiles: files.length,
|
||||
skippedFiles,
|
||||
fileCount: 0,
|
||||
folderCount: 0
|
||||
}
|
||||
}
|
||||
|
||||
// 不允许文件夹被放入文件中
|
||||
if (position === 'inside' && targetNode.type === 'file' && sourceNode.type === 'folder') {
|
||||
logger.error('Move nodes failed: cannot move a folder inside a file')
|
||||
return { success: false }
|
||||
const folders = collectFolders(markdownFiles, basePath)
|
||||
await createFolders(folders)
|
||||
|
||||
let fileCount = 0
|
||||
|
||||
for (const file of markdownFiles) {
|
||||
const { dir, name } = resolveFileTarget(file, basePath)
|
||||
const { safeName } = await window.api.file.checkFileName(dir, name, true)
|
||||
const finalPath = `${dir}/${safeName}${MARKDOWN_EXT}`
|
||||
|
||||
try {
|
||||
const content = await file.text()
|
||||
await window.api.file.write(finalPath, content)
|
||||
fileCount += 1
|
||||
} catch (error) {
|
||||
logger.error('Failed to write uploaded file:', error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
// 不允许将节点移动到自身内部
|
||||
if (position === 'inside' && isParentNode(tree, sourceNodeId, targetNodeId)) {
|
||||
logger.error('Move nodes failed: cannot move a node inside itself or its descendants')
|
||||
return { success: false }
|
||||
}
|
||||
|
||||
let targetPath: string = ''
|
||||
|
||||
if (position === 'inside') {
|
||||
// 目标是文件夹内部
|
||||
if (targetNode.type === 'folder') {
|
||||
targetPath = targetNode.externalPath
|
||||
} else {
|
||||
logger.error('Cannot move node inside a file node')
|
||||
return { success: false }
|
||||
}
|
||||
} else {
|
||||
const targetParent = findParentNode(tree, targetNodeId)
|
||||
if (targetParent) {
|
||||
targetPath = targetParent.externalPath
|
||||
} else {
|
||||
targetPath = getFileDirectory(targetNode.externalPath!)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否为同级拖动排序
|
||||
const sourceParent = findParentNode(tree, sourceNodeId)
|
||||
const sourceDir = sourceParent ? sourceParent.externalPath : getFileDirectory(sourceNode.externalPath!)
|
||||
|
||||
const isSameLevelReorder = position !== 'inside' && sourceDir === targetPath
|
||||
|
||||
if (isSameLevelReorder) {
|
||||
// 同级拖动排序:跳过文件系统操作,只更新树结构
|
||||
logger.debug(`Same level reorder detected, skipping file system operations`)
|
||||
const success = await moveNodeInTree(tree, sourceNodeId, targetNodeId, position)
|
||||
// 返回一个特殊标识,告诉调用方这是手动排序,不需要重新自动排序
|
||||
return success ? { success: true, type: 'manual_reorder' } : { success: false }
|
||||
}
|
||||
|
||||
// 构建新的文件路径
|
||||
const sourceName = sourceNode.externalPath!.split('/').pop()!
|
||||
const sourceNameWithoutExt = sourceName.replace(sourceNode.type === 'file' ? MARKDOWN_EXT : '', '')
|
||||
|
||||
const { safeName } = await window.api.file.checkFileName(
|
||||
targetPath,
|
||||
sourceNameWithoutExt,
|
||||
sourceNode.type === 'file'
|
||||
)
|
||||
|
||||
const baseName = safeName + (sourceNode.type === 'file' ? MARKDOWN_EXT : '')
|
||||
const newPath = `${targetPath}/${baseName}`
|
||||
|
||||
if (sourceNode.externalPath !== newPath) {
|
||||
try {
|
||||
if (sourceNode.type === 'folder') {
|
||||
await window.api.file.moveDir(sourceNode.externalPath, newPath)
|
||||
} else {
|
||||
await window.api.file.move(sourceNode.externalPath, newPath)
|
||||
}
|
||||
sourceNode.externalPath = newPath
|
||||
logger.debug(`Moved external ${sourceNode.type} to: ${newPath}`)
|
||||
} catch (error) {
|
||||
logger.error(`Failed to move external ${sourceNode.type}:`, error as Error)
|
||||
return { success: false }
|
||||
}
|
||||
}
|
||||
|
||||
const success = await moveNodeInTree(tree, sourceNodeId, targetNodeId, position)
|
||||
return success ? { success: true, type: 'file_system_move' } : { success: false }
|
||||
} catch (error) {
|
||||
logger.error('Move nodes failed:', error as Error)
|
||||
return { success: false }
|
||||
return {
|
||||
uploadedNodes: [],
|
||||
totalFiles: files.length,
|
||||
skippedFiles,
|
||||
fileCount,
|
||||
folderCount: folders.size
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 对节点数组进行排序
|
||||
*/
|
||||
function sortNodesArray(nodes: NotesTreeNode[], sortType: NotesSortType): void {
|
||||
// 首先分离文件夹和文件
|
||||
const folders: NotesTreeNode[] = nodes.filter((node) => node.type === 'folder')
|
||||
const files: NotesTreeNode[] = nodes.filter((node) => node.type === 'file')
|
||||
|
||||
// 根据排序类型对文件夹和文件分别进行排序
|
||||
const sortFunction = getSortFunction(sortType)
|
||||
folders.sort(sortFunction)
|
||||
files.sort(sortFunction)
|
||||
|
||||
// 清空原数组并重新填入排序后的节点
|
||||
nodes.length = 0
|
||||
nodes.push(...folders, ...files)
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据排序类型获取相应的排序函数
|
||||
*/
|
||||
function getSortFunction(sortType: NotesSortType): (a: NotesTreeNode, b: NotesTreeNode) => number {
|
||||
function getSorter(sortType: NotesSortType): (a: NotesTreeNode, b: NotesTreeNode) => number {
|
||||
switch (sortType) {
|
||||
case 'sort_a2z':
|
||||
return (a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'accent' })
|
||||
|
||||
case 'sort_z2a':
|
||||
return (a, b) => b.name.localeCompare(a.name, undefined, { sensitivity: 'accent' })
|
||||
|
||||
case 'sort_updated_desc':
|
||||
return (a, b) => {
|
||||
const timeA = a.updatedAt ? new Date(a.updatedAt).getTime() : 0
|
||||
const timeB = b.updatedAt ? new Date(b.updatedAt).getTime() : 0
|
||||
return timeB - timeA
|
||||
}
|
||||
|
||||
return (a, b) => getTime(b.updatedAt) - getTime(a.updatedAt)
|
||||
case 'sort_updated_asc':
|
||||
return (a, b) => {
|
||||
const timeA = a.updatedAt ? new Date(a.updatedAt).getTime() : 0
|
||||
const timeB = b.updatedAt ? new Date(b.updatedAt).getTime() : 0
|
||||
return timeA - timeB
|
||||
}
|
||||
|
||||
return (a, b) => getTime(a.updatedAt) - getTime(b.updatedAt)
|
||||
case 'sort_created_desc':
|
||||
return (a, b) => {
|
||||
const timeA = a.createdAt ? new Date(a.createdAt).getTime() : 0
|
||||
const timeB = b.createdAt ? new Date(b.createdAt).getTime() : 0
|
||||
return timeB - timeA
|
||||
}
|
||||
|
||||
return (a, b) => getTime(b.createdAt) - getTime(a.createdAt)
|
||||
case 'sort_created_asc':
|
||||
return (a, b) => {
|
||||
const timeA = a.createdAt ? new Date(a.createdAt).getTime() : 0
|
||||
const timeB = b.createdAt ? new Date(b.createdAt).getTime() : 0
|
||||
return timeA - timeB
|
||||
}
|
||||
|
||||
return (a, b) => getTime(a.createdAt) - getTime(b.createdAt)
|
||||
default:
|
||||
return (a, b) => a.name.localeCompare(b.name)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归排序笔记树中的所有层级
|
||||
*/
|
||||
export async function sortAllLevels(sortType: NotesSortType, tree?: NotesTreeNode[]): Promise<void> {
|
||||
try {
|
||||
if (!tree) {
|
||||
tree = await getNotesTree()
|
||||
}
|
||||
sortNodesArray(tree, sortType)
|
||||
recursiveSortNodes(tree, sortType)
|
||||
await db.notes_tree.put({ id: NOTES_TREE_ID, tree })
|
||||
logger.info(`Sorted all levels of notes successfully: ${sortType}`)
|
||||
} catch (error) {
|
||||
logger.error('Failed to sort all levels of notes:', error as Error)
|
||||
throw error
|
||||
}
|
||||
function getTime(value?: string): number {
|
||||
return value ? new Date(value).getTime() : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归对节点中的子节点进行排序
|
||||
*/
|
||||
function recursiveSortNodes(nodes: NotesTreeNode[], sortType: NotesSortType): void {
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'folder' && node.children && node.children.length > 0) {
|
||||
sortNodesArray(node.children, sortType)
|
||||
recursiveSortNodes(node.children, sortType)
|
||||
}
|
||||
}
|
||||
function normalizePath(value: string): string {
|
||||
return value.replace(/\\/g, '/')
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据外部路径查找节点(递归查找)
|
||||
*/
|
||||
function findNodeByExternalPath(nodes: NotesTreeNode[], externalPath: string): NotesTreeNode | null {
|
||||
for (const node of nodes) {
|
||||
if (node.externalPath === externalPath) {
|
||||
return node
|
||||
}
|
||||
if (node.children && node.children.length > 0) {
|
||||
const found = findNodeByExternalPath(node.children, externalPath)
|
||||
if (found) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
function filterMarkdown(files: File[]): File[] {
|
||||
return files.filter((file) => file.name.toLowerCase().endsWith(MARKDOWN_EXT))
|
||||
}
|
||||
|
||||
/**
|
||||
* 过滤出 Markdown 文件
|
||||
*/
|
||||
function filterMarkdownFiles(files: File[]): File[] {
|
||||
return Array.from(files).filter((file) => {
|
||||
if (file.name.toLowerCase().endsWith(MARKDOWN_EXT)) {
|
||||
return true
|
||||
function collectFolders(files: File[], basePath: string): Set<string> {
|
||||
const folders = new Set<string>()
|
||||
|
||||
files.forEach((file) => {
|
||||
const relativePath = file.webkitRelativePath || ''
|
||||
if (!relativePath.includes('/')) {
|
||||
return
|
||||
}
|
||||
|
||||
const parts = relativePath.split('/')
|
||||
parts.pop()
|
||||
|
||||
let current = basePath
|
||||
for (const part of parts) {
|
||||
current = `${current}/${part}`
|
||||
folders.add(current)
|
||||
}
|
||||
logger.warn(`Skipping non-markdown file: ${file.name}`)
|
||||
return false
|
||||
})
|
||||
|
||||
return folders
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建空的上传结果
|
||||
*/
|
||||
function createEmptyUploadResult(totalFiles: number, skippedFiles: number): UploadResult {
|
||||
return {
|
||||
uploadedNodes: [],
|
||||
totalFiles,
|
||||
skippedFiles,
|
||||
fileCount: 0,
|
||||
folderCount: 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理重复的根文件夹名称,为重复的文件夹重写 webkitRelativePath
|
||||
*/
|
||||
async function processDuplicateRootFolders(markdownFiles: File[], targetFolderPath: string): Promise<File[]> {
|
||||
// 按根文件夹名称分组文件
|
||||
const filesByRootFolder = new Map<string, File[]>()
|
||||
const processedFiles: File[] = []
|
||||
|
||||
for (const file of markdownFiles) {
|
||||
const filePath = file.webkitRelativePath || file.name
|
||||
|
||||
if (filePath.includes('/')) {
|
||||
const rootFolderName = filePath.substring(0, filePath.indexOf('/'))
|
||||
if (!filesByRootFolder.has(rootFolderName)) {
|
||||
filesByRootFolder.set(rootFolderName, [])
|
||||
}
|
||||
filesByRootFolder.get(rootFolderName)!.push(file)
|
||||
} else {
|
||||
// 单个文件,直接添加
|
||||
processedFiles.push(file)
|
||||
}
|
||||
}
|
||||
|
||||
// 为每个根文件夹组生成唯一的文件夹名称
|
||||
for (const [rootFolderName, files] of filesByRootFolder.entries()) {
|
||||
const { safeName } = await window.api.file.checkFileName(targetFolderPath, rootFolderName, false)
|
||||
|
||||
for (const file of files) {
|
||||
// 创建一个新的 File 对象,并修改 webkitRelativePath
|
||||
const originalPath = file.webkitRelativePath || file.name
|
||||
const relativePath = originalPath.substring(originalPath.indexOf('/') + 1)
|
||||
const newPath = `${safeName}/${relativePath}`
|
||||
|
||||
const newFile = new File([file], file.name, {
|
||||
type: file.type,
|
||||
lastModified: file.lastModified
|
||||
})
|
||||
|
||||
Object.defineProperty(newFile, 'webkitRelativePath', {
|
||||
value: newPath,
|
||||
writable: false
|
||||
})
|
||||
|
||||
processedFiles.push(newFile)
|
||||
}
|
||||
}
|
||||
|
||||
return processedFiles
|
||||
}
|
||||
|
||||
/**
|
||||
* 按路径分组文件并收集需要创建的文件夹
|
||||
*/
|
||||
function groupFilesByPath(
|
||||
markdownFiles: File[],
|
||||
targetFolderPath: string
|
||||
): { filesByPath: Map<string, File[]>; foldersToCreate: Set<string> } {
|
||||
const filesByPath = new Map<string, File[]>()
|
||||
const foldersToCreate = new Set<string>()
|
||||
|
||||
for (const file of markdownFiles) {
|
||||
const filePath = file.webkitRelativePath || file.name
|
||||
const relativeDirPath = filePath.includes('/') ? filePath.substring(0, filePath.lastIndexOf('/')) : ''
|
||||
const fullDirPath = relativeDirPath ? `${targetFolderPath}/${relativeDirPath}` : targetFolderPath
|
||||
|
||||
if (relativeDirPath) {
|
||||
const pathParts = relativeDirPath.split('/')
|
||||
|
||||
let currentPath = targetFolderPath
|
||||
for (const part of pathParts) {
|
||||
currentPath = `${currentPath}/${part}`
|
||||
foldersToCreate.add(currentPath)
|
||||
}
|
||||
}
|
||||
|
||||
if (!filesByPath.has(fullDirPath)) {
|
||||
filesByPath.set(fullDirPath, [])
|
||||
}
|
||||
filesByPath.get(fullDirPath)!.push(file)
|
||||
}
|
||||
|
||||
return { filesByPath, foldersToCreate }
|
||||
}
|
||||
|
||||
/**
|
||||
* 顺序创建文件夹(避免竞争条件)
|
||||
*/
|
||||
async function createFoldersSequentially(
|
||||
foldersToCreate: Set<string>,
|
||||
targetFolderPath: string,
|
||||
tree: NotesTreeNode[],
|
||||
uploadedNodes: NotesTreeNode[]
|
||||
): Promise<Map<string, NotesTreeNode>> {
|
||||
const createdFolders = new Map<string, NotesTreeNode>()
|
||||
const sortedFolders = Array.from(foldersToCreate).sort()
|
||||
const folderCreationLock = new Set<string>()
|
||||
|
||||
for (const folderPath of sortedFolders) {
|
||||
if (folderCreationLock.has(folderPath)) {
|
||||
continue
|
||||
}
|
||||
folderCreationLock.add(folderPath)
|
||||
async function createFolders(folders: Set<string>): Promise<void> {
|
||||
const ordered = Array.from(folders).sort((a, b) => a.length - b.length)
|
||||
|
||||
for (const folder of ordered) {
|
||||
try {
|
||||
const result = await createSingleFolder(folderPath, targetFolderPath, tree, createdFolders)
|
||||
if (result) {
|
||||
createdFolders.set(folderPath, result)
|
||||
if (result.externalPath !== folderPath) {
|
||||
createdFolders.set(result.externalPath, result)
|
||||
}
|
||||
uploadedNodes.push(result)
|
||||
logger.debug(`Created folder: ${folderPath} -> ${result.externalPath}`)
|
||||
}
|
||||
await window.api.file.mkdir(folder)
|
||||
} catch (error) {
|
||||
logger.error(`Failed to create folder ${folderPath}:`, error as Error)
|
||||
} finally {
|
||||
folderCreationLock.delete(folderPath)
|
||||
logger.debug('Skip existing folder while uploading notes', {
|
||||
folder,
|
||||
error: (error as Error).message
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return createdFolders
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建单个文件夹
|
||||
*/
|
||||
async function createSingleFolder(
|
||||
folderPath: string,
|
||||
targetFolderPath: string,
|
||||
tree: NotesTreeNode[],
|
||||
createdFolders: Map<string, NotesTreeNode>
|
||||
): Promise<NotesTreeNode | null> {
|
||||
const existingNode = findNodeByExternalPath(tree, folderPath)
|
||||
if (existingNode) {
|
||||
return existingNode
|
||||
}
|
||||
|
||||
const relativePath = folderPath.replace(targetFolderPath + '/', '')
|
||||
const originalFolderName = relativePath.split('/').pop()!
|
||||
const parentFolderPath = folderPath.substring(0, folderPath.lastIndexOf('/'))
|
||||
|
||||
const { safeName: safeFolderName, exists } = await window.api.file.checkFileName(
|
||||
parentFolderPath,
|
||||
originalFolderName,
|
||||
false
|
||||
)
|
||||
|
||||
const actualFolderPath = `${parentFolderPath}/${safeFolderName}`
|
||||
|
||||
if (exists) {
|
||||
logger.warn(`Folder already exists, creating with new name: ${originalFolderName} -> ${safeFolderName}`)
|
||||
}
|
||||
|
||||
try {
|
||||
await window.api.file.mkdir(actualFolderPath)
|
||||
} catch (error) {
|
||||
logger.debug(`Error creating folder: ${actualFolderPath}`, error as Error)
|
||||
}
|
||||
|
||||
let parentNode: NotesTreeNode | null
|
||||
if (parentFolderPath === targetFolderPath) {
|
||||
parentNode =
|
||||
tree.find((node) => node.externalPath === targetFolderPath) || findNodeByExternalPath(tree, targetFolderPath)
|
||||
} else {
|
||||
parentNode = createdFolders.get(parentFolderPath) || null
|
||||
if (!parentNode) {
|
||||
parentNode = tree.find((node) => node.externalPath === parentFolderPath) || null
|
||||
if (!parentNode) {
|
||||
parentNode = findNodeByExternalPath(tree, parentFolderPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const folderId = uuidv4()
|
||||
const folder: NotesTreeNode = {
|
||||
id: folderId,
|
||||
name: safeFolderName,
|
||||
treePath: parentNode ? `${parentNode.treePath}/${safeFolderName}` : `/${safeFolderName}`,
|
||||
externalPath: actualFolderPath,
|
||||
type: 'folder',
|
||||
children: [],
|
||||
expanded: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
await insertNodeIntoTree(tree, folder, parentNode?.id)
|
||||
return folder
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取文件内容(支持大文件处理)
|
||||
*/
|
||||
async function readFileContent(file: File): Promise<string> {
|
||||
const MAX_FILE_SIZE = 50 * 1024 * 1024 // 50MB
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
logger.warn(
|
||||
`Large file detected (${Math.round(file.size / 1024 / 1024)}MB): ${file.name}. Consider using streaming for better performance.`
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
return await file.text()
|
||||
} catch (error) {
|
||||
logger.error(`Failed to read file content for ${file.name}:`, error as Error)
|
||||
throw new Error(`Failed to read file content: ${file.name}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传所有文件
|
||||
*/
|
||||
async function uploadAllFiles(
|
||||
filesByPath: Map<string, File[]>,
|
||||
targetFolderPath: string,
|
||||
tree: NotesTreeNode[],
|
||||
createdFolders: Map<string, NotesTreeNode>,
|
||||
uploadedNodes: NotesTreeNode[]
|
||||
): Promise<void> {
|
||||
const uploadPromises: Promise<NotesTreeNode | null>[] = []
|
||||
|
||||
for (const [dirPath, dirFiles] of filesByPath.entries()) {
|
||||
for (const file of dirFiles) {
|
||||
const uploadPromise = uploadSingleFile(file, dirPath, targetFolderPath, tree, createdFolders)
|
||||
.then((result) => {
|
||||
if (result) {
|
||||
logger.debug(`Uploaded file: ${result.externalPath}`)
|
||||
}
|
||||
return result
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error(`Failed to upload file ${file.name}:`, error as Error)
|
||||
return null
|
||||
})
|
||||
|
||||
uploadPromises.push(uploadPromise)
|
||||
}
|
||||
function resolveFileTarget(file: File, basePath: string): { dir: string; name: string } {
|
||||
if (!file.webkitRelativePath || !file.webkitRelativePath.includes('/')) {
|
||||
const nameWithoutExt = file.name.endsWith(MARKDOWN_EXT) ? file.name.slice(0, -MARKDOWN_EXT.length) : file.name
|
||||
return { dir: basePath, name: nameWithoutExt }
|
||||
}
|
||||
|
||||
const results = await Promise.all(uploadPromises)
|
||||
const parts = file.webkitRelativePath.split('/')
|
||||
const fileName = parts.pop() || file.name
|
||||
const dirPath = `${basePath}/${parts.join('/')}`
|
||||
const nameWithoutExt = fileName.endsWith(MARKDOWN_EXT) ? fileName.slice(0, -MARKDOWN_EXT.length) : fileName
|
||||
|
||||
results.forEach((result) => {
|
||||
if (result) {
|
||||
uploadedNodes.push(result)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传单个文件,需要根据实际创建的文件夹路径来找到正确的父节点
|
||||
*/
|
||||
async function uploadSingleFile(
|
||||
file: File,
|
||||
originalDirPath: string,
|
||||
targetFolderPath: string,
|
||||
tree: NotesTreeNode[],
|
||||
createdFolders: Map<string, NotesTreeNode>
|
||||
): Promise<NotesTreeNode | null> {
|
||||
const fileName = (file.webkitRelativePath || file.name).split('/').pop()!
|
||||
const nameWithoutExt = fileName.replace(MARKDOWN_EXT, '')
|
||||
|
||||
let actualDirPath = originalDirPath
|
||||
let parentNode: NotesTreeNode | null = null
|
||||
|
||||
if (originalDirPath === targetFolderPath) {
|
||||
parentNode =
|
||||
tree.find((node) => node.externalPath === targetFolderPath) || findNodeByExternalPath(tree, targetFolderPath)
|
||||
|
||||
if (!parentNode) {
|
||||
logger.debug(`Uploading file ${fileName} to root directory: ${targetFolderPath}`)
|
||||
}
|
||||
} else {
|
||||
parentNode = createdFolders.get(originalDirPath) || null
|
||||
if (!parentNode) {
|
||||
parentNode = tree.find((node) => node.externalPath === originalDirPath) || null
|
||||
if (!parentNode) {
|
||||
parentNode = findNodeByExternalPath(tree, originalDirPath)
|
||||
}
|
||||
}
|
||||
|
||||
if (!parentNode) {
|
||||
for (const [originalPath, createdNode] of createdFolders.entries()) {
|
||||
if (originalPath === originalDirPath) {
|
||||
parentNode = createdNode
|
||||
actualDirPath = createdNode.externalPath
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!parentNode) {
|
||||
logger.error(`Cannot upload file ${fileName}: parent node not found for path ${originalDirPath}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const { safeName, exists } = await window.api.file.checkFileName(actualDirPath, nameWithoutExt, true)
|
||||
if (exists) {
|
||||
logger.warn(`Note already exists, will be overwritten: ${safeName}`)
|
||||
}
|
||||
|
||||
const notePath = `${actualDirPath}/${safeName}${MARKDOWN_EXT}`
|
||||
|
||||
const noteId = uuidv4()
|
||||
const note: NotesTreeNode = {
|
||||
id: noteId,
|
||||
name: safeName,
|
||||
treePath: parentNode ? `${parentNode.treePath}/${safeName}` : `/${safeName}`,
|
||||
externalPath: notePath,
|
||||
type: 'file',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
const content = await readFileContent(file)
|
||||
await window.api.file.write(notePath, content)
|
||||
await insertNodeIntoTree(tree, note, parentNode?.id)
|
||||
|
||||
return note
|
||||
return { dir: dirPath, name: nameWithoutExt }
|
||||
}
|
||||
|
||||
@ -1,217 +1,47 @@
|
||||
import { loggerService } from '@logger'
|
||||
import db from '@renderer/databases'
|
||||
import type { NotesTreeNode } from '@renderer/types/note'
|
||||
|
||||
const MARKDOWN_EXT = '.md'
|
||||
const NOTES_TREE_ID = 'notes-tree-structure'
|
||||
|
||||
const logger = loggerService.withContext('NotesTreeService')
|
||||
|
||||
/**
|
||||
* 获取树结构
|
||||
*/
|
||||
export const getNotesTree = async (): Promise<NotesTreeNode[]> => {
|
||||
const record = await db.notes_tree.get(NOTES_TREE_ID)
|
||||
return record?.tree || []
|
||||
export function normalizePathValue(path: string): string {
|
||||
return path.replace(/\\/g, '/')
|
||||
}
|
||||
|
||||
/**
|
||||
* 在树中插入节点
|
||||
*/
|
||||
export async function insertNodeIntoTree(
|
||||
tree: NotesTreeNode[],
|
||||
node: NotesTreeNode,
|
||||
parentId?: string
|
||||
): Promise<NotesTreeNode[]> {
|
||||
try {
|
||||
if (!parentId) {
|
||||
tree.push(node)
|
||||
} else {
|
||||
const parent = findNodeInTree(tree, parentId)
|
||||
if (parent && parent.type === 'folder') {
|
||||
if (!parent.children) {
|
||||
parent.children = []
|
||||
}
|
||||
parent.children.push(node)
|
||||
}
|
||||
}
|
||||
|
||||
await db.notes_tree.put({ id: NOTES_TREE_ID, tree })
|
||||
return tree
|
||||
} catch (error) {
|
||||
logger.error('Failed to insert node into tree:', error as Error)
|
||||
throw error
|
||||
}
|
||||
export function addUniquePath(list: string[], path: string): string[] {
|
||||
const normalized = normalizePathValue(path)
|
||||
return list.includes(normalized) ? list : [...list, normalized]
|
||||
}
|
||||
|
||||
/**
|
||||
* 从树中删除节点
|
||||
*/
|
||||
export async function removeNodeFromTree(tree: NotesTreeNode[], nodeId: string): Promise<boolean> {
|
||||
const removed = removeNodeFromTreeInMemory(tree, nodeId)
|
||||
if (removed) {
|
||||
await db.notes_tree.put({ id: NOTES_TREE_ID, tree })
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
/**
|
||||
* 从树中删除节点(仅在内存中操作,不保存数据库)
|
||||
*/
|
||||
function removeNodeFromTreeInMemory(tree: NotesTreeNode[], nodeId: string): boolean {
|
||||
for (let i = 0; i < tree.length; i++) {
|
||||
if (tree[i].id === nodeId) {
|
||||
tree.splice(i, 1)
|
||||
return true
|
||||
}
|
||||
if (tree[i].children) {
|
||||
const removed = removeNodeFromTreeInMemory(tree[i].children!, nodeId)
|
||||
if (removed) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export async function moveNodeInTree(
|
||||
tree: NotesTreeNode[],
|
||||
sourceNodeId: string,
|
||||
targetNodeId: string,
|
||||
position: 'before' | 'after' | 'inside'
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const sourceNode = findNodeInTree(tree, sourceNodeId)
|
||||
const targetNode = findNodeInTree(tree, targetNodeId)
|
||||
|
||||
if (!sourceNode || !targetNode) {
|
||||
logger.error(`Move nodes in tree failed: node not found (source: ${sourceNodeId}, target: ${targetNodeId})`)
|
||||
export function removePathEntries(list: string[], path: string, deep: boolean): string[] {
|
||||
const normalized = normalizePathValue(path)
|
||||
const prefix = `${normalized}/`
|
||||
return list.filter((item) => {
|
||||
if (item === normalized) {
|
||||
return false
|
||||
}
|
||||
return !(deep && item.startsWith(prefix))
|
||||
})
|
||||
}
|
||||
|
||||
// 在移除节点之前先获取源节点的父节点信息,用于后续判断是否为同级排序
|
||||
const sourceParent = findParentNode(tree, sourceNodeId)
|
||||
const targetParent = findParentNode(tree, targetNodeId)
|
||||
|
||||
// 从原位置移除节点(不保存数据库,只在内存中操作)
|
||||
const removed = removeNodeFromTreeInMemory(tree, sourceNodeId)
|
||||
if (!removed) {
|
||||
logger.error('Move nodes in tree failed: could not remove source node')
|
||||
return false
|
||||
export function replacePathEntries(list: string[], oldPath: string, newPath: string, deep: boolean): string[] {
|
||||
const oldNormalized = normalizePathValue(oldPath)
|
||||
const newNormalized = normalizePathValue(newPath)
|
||||
const prefix = `${oldNormalized}/`
|
||||
return list.map((item) => {
|
||||
if (item === oldNormalized) {
|
||||
return newNormalized
|
||||
}
|
||||
|
||||
try {
|
||||
// 根据位置进行放置
|
||||
if (position === 'inside' && targetNode.type === 'folder') {
|
||||
if (!targetNode.children) {
|
||||
targetNode.children = []
|
||||
}
|
||||
targetNode.children.push(sourceNode)
|
||||
targetNode.expanded = true
|
||||
|
||||
sourceNode.treePath = `${targetNode.treePath}/${sourceNode.name}`
|
||||
} else {
|
||||
const targetList = targetParent ? targetParent.children! : tree
|
||||
const targetIndex = targetList.findIndex((node) => node.id === targetNodeId)
|
||||
|
||||
if (targetIndex === -1) {
|
||||
logger.error('Move nodes in tree failed: target position not found')
|
||||
return false
|
||||
}
|
||||
|
||||
// 根据position确定插入位置
|
||||
const insertIndex = position === 'before' ? targetIndex : targetIndex + 1
|
||||
targetList.splice(insertIndex, 0, sourceNode)
|
||||
|
||||
// 检查是否为同级排序,如果是则保持原有的 treePath
|
||||
const isSameLevelReorder = sourceParent === targetParent
|
||||
|
||||
// 只有在跨级移动时才更新节点路径
|
||||
if (!isSameLevelReorder) {
|
||||
if (targetParent) {
|
||||
sourceNode.treePath = `${targetParent.treePath}/${sourceNode.name}`
|
||||
} else {
|
||||
sourceNode.treePath = `/${sourceNode.name}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新修改时间
|
||||
sourceNode.updatedAt = new Date().toISOString()
|
||||
|
||||
// 只有在所有操作成功后才保存到数据库
|
||||
await db.notes_tree.put({ id: NOTES_TREE_ID, tree })
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error('Move nodes in tree failed during placement, attempting to restore:', error as Error)
|
||||
// 如果放置失败,尝试恢复原始节点到原位置
|
||||
// 这里需要重新实现恢复逻辑,暂时返回false
|
||||
return false
|
||||
if (deep && item.startsWith(prefix)) {
|
||||
return `${newNormalized}${item.slice(oldNormalized.length)}`
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Move nodes in tree failed:', error as Error)
|
||||
return false
|
||||
}
|
||||
return item
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 重命名节点
|
||||
*/
|
||||
export async function renameNodeFromTree(
|
||||
tree: NotesTreeNode[],
|
||||
nodeId: string,
|
||||
newName: string
|
||||
): Promise<NotesTreeNode> {
|
||||
const node = findNodeInTree(tree, nodeId)
|
||||
|
||||
if (!node) {
|
||||
throw new Error('Node not found')
|
||||
}
|
||||
|
||||
node.name = newName
|
||||
|
||||
const dirPath = node.treePath.substring(0, node.treePath.lastIndexOf('/') + 1)
|
||||
node.treePath = dirPath + newName
|
||||
|
||||
const externalDirPath = node.externalPath.substring(0, node.externalPath.lastIndexOf('/') + 1)
|
||||
node.externalPath = node.type === 'file' ? externalDirPath + newName + MARKDOWN_EXT : externalDirPath + newName
|
||||
|
||||
node.updatedAt = new Date().toISOString()
|
||||
await db.notes_tree.put({ id: NOTES_TREE_ID, tree })
|
||||
return node
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改节点键值
|
||||
*/
|
||||
export async function updateNodeInTree(
|
||||
tree: NotesTreeNode[],
|
||||
nodeId: string,
|
||||
updates: Partial<NotesTreeNode>
|
||||
): Promise<NotesTreeNode> {
|
||||
const node = findNodeInTree(tree, nodeId)
|
||||
if (!node) {
|
||||
throw new Error('Node not found')
|
||||
}
|
||||
|
||||
Object.assign(node, updates)
|
||||
node.updatedAt = new Date().toISOString()
|
||||
await db.notes_tree.put({ id: NOTES_TREE_ID, tree })
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
/**
|
||||
* 在树中查找节点
|
||||
*/
|
||||
export function findNodeInTree(tree: NotesTreeNode[], nodeId: string): NotesTreeNode | null {
|
||||
export function findNode(tree: NotesTreeNode[], nodeId: string): NotesTreeNode | null {
|
||||
for (const node of tree) {
|
||||
if (node.id === nodeId) {
|
||||
return node
|
||||
}
|
||||
if (node.children) {
|
||||
const found = findNodeInTree(node.children, nodeId)
|
||||
const found = findNode(node.children, nodeId)
|
||||
if (found) {
|
||||
return found
|
||||
}
|
||||
@ -220,16 +50,13 @@ export function findNodeInTree(tree: NotesTreeNode[], nodeId: string): NotesTree
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据路径查找节点
|
||||
*/
|
||||
export function findNodeByPath(tree: NotesTreeNode[], path: string): NotesTreeNode | null {
|
||||
export function findNodeByPath(tree: NotesTreeNode[], targetPath: string): NotesTreeNode | null {
|
||||
for (const node of tree) {
|
||||
if (node.treePath === path) {
|
||||
if (node.treePath === targetPath || node.externalPath === targetPath) {
|
||||
return node
|
||||
}
|
||||
if (node.children) {
|
||||
const found = findNodeByPath(node.children, path)
|
||||
const found = findNodeByPath(node.children, targetPath)
|
||||
if (found) {
|
||||
return found
|
||||
}
|
||||
@ -238,53 +65,113 @@ export function findNodeByPath(tree: NotesTreeNode[], path: string): NotesTreeNo
|
||||
return null
|
||||
}
|
||||
|
||||
// ---
|
||||
// 辅助函数
|
||||
// ---
|
||||
export function updateTreeNode(
|
||||
nodes: NotesTreeNode[],
|
||||
nodeId: string,
|
||||
updater: (node: NotesTreeNode) => NotesTreeNode
|
||||
): NotesTreeNode[] {
|
||||
let changed = false
|
||||
|
||||
/**
|
||||
* 查找节点的父节点
|
||||
*/
|
||||
export function findParentNode(tree: NotesTreeNode[], targetNodeId: string): NotesTreeNode | null {
|
||||
const nextNodes = nodes.map((node) => {
|
||||
if (node.id === nodeId) {
|
||||
changed = true
|
||||
const updated = updater(node)
|
||||
if (updated.type === 'folder' && !updated.children) {
|
||||
return { ...updated, children: [] }
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
if (node.children && node.children.length > 0) {
|
||||
const updatedChildren = updateTreeNode(node.children, nodeId, updater)
|
||||
if (updatedChildren !== node.children) {
|
||||
changed = true
|
||||
return { ...node, children: updatedChildren }
|
||||
}
|
||||
}
|
||||
|
||||
return node
|
||||
})
|
||||
|
||||
return changed ? nextNodes : nodes
|
||||
}
|
||||
|
||||
export function findParent(tree: NotesTreeNode[], nodeId: string): NotesTreeNode | null {
|
||||
for (const node of tree) {
|
||||
if (node.children) {
|
||||
const isDirectChild = node.children.some((child) => child.id === targetNodeId)
|
||||
if (isDirectChild) {
|
||||
return node
|
||||
}
|
||||
|
||||
const parent = findParentNode(node.children, targetNodeId)
|
||||
if (parent) {
|
||||
return parent
|
||||
}
|
||||
if (!node.children) {
|
||||
continue
|
||||
}
|
||||
if (node.children.some((child) => child.id === nodeId)) {
|
||||
return node
|
||||
}
|
||||
const found = findParent(node.children, nodeId)
|
||||
if (found) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断节点是否为另一个节点的父节点
|
||||
*/
|
||||
export function isParentNode(tree: NotesTreeNode[], parentId: string, childId: string): boolean {
|
||||
const childNode = findNodeInTree(tree, childId)
|
||||
if (!childNode) {
|
||||
return false
|
||||
export function reorderTreeNodes(
|
||||
nodes: NotesTreeNode[],
|
||||
sourceId: string,
|
||||
targetId: string,
|
||||
position: 'before' | 'after'
|
||||
): NotesTreeNode[] {
|
||||
const [updatedNodes, moved] = reorderSiblings(nodes, sourceId, targetId, position)
|
||||
if (moved) {
|
||||
return updatedNodes
|
||||
}
|
||||
|
||||
const parentNode = findNodeInTree(tree, parentId)
|
||||
if (!parentNode || parentNode.type !== 'folder' || !parentNode.children) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (parentNode.children.some((child) => child.id === childId)) {
|
||||
return true
|
||||
}
|
||||
|
||||
for (const child of parentNode.children) {
|
||||
if (isParentNode(tree, child.id, childId)) {
|
||||
return true
|
||||
let changed = false
|
||||
const nextNodes = nodes.map((node) => {
|
||||
if (!node.children || node.children.length === 0) {
|
||||
return node
|
||||
}
|
||||
|
||||
const reorderedChildren = reorderTreeNodes(node.children, sourceId, targetId, position)
|
||||
if (reorderedChildren !== node.children) {
|
||||
changed = true
|
||||
return { ...node, children: reorderedChildren }
|
||||
}
|
||||
|
||||
return node
|
||||
})
|
||||
|
||||
return changed ? nextNodes : nodes
|
||||
}
|
||||
|
||||
function reorderSiblings(
|
||||
nodes: NotesTreeNode[],
|
||||
sourceId: string,
|
||||
targetId: string,
|
||||
position: 'before' | 'after'
|
||||
): [NotesTreeNode[], boolean] {
|
||||
const sourceIndex = nodes.findIndex((node) => node.id === sourceId)
|
||||
const targetIndex = nodes.findIndex((node) => node.id === targetId)
|
||||
|
||||
if (sourceIndex === -1 || targetIndex === -1) {
|
||||
return [nodes, false]
|
||||
}
|
||||
|
||||
return false
|
||||
const updated = [...nodes]
|
||||
const [sourceNode] = updated.splice(sourceIndex, 1)
|
||||
|
||||
let insertIndex = targetIndex
|
||||
if (sourceIndex < targetIndex) {
|
||||
insertIndex -= 1
|
||||
}
|
||||
if (position === 'after') {
|
||||
insertIndex += 1
|
||||
}
|
||||
|
||||
if (insertIndex < 0) {
|
||||
insertIndex = 0
|
||||
}
|
||||
if (insertIndex > updated.length) {
|
||||
insertIndex = updated.length
|
||||
}
|
||||
|
||||
updated.splice(insertIndex, 0, sourceNode)
|
||||
return [updated, true]
|
||||
}
|
||||
|
||||
@ -16,11 +16,12 @@ export const createThinkingCallbacks = (deps: ThinkingCallbacksDependencies) =>
|
||||
|
||||
// 内部维护的状态
|
||||
let thinkingBlockId: string | null = null
|
||||
let thinking_millsec_now: number = 0
|
||||
|
||||
return {
|
||||
onThinkingStart: async () => {
|
||||
if (blockManager.hasInitialPlaceholder) {
|
||||
const changes = {
|
||||
const changes: Partial<MessageBlock> = {
|
||||
type: MessageBlockType.THINKING,
|
||||
content: '',
|
||||
status: MessageBlockStatus.STREAMING,
|
||||
@ -36,29 +37,31 @@ export const createThinkingCallbacks = (deps: ThinkingCallbacksDependencies) =>
|
||||
thinkingBlockId = newBlock.id
|
||||
await blockManager.handleBlockTransition(newBlock, MessageBlockType.THINKING)
|
||||
}
|
||||
thinking_millsec_now = performance.now()
|
||||
},
|
||||
|
||||
onThinkingChunk: async (text: string, thinking_millsec?: number) => {
|
||||
onThinkingChunk: async (text: string) => {
|
||||
if (thinkingBlockId) {
|
||||
const blockChanges: Partial<MessageBlock> = {
|
||||
content: text,
|
||||
status: MessageBlockStatus.STREAMING,
|
||||
thinking_millsec: thinking_millsec || 0
|
||||
status: MessageBlockStatus.STREAMING
|
||||
// thinking_millsec: performance.now() - thinking_millsec_now
|
||||
}
|
||||
blockManager.smartBlockUpdate(thinkingBlockId, blockChanges, MessageBlockType.THINKING)
|
||||
}
|
||||
},
|
||||
|
||||
onThinkingComplete: (finalText: string, final_thinking_millsec?: number) => {
|
||||
onThinkingComplete: (finalText: string) => {
|
||||
if (thinkingBlockId) {
|
||||
const changes = {
|
||||
type: MessageBlockType.THINKING,
|
||||
const now = performance.now()
|
||||
const changes: Partial<MessageBlock> = {
|
||||
content: finalText,
|
||||
status: MessageBlockStatus.SUCCESS,
|
||||
thinking_millsec: final_thinking_millsec || 0
|
||||
thinking_millsec: now - thinking_millsec_now
|
||||
}
|
||||
blockManager.smartBlockUpdate(thinkingBlockId, changes, MessageBlockType.THINKING, true)
|
||||
thinkingBlockId = null
|
||||
thinking_millsec_now = 0
|
||||
} else {
|
||||
logger.warn(
|
||||
`[onThinkingComplete] Received thinking.complete but last block was not THINKING (was ${blockManager.lastBlockType}) or lastBlockId is null.`
|
||||
|
||||
@ -71,7 +71,7 @@ const persistedReducer = persistReducer(
|
||||
{
|
||||
key: 'cherry-studio',
|
||||
storage,
|
||||
version: 157,
|
||||
version: 158,
|
||||
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs'],
|
||||
migrate
|
||||
},
|
||||
|
||||
@ -2543,6 +2543,16 @@ const migrateConfig = {
|
||||
logger.error('migrate 157 error', error as Error)
|
||||
return state
|
||||
}
|
||||
},
|
||||
'158': (state: RootState) => {
|
||||
try {
|
||||
state.llm.providers = state.llm.providers.filter((provider) => provider.id !== 'cherryin')
|
||||
addProvider(state, 'longcat')
|
||||
return state
|
||||
} catch (error) {
|
||||
logger.error('migrate 158 error', error as Error)
|
||||
return state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -21,6 +21,8 @@ export interface NoteState {
|
||||
settings: NotesSettings
|
||||
notesPath: string
|
||||
sortType: NotesSortType
|
||||
starredPaths: string[]
|
||||
expandedPaths: string[]
|
||||
}
|
||||
|
||||
export const initialState: NoteState = {
|
||||
@ -37,7 +39,9 @@ export const initialState: NoteState = {
|
||||
showWorkspace: true
|
||||
},
|
||||
notesPath: '',
|
||||
sortType: 'sort_a2z'
|
||||
sortType: 'sort_a2z',
|
||||
starredPaths: [],
|
||||
expandedPaths: []
|
||||
}
|
||||
|
||||
const noteSlice = createSlice({
|
||||
@ -58,16 +62,32 @@ const noteSlice = createSlice({
|
||||
},
|
||||
setSortType: (state, action: PayloadAction<NotesSortType>) => {
|
||||
state.sortType = action.payload
|
||||
},
|
||||
setStarredPaths: (state, action: PayloadAction<string[]>) => {
|
||||
state.starredPaths = action.payload ?? []
|
||||
},
|
||||
setExpandedPaths: (state, action: PayloadAction<string[]>) => {
|
||||
state.expandedPaths = action.payload ?? []
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const { setActiveNodeId, setActiveFilePath, updateNotesSettings, setNotesPath, setSortType } = noteSlice.actions
|
||||
export const {
|
||||
setActiveNodeId,
|
||||
setActiveFilePath,
|
||||
updateNotesSettings,
|
||||
setNotesPath,
|
||||
setSortType,
|
||||
setStarredPaths,
|
||||
setExpandedPaths
|
||||
} = noteSlice.actions
|
||||
|
||||
export const selectActiveNodeId = (state: RootState) => state.note.activeNodeId
|
||||
export const selectActiveFilePath = (state: RootState) => state.note.activeFilePath
|
||||
export const selectNotesSettings = (state: RootState) => state.note.settings
|
||||
export const selectNotesPath = (state: RootState) => state.note.notesPath
|
||||
export const selectSortType = (state: RootState) => state.note.sortType
|
||||
export const selectStarredPaths = (state: RootState) => state.note.starredPaths ?? []
|
||||
export const selectExpandedPaths = (state: RootState) => state.note.expandedPaths ?? []
|
||||
|
||||
export default noteSlice.reducer
|
||||
|
||||
@ -410,7 +410,8 @@ describe('streamCallback Integration Tests', () => {
|
||||
{ type: ChunkType.THINKING_START },
|
||||
{ type: ChunkType.THINKING_DELTA, text: 'Let me think...', thinking_millsec: 1000 },
|
||||
{ type: ChunkType.THINKING_DELTA, text: 'I need to consider...', thinking_millsec: 2000 },
|
||||
{ type: ChunkType.THINKING_COMPLETE, text: 'Final thoughts', thinking_millsec: 3000 },
|
||||
{ type: ChunkType.THINKING_DELTA, text: 'Final thoughts', thinking_millsec: 3000 },
|
||||
{ type: ChunkType.THINKING_COMPLETE, text: 'Final thoughts' },
|
||||
{ type: ChunkType.BLOCK_COMPLETE }
|
||||
]
|
||||
|
||||
@ -424,7 +425,10 @@ describe('streamCallback Integration Tests', () => {
|
||||
expect(thinkingBlock).toBeDefined()
|
||||
expect(thinkingBlock?.content).toBe('Final thoughts')
|
||||
expect(thinkingBlock?.status).toBe(MessageBlockStatus.SUCCESS)
|
||||
expect((thinkingBlock as any)?.thinking_millsec).toBe(3000)
|
||||
// thinking_millsec 现在是本地计算的,只验证它存在且是一个合理的数字
|
||||
expect((thinkingBlock as any)?.thinking_millsec).toBeDefined()
|
||||
expect(typeof (thinkingBlock as any)?.thinking_millsec).toBe('number')
|
||||
expect((thinkingBlock as any)?.thinking_millsec).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it('should handle tool call flow', async () => {
|
||||
|
||||
@ -269,7 +269,7 @@ export type Provider = {
|
||||
}
|
||||
|
||||
export const SystemProviderIds = {
|
||||
cherryin: 'cherryin',
|
||||
// cherryin: 'cherryin',
|
||||
silicon: 'silicon',
|
||||
aihubmix: 'aihubmix',
|
||||
ocoolai: 'ocoolai',
|
||||
@ -322,7 +322,8 @@ export const SystemProviderIds = {
|
||||
voyageai: 'voyageai',
|
||||
'aws-bedrock': 'aws-bedrock',
|
||||
poe: 'poe',
|
||||
aionly: 'aionly'
|
||||
aionly: 'aionly',
|
||||
longcat: 'longcat'
|
||||
} as const
|
||||
|
||||
export type SystemProviderId = keyof typeof SystemProviderIds
|
||||
|
||||
@ -10,6 +10,7 @@ import type {
|
||||
MemoryItem,
|
||||
Metrics,
|
||||
Model,
|
||||
NormalToolResponse,
|
||||
Topic,
|
||||
Usage,
|
||||
WebSearchResponse,
|
||||
@ -113,7 +114,7 @@ export interface ToolMessageBlock extends BaseMessageBlock {
|
||||
arguments?: Record<string, any>
|
||||
content?: string | object
|
||||
metadata?: BaseMessageBlock['metadata'] & {
|
||||
rawMcpToolResponse?: MCPToolResponse
|
||||
rawMcpToolResponse?: MCPToolResponse | NormalToolResponse
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -4,10 +4,9 @@ import { Client } from '@notionhq/client'
|
||||
import i18n from '@renderer/i18n'
|
||||
import { getProviderLabel } from '@renderer/i18n/label'
|
||||
import { getMessageTitle } from '@renderer/services/MessagesService'
|
||||
import { createNote } from '@renderer/services/NotesService'
|
||||
import { addNote } from '@renderer/services/NotesService'
|
||||
import type { Topic } from '@renderer/types'
|
||||
import type { Message } from '@renderer/types/newMessage'
|
||||
import type { NotesTreeNode } from '@renderer/types/note'
|
||||
import { removeSpecialCharactersForFileName } from '@renderer/utils/file'
|
||||
import { convertMathFormula, markdownToPlainText } from '@renderer/utils/markdown'
|
||||
import { getCitationContent, getMainTextContent, getThinkingContent } from '@renderer/utils/messageUtils/find'
|
||||
@ -1075,18 +1074,12 @@ async function createSiyuanDoc(
|
||||
* @param content
|
||||
* @param folderPath
|
||||
*/
|
||||
export const exportMessageToNotes = async (
|
||||
title: string,
|
||||
content: string,
|
||||
folderPath: string
|
||||
): Promise<NotesTreeNode> => {
|
||||
export const exportMessageToNotes = async (title: string, content: string, folderPath: string): Promise<void> => {
|
||||
try {
|
||||
const cleanedContent = content.replace(/^## 🤖 Assistant(\n|$)/m, '')
|
||||
const note = await createNote(title, cleanedContent, folderPath)
|
||||
await addNote(title, cleanedContent, folderPath)
|
||||
|
||||
window.toast.success(i18n.t('message.success.notes.export'))
|
||||
|
||||
return note
|
||||
} catch (error) {
|
||||
logger.error('导出到笔记失败:', error as Error)
|
||||
window.toast.error(i18n.t('message.error.notes.export'))
|
||||
@ -1100,14 +1093,12 @@ export const exportMessageToNotes = async (
|
||||
* @param folderPath
|
||||
* @returns 创建的笔记节点
|
||||
*/
|
||||
export const exportTopicToNotes = async (topic: Topic, folderPath: string): Promise<NotesTreeNode> => {
|
||||
export const exportTopicToNotes = async (topic: Topic, folderPath: string): Promise<void> => {
|
||||
try {
|
||||
const content = await topicToMarkdown(topic)
|
||||
const note = await createNote(topic.name, content, folderPath)
|
||||
await addNote(topic.name, content, folderPath)
|
||||
|
||||
window.toast.success(i18n.t('message.success.notes.export'))
|
||||
|
||||
return note
|
||||
} catch (error) {
|
||||
logger.error('导出到笔记失败:', error as Error)
|
||||
window.toast.error(i18n.t('message.error.notes.export'))
|
||||
|
||||
15
yarn.lock
15
yarn.lock
@ -155,7 +155,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@ai-sdk/google@npm:2.0.14, @ai-sdk/google@npm:^2.0.14":
|
||||
"@ai-sdk/google@npm:2.0.14":
|
||||
version: 2.0.14
|
||||
resolution: "@ai-sdk/google@npm:2.0.14"
|
||||
dependencies:
|
||||
@ -167,6 +167,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@ai-sdk/google@patch:@ai-sdk/google@npm%3A2.0.14#~/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch":
|
||||
version: 2.0.14
|
||||
resolution: "@ai-sdk/google@patch:@ai-sdk/google@npm%3A2.0.14#~/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch::version=2.0.14&hash=351f1a"
|
||||
dependencies:
|
||||
"@ai-sdk/provider": "npm:2.0.0"
|
||||
"@ai-sdk/provider-utils": "npm:3.0.9"
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4
|
||||
checksum: 10c0/1ed5a0732a82b981d51f63c6241ed8ee94d5c29a842764db770305cfc2f49ab6e528cac438b5357fc7b02194104c7b76d4390a1dc1d019ace9c174b0849e0da6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@ai-sdk/mistral@npm:^2.0.14":
|
||||
version: 2.0.14
|
||||
resolution: "@ai-sdk/mistral@npm:2.0.14"
|
||||
@ -2411,7 +2423,6 @@ __metadata:
|
||||
"@ai-sdk/anthropic": "npm:^2.0.17"
|
||||
"@ai-sdk/azure": "npm:^2.0.30"
|
||||
"@ai-sdk/deepseek": "npm:^1.0.17"
|
||||
"@ai-sdk/google": "npm:^2.0.14"
|
||||
"@ai-sdk/openai": "npm:^2.0.30"
|
||||
"@ai-sdk/openai-compatible": "npm:^1.0.17"
|
||||
"@ai-sdk/provider": "npm:^2.0.0"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user