Compare commits
55 Commits
feat/messa
...
v1.6.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
170632a199 | ||
|
|
cd5841cdd4 | ||
|
|
763afc5ca2 | ||
|
|
45f033ff4e | ||
|
|
f8fadcc73f | ||
|
|
a94e5dad5f | ||
|
|
632fd4c567 | ||
|
|
401e17eb0e | ||
|
|
80fc118465 | ||
|
|
9a8d7640f5 | ||
|
|
2b3f6d5640 | ||
|
|
a2d81e6204 | ||
|
|
b6107c5fb1 | ||
|
|
6a8544fb0e | ||
|
|
37f7042f0f | ||
|
|
65d066cbef | ||
|
|
504531d4d5 | ||
|
|
d4b3428160 | ||
|
|
cd881ceb34 | ||
|
|
a9843b4128 | ||
|
|
d2d5064eed | ||
|
|
8bec7640fa | ||
|
|
fcf53f06ef | ||
|
|
2048f210e7 | ||
|
|
78eacccf6e | ||
|
|
a436ab1d78 | ||
|
|
2aedbf5702 | ||
|
|
b7e7174f3d | ||
|
|
e7e5c0456f | ||
|
|
53e38ed1aa | ||
|
|
f91e7da0a1 | ||
|
|
74db4c4646 | ||
|
|
1e4902b267 | ||
|
|
932b1d529a | ||
|
|
38ac42af8c | ||
|
|
d11a2cd95c | ||
|
|
23f61b0d62 | ||
|
|
961ee22327 | ||
|
|
c7d2588f1a | ||
|
|
06ab2822be | ||
|
|
bb0ec0a3ec | ||
|
|
483b4e090e | ||
|
|
4975c2d9e8 | ||
|
|
5365fddec9 | ||
|
|
e401685449 | ||
|
|
e195ad4a8f | ||
|
|
20f5271682 | ||
|
|
5524571c80 | ||
|
|
cd3031479c | ||
|
|
1df6e8c732 | ||
|
|
ed2e01491e | ||
|
|
228ed474ce | ||
|
|
6829a03437 | ||
|
|
dabfb8dc0e | ||
|
|
4aa9c9f225 |
6
.github/workflows/auto-i18n.yml
vendored
@@ -2,8 +2,8 @@ name: Auto I18N
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
API_KEY: ${{ secrets.TRANSLATE_API_KEY }}
|
API_KEY: ${{ secrets.TRANSLATE_API_KEY }}
|
||||||
MODEL: ${{ vars.MODEL || 'deepseek/deepseek-v3.1'}}
|
MODEL: ${{ vars.AUTO_I18N_MODEL || 'deepseek/deepseek-v3.1'}}
|
||||||
BASE_URL: ${{ vars.BASE_URL || 'https://api.ppinfra.com/openai'}}
|
BASE_URL: ${{ vars.AUTO_I18N_BASE_URL || 'https://api.ppinfra.com/openai'}}
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
ref: ${{ github.event.pull_request.head.ref }}
|
ref: ${{ github.event.pull_request.head.ref }}
|
||||||
|
|
||||||
- name: 📦 Setting Node.js
|
- name: 📦 Setting Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
|
|
||||||
|
|||||||
2
.github/workflows/claude-code-review.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
|
|||||||
2
.github/workflows/claude-translator.yml
vendored
@@ -29,7 +29,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
|
|||||||
2
.github/workflows/claude.yml
vendored
@@ -37,7 +37,7 @@ jobs:
|
|||||||
actions: read # Required for Claude to read CI results on PRs
|
actions: read # Required for Claude to read CI results on PRs
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
|
|||||||
2
.github/workflows/delete-branch.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
|||||||
if: github.event.pull_request.merged == true && github.event.pull_request.head.repo.full_name == github.repository
|
if: github.event.pull_request.merged == true && github.event.pull_request.head.repo.full_name == github.repository
|
||||||
steps:
|
steps:
|
||||||
- name: Delete merged branch
|
- name: Delete merged branch
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@v8
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
github.rest.git.deleteRef({
|
github.rest.git.deleteRef({
|
||||||
|
|||||||
26
.github/workflows/nightly-build.yml
vendored
@@ -56,7 +56,7 @@ jobs:
|
|||||||
ref: main
|
ref: main
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
|
|
||||||
@@ -99,9 +99,9 @@ jobs:
|
|||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
NODE_OPTIONS: --max-old-space-size=8192
|
NODE_OPTIONS: --max-old-space-size=8192
|
||||||
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
|
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
|
||||||
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
|
MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }}
|
||||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
|
RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||||
|
|
||||||
- name: Build Mac
|
- name: Build Mac
|
||||||
if: matrix.os == 'macos-latest'
|
if: matrix.os == 'macos-latest'
|
||||||
@@ -110,15 +110,15 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||||
APPLE_ID: ${{ vars.APPLE_ID }}
|
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ vars.APPLE_APP_SPECIFIC_PASSWORD }}
|
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||||
APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
NODE_OPTIONS: --max-old-space-size=8192
|
NODE_OPTIONS: --max-old-space-size=8192
|
||||||
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
|
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
|
||||||
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
|
MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }}
|
||||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
|
RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||||
|
|
||||||
- name: Build Windows
|
- name: Build Windows
|
||||||
if: matrix.os == 'windows-latest'
|
if: matrix.os == 'windows-latest'
|
||||||
@@ -128,9 +128,9 @@ jobs:
|
|||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
NODE_OPTIONS: --max-old-space-size=8192
|
NODE_OPTIONS: --max-old-space-size=8192
|
||||||
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
|
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
|
||||||
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
|
MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }}
|
||||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
|
RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||||
|
|
||||||
- name: Rename artifacts with nightly format
|
- name: Rename artifacts with nightly format
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|||||||
2
.github/workflows/pr-ci.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
|||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
|
|
||||||
|
|||||||
26
.github/workflows/release.yml
vendored
@@ -47,7 +47,7 @@ jobs:
|
|||||||
npm version "$VERSION" --no-git-tag-version --allow-same-version
|
npm version "$VERSION" --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
|
|
||||||
@@ -86,9 +86,9 @@ jobs:
|
|||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
NODE_OPTIONS: --max-old-space-size=8192
|
NODE_OPTIONS: --max-old-space-size=8192
|
||||||
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
|
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
|
||||||
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
|
MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }}
|
||||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
|
RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||||
|
|
||||||
- name: Build Mac
|
- name: Build Mac
|
||||||
if: matrix.os == 'macos-latest'
|
if: matrix.os == 'macos-latest'
|
||||||
@@ -98,15 +98,15 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||||
APPLE_ID: ${{ vars.APPLE_ID }}
|
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ vars.APPLE_APP_SPECIFIC_PASSWORD }}
|
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||||
APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
NODE_OPTIONS: --max-old-space-size=8192
|
NODE_OPTIONS: --max-old-space-size=8192
|
||||||
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
|
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
|
||||||
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
|
MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }}
|
||||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
|
RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||||
|
|
||||||
- name: Build Windows
|
- name: Build Windows
|
||||||
if: matrix.os == 'windows-latest'
|
if: matrix.os == 'windows-latest'
|
||||||
@@ -116,9 +116,9 @@ jobs:
|
|||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
NODE_OPTIONS: --max-old-space-size=8192
|
NODE_OPTIONS: --max-old-space-size=8192
|
||||||
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
|
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
|
||||||
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
|
MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }}
|
||||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
|
RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||||
|
|
||||||
- name: Release
|
- name: Release
|
||||||
uses: ncipollo/release-action@v1
|
uses: ncipollo/release-action@v1
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
diff --git a/dist/index.mjs b/dist/index.mjs
|
|
||||||
index 110f37ec18c98b1d55ae2b73cc716194e6f9094d..91d0f336b318833c6cee9599fe91370c0ff75323 100644
|
|
||||||
--- a/dist/index.mjs
|
|
||||||
+++ b/dist/index.mjs
|
|
||||||
@@ -447,7 +447,10 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
|
|
||||||
}
|
|
||||||
|
|
||||||
// src/get-model-path.ts
|
|
||||||
-function getModelPath(modelId) {
|
|
||||||
+function getModelPath(modelId, baseURL) {
|
|
||||||
+ if (baseURL?.includes('cherryin')) {
|
|
||||||
+ return `models/${modelId}`;
|
|
||||||
+ }
|
|
||||||
return modelId.includes("/") ? modelId : `models/${modelId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -856,7 +859,8 @@ var GoogleGenerativeAILanguageModel = class {
|
|
||||||
rawValue: rawResponse
|
|
||||||
} = await postJsonToApi2({
|
|
||||||
url: `${this.config.baseURL}/${getModelPath(
|
|
||||||
- this.modelId
|
|
||||||
+ this.modelId,
|
|
||||||
+ this.config.baseURL
|
|
||||||
)}:generateContent`,
|
|
||||||
headers: mergedHeaders,
|
|
||||||
body: args,
|
|
||||||
@@ -962,7 +966,8 @@ var GoogleGenerativeAILanguageModel = class {
|
|
||||||
);
|
|
||||||
const { responseHeaders, value: response } = await postJsonToApi2({
|
|
||||||
url: `${this.config.baseURL}/${getModelPath(
|
|
||||||
- this.modelId
|
|
||||||
+ this.modelId,
|
|
||||||
+ this.config.baseURL
|
|
||||||
)}:streamGenerateContent?alt=sse`,
|
|
||||||
headers,
|
|
||||||
body: args,
|
|
||||||
13
.yarn/patches/@ai-sdk-google-npm-2.0.17-fd88491de4.patch
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
diff --git a/dist/index.mjs b/dist/index.mjs
|
||||||
|
index b957cb824faa79cf01ba3a504f221870bd8e306a..4d71d30f655775d61537d9d8b73f6e17d41fa67e 100644
|
||||||
|
--- a/dist/index.mjs
|
||||||
|
+++ b/dist/index.mjs
|
||||||
|
@@ -452,7 +452,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
|
||||||
@@ -125,59 +125,17 @@ afterSign: scripts/notarize.js
|
|||||||
artifactBuildCompleted: scripts/artifact-build-completed.js
|
artifactBuildCompleted: scripts/artifact-build-completed.js
|
||||||
releaseInfo:
|
releaseInfo:
|
||||||
releaseNotes: |
|
releaseNotes: |
|
||||||
<!--LANG:en-->
|
What's New in v1.6.4
|
||||||
🚀 New Features:
|
|
||||||
- Refactored AI core engine for more efficient and stable content generation
|
|
||||||
- Added support for multiple AI model providers: CherryIN, AiOnly
|
|
||||||
- Added API server functionality for external application integration
|
|
||||||
- Added PaddleOCR document recognition for enhanced document processing
|
|
||||||
- Added Anthropic OAuth authentication support
|
|
||||||
- Added data storage space limit notifications
|
|
||||||
- Added font settings for global and code fonts customization
|
|
||||||
- Added auto-copy feature after translation completion
|
|
||||||
- Added keyboard shortcuts: rename topic, edit last message, etc.
|
|
||||||
- Added text attachment preview for viewing file contents in messages
|
|
||||||
- Added custom window control buttons (minimize, maximize, close)
|
|
||||||
- Support for Qwen long-text (qwen-long) and document analysis (qwen-doc) models with native file uploads
|
|
||||||
- Support for Qwen image recognition models (Qwen-Image)
|
|
||||||
- Added iFlow CLI support
|
|
||||||
- Converted knowledge base and web search to tool-calling approach for better flexibility
|
|
||||||
|
|
||||||
🎨 UI Improvements & Bug Fixes:
|
Features:
|
||||||
- Integrated HeroUI and Tailwind CSS framework
|
- Providers: add CherryIN provider
|
||||||
- Optimized message notification styles with unified toast component
|
- Notes: Add right-click context menu to create notes and folders
|
||||||
- Moved free models to bottom with fixed position for easier access
|
- Mini App: Add search functionality in mini app page
|
||||||
- Refactored quick panel and input bar tools for smoother operation
|
- Update Dialog: Add updating dialog in renderer process
|
||||||
- Optimized responsive design for navbar and sidebar
|
- Mini App: Remove some mini apps
|
||||||
- Improved scrollbar component with horizontal scrolling support
|
|
||||||
- Fixed multiple translation issues: paste handling, file processing, state management
|
|
||||||
- Various UI optimizations and bug fixes
|
|
||||||
<!--LANG:zh-CN-->
|
|
||||||
🚀 新功能:
|
|
||||||
- 重构 AI 核心引擎,提供更高效稳定的内容生成
|
|
||||||
- 新增多个 AI 模型提供商支持:CherryIN、AiOnly
|
|
||||||
- 新增 API 服务器功能,支持外部应用集成
|
|
||||||
- 新增 PaddleOCR 文档识别,增强文档处理能力
|
|
||||||
- 新增 Anthropic OAuth 认证支持
|
|
||||||
- 新增数据存储空间限制提醒
|
|
||||||
- 新增字体设置,支持全局字体和代码字体自定义
|
|
||||||
- 新增翻译完成后自动复制功能
|
|
||||||
- 新增键盘快捷键:重命名主题、编辑最后一条消息等
|
|
||||||
- 新增文本附件预览,可查看消息中的文件内容
|
|
||||||
- 新增自定义窗口控制按钮(最小化、最大化、关闭)
|
|
||||||
- 支持通义千问长文本(qwen-long)和文档分析(qwen-doc)模型,原生文件上传
|
|
||||||
- 支持通义千问图像识别模型(Qwen-Image)
|
|
||||||
- 新增 iFlow CLI 支持
|
|
||||||
- 知识库和网页搜索转换为工具调用方式,提升灵活性
|
|
||||||
|
|
||||||
🎨 界面改进与问题修复:
|
Bug Fixes:
|
||||||
- 集成 HeroUI 和 Tailwind CSS 框架
|
- Fix reasoning block insertion order - now inserts before content block
|
||||||
- 优化消息通知样式,统一 toast 组件
|
- Fix knowledge base deletion and web search RAG errors
|
||||||
- 免费模型移至底部固定位置,便于访问
|
- Fix Qwen model URL configuration
|
||||||
- 重构快捷面板和输入栏工具,操作更流畅
|
|
||||||
- 优化导航栏和侧边栏响应式设计
|
|
||||||
- 改进滚动条组件,支持水平滚动
|
|
||||||
- 修复多个翻译问题:粘贴处理、文件处理、状态管理
|
|
||||||
- 各种界面优化和问题修复
|
|
||||||
<!--LANG:END-->
|
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ export default defineConfig({
|
|||||||
output: {
|
output: {
|
||||||
manualChunks: undefined, // 彻底禁用代码分割 - 返回 null 强制单文件打包
|
manualChunks: undefined, // 彻底禁用代码分割 - 返回 null 强制单文件打包
|
||||||
inlineDynamicImports: true // 内联所有动态导入,这是关键配置
|
inlineDynamicImports: true // 内联所有动态导入,这是关键配置
|
||||||
|
},
|
||||||
|
onwarn(warning, warn) {
|
||||||
|
if (warning.code === 'COMMONJS_VARIABLE_IN_ESM') return
|
||||||
|
warn(warning)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
sourcemap: isDev
|
sourcemap: isDev
|
||||||
@@ -111,6 +115,10 @@ export default defineConfig({
|
|||||||
selectionToolbar: resolve(__dirname, 'src/renderer/selectionToolbar.html'),
|
selectionToolbar: resolve(__dirname, 'src/renderer/selectionToolbar.html'),
|
||||||
selectionAction: resolve(__dirname, 'src/renderer/selectionAction.html'),
|
selectionAction: resolve(__dirname, 'src/renderer/selectionAction.html'),
|
||||||
traceWindow: resolve(__dirname, 'src/renderer/traceWindow.html')
|
traceWindow: resolve(__dirname, 'src/renderer/traceWindow.html')
|
||||||
|
},
|
||||||
|
onwarn(warning, warn) {
|
||||||
|
if (warning.code === 'COMMONJS_VARIABLE_IN_ESM') return
|
||||||
|
warn(warning)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
16
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "CherryStudio",
|
"name": "CherryStudio",
|
||||||
"version": "1.6.1",
|
"version": "1.6.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "A powerful AI assistant for producer.",
|
"description": "A powerful AI assistant for producer.",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
@@ -97,10 +97,10 @@
|
|||||||
"@agentic/exa": "^7.3.3",
|
"@agentic/exa": "^7.3.3",
|
||||||
"@agentic/searxng": "^7.3.3",
|
"@agentic/searxng": "^7.3.3",
|
||||||
"@agentic/tavily": "^7.3.3",
|
"@agentic/tavily": "^7.3.3",
|
||||||
"@ai-sdk/amazon-bedrock": "^3.0.21",
|
"@ai-sdk/amazon-bedrock": "^3.0.29",
|
||||||
"@ai-sdk/google-vertex": "^3.0.27",
|
"@ai-sdk/google-vertex": "^3.0.33",
|
||||||
"@ai-sdk/mistral": "^2.0.14",
|
"@ai-sdk/mistral": "^2.0.17",
|
||||||
"@ai-sdk/perplexity": "^2.0.9",
|
"@ai-sdk/perplexity": "^2.0.11",
|
||||||
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
||||||
"@anthropic-ai/sdk": "^0.41.0",
|
"@anthropic-ai/sdk": "^0.41.0",
|
||||||
"@anthropic-ai/vertex-sdk": "patch:@anthropic-ai/vertex-sdk@npm%3A0.11.4#~/.yarn/patches/@anthropic-ai-vertex-sdk-npm-0.11.4-c19cb41edb.patch",
|
"@anthropic-ai/vertex-sdk": "patch:@anthropic-ai/vertex-sdk@npm%3A0.11.4#~/.yarn/patches/@anthropic-ai-vertex-sdk-npm-0.11.4-c19cb41edb.patch",
|
||||||
@@ -215,7 +215,7 @@
|
|||||||
"@viz-js/lang-dot": "^1.0.5",
|
"@viz-js/lang-dot": "^1.0.5",
|
||||||
"@viz-js/viz": "^3.14.0",
|
"@viz-js/viz": "^3.14.0",
|
||||||
"@xyflow/react": "^12.4.4",
|
"@xyflow/react": "^12.4.4",
|
||||||
"ai": "^5.0.44",
|
"ai": "^5.0.59",
|
||||||
"antd": "patch:antd@npm%3A5.27.0#~/.yarn/patches/antd-npm-5.27.0-aa91c36546.patch",
|
"antd": "patch:antd@npm%3A5.27.0#~/.yarn/patches/antd-npm-5.27.0-aa91c36546.patch",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"async-mutex": "^0.5.0",
|
"async-mutex": "^0.5.0",
|
||||||
@@ -238,7 +238,7 @@
|
|||||||
"docx": "^9.0.2",
|
"docx": "^9.0.2",
|
||||||
"dompurify": "^3.2.6",
|
"dompurify": "^3.2.6",
|
||||||
"dotenv-cli": "^7.4.2",
|
"dotenv-cli": "^7.4.2",
|
||||||
"electron": "37.4.0",
|
"electron": "37.6.0",
|
||||||
"electron-builder": "26.0.15",
|
"electron-builder": "26.0.15",
|
||||||
"electron-devtools-installer": "^3.2.0",
|
"electron-devtools-installer": "^3.2.0",
|
||||||
"electron-store": "^8.2.0",
|
"electron-store": "^8.2.0",
|
||||||
@@ -369,7 +369,7 @@
|
|||||||
"undici": "6.21.2",
|
"undici": "6.21.2",
|
||||||
"vite": "npm:rolldown-vite@latest",
|
"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"
|
"@ai-sdk/google@npm:2.0.17": "patch:@ai-sdk/google@npm%3A2.0.17#~/.yarn/patches/@ai-sdk-google-npm-2.0.17-fd88491de4.patch"
|
||||||
},
|
},
|
||||||
"packageManager": "yarn@4.9.1",
|
"packageManager": "yarn@4.9.1",
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@cherrystudio/ai-core",
|
"name": "@cherrystudio/ai-core",
|
||||||
"version": "1.0.0-alpha.18",
|
"version": "1.0.1",
|
||||||
"description": "Cherry Studio AI Core - Unified AI Provider Interface Based on Vercel AI SDK",
|
"description": "Cherry Studio AI Core - Unified AI Provider Interface Based on Vercel AI SDK",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"module": "dist/index.mjs",
|
"module": "dist/index.mjs",
|
||||||
@@ -36,15 +36,14 @@
|
|||||||
"ai": "^5.0.26"
|
"ai": "^5.0.26"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "^2.0.17",
|
"@ai-sdk/anthropic": "^2.0.22",
|
||||||
"@ai-sdk/azure": "^2.0.30",
|
"@ai-sdk/azure": "^2.0.42",
|
||||||
"@ai-sdk/deepseek": "^1.0.17",
|
"@ai-sdk/deepseek": "^1.0.20",
|
||||||
"@ai-sdk/google": "patch:@ai-sdk/google@npm%3A2.0.14#~/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch",
|
"@ai-sdk/openai": "^2.0.42",
|
||||||
"@ai-sdk/openai": "^2.0.30",
|
"@ai-sdk/openai-compatible": "^1.0.19",
|
||||||
"@ai-sdk/openai-compatible": "^1.0.17",
|
|
||||||
"@ai-sdk/provider": "^2.0.0",
|
"@ai-sdk/provider": "^2.0.0",
|
||||||
"@ai-sdk/provider-utils": "^3.0.9",
|
"@ai-sdk/provider-utils": "^3.0.10",
|
||||||
"@ai-sdk/xai": "^2.0.18",
|
"@ai-sdk/xai": "^2.0.23",
|
||||||
"zod": "^4.1.5"
|
"zod": "^4.1.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -261,22 +261,39 @@ export const createPromptToolUsePlugin = (config: PromptToolUseConfig = {}) => {
|
|||||||
return params
|
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 userSystemPrompt = typeof params.system === 'string' ? params.system : ''
|
||||||
const systemPrompt = buildSystemPrompt(userSystemPrompt, params.tools)
|
const systemPrompt = buildSystemPrompt(userSystemPrompt, promptTools)
|
||||||
let systemMessage: string | null = systemPrompt
|
let systemMessage: string | null = systemPrompt
|
||||||
if (config.createSystemMessage) {
|
if (config.createSystemMessage) {
|
||||||
// 🎯 如果用户提供了自定义处理函数,使用它
|
// 🎯 如果用户提供了自定义处理函数,使用它
|
||||||
systemMessage = config.createSystemMessage(systemPrompt, params, context)
|
systemMessage = config.createSystemMessage(systemPrompt, params, context)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 移除 tools,改为 prompt 模式
|
// 保留 provider-defined tools,移除其他 tools
|
||||||
const transformedParams = {
|
const transformedParams = {
|
||||||
...params,
|
...params,
|
||||||
...(systemMessage ? { system: systemMessage } : {}),
|
...(systemMessage ? { system: systemMessage } : {}),
|
||||||
tools: undefined
|
tools: Object.keys(providerDefinedTools).length > 0 ? providerDefinedTools : undefined
|
||||||
}
|
}
|
||||||
context.originalParams = transformedParams
|
context.originalParams = transformedParams
|
||||||
return transformedParams
|
return transformedParams
|
||||||
@@ -285,8 +302,9 @@ export const createPromptToolUsePlugin = (config: PromptToolUseConfig = {}) => {
|
|||||||
let textBuffer = ''
|
let textBuffer = ''
|
||||||
// let stepId = ''
|
// let stepId = ''
|
||||||
|
|
||||||
|
// 如果没有需要 prompt 模式处理的工具,直接返回原始流
|
||||||
if (!context.mcpTools) {
|
if (!context.mcpTools) {
|
||||||
throw new Error('No tools available')
|
return new TransformStream()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从 context 中获取或初始化 usage 累加器
|
// 从 context 中获取或初始化 usage 累加器
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { anthropic } from '@ai-sdk/anthropic'
|
import { anthropic } from '@ai-sdk/anthropic'
|
||||||
import { google } from '@ai-sdk/google'
|
import { google } from '@ai-sdk/google'
|
||||||
import { openai } from '@ai-sdk/openai'
|
import { openai } from '@ai-sdk/openai'
|
||||||
|
import { InferToolInput, InferToolOutput } from 'ai'
|
||||||
|
|
||||||
import { ProviderOptionsMap } from '../../../options/types'
|
import { ProviderOptionsMap } from '../../../options/types'
|
||||||
import { OpenRouterSearchConfig } from './openrouter'
|
import { OpenRouterSearchConfig } from './openrouter'
|
||||||
@@ -58,24 +59,31 @@ export const DEFAULT_WEB_SEARCH_CONFIG: WebSearchPluginConfig = {
|
|||||||
|
|
||||||
export type WebSearchToolOutputSchema = {
|
export type WebSearchToolOutputSchema = {
|
||||||
// Anthropic 工具 - 手动定义
|
// Anthropic 工具 - 手动定义
|
||||||
anthropicWebSearch: Array<{
|
anthropic: InferToolOutput<ReturnType<typeof anthropic.tools.webSearch_20250305>>
|
||||||
url: string
|
|
||||||
title: string
|
|
||||||
pageAge: string | null
|
|
||||||
encryptedContent: string
|
|
||||||
type: string
|
|
||||||
}>
|
|
||||||
|
|
||||||
// OpenAI 工具 - 基于实际输出
|
// OpenAI 工具 - 基于实际输出
|
||||||
openaiWebSearch: {
|
// TODO: 上游定义不规范,是unknown
|
||||||
|
// openai: InferToolOutput<ReturnType<typeof openai.tools.webSearch>>
|
||||||
|
openai: {
|
||||||
|
status: 'completed' | 'failed'
|
||||||
|
}
|
||||||
|
'openai-chat': {
|
||||||
status: 'completed' | 'failed'
|
status: 'completed' | 'failed'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Google 工具
|
// Google 工具
|
||||||
googleSearch: {
|
// TODO: 上游定义不规范,是unknown
|
||||||
|
// google: InferToolOutput<ReturnType<typeof google.tools.googleSearch>>
|
||||||
|
google: {
|
||||||
webSearchQueries?: string[]
|
webSearchQueries?: string[]
|
||||||
groundingChunks?: Array<{
|
groundingChunks?: Array<{
|
||||||
web?: { uri: string; title: string }
|
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>>
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ export enum IpcChannel {
|
|||||||
App_SetLanguage = 'app:set-language',
|
App_SetLanguage = 'app:set-language',
|
||||||
App_SetEnableSpellCheck = 'app:set-enable-spell-check',
|
App_SetEnableSpellCheck = 'app:set-enable-spell-check',
|
||||||
App_SetSpellCheckLanguages = 'app:set-spell-check-languages',
|
App_SetSpellCheckLanguages = 'app:set-spell-check-languages',
|
||||||
App_ShowUpdateDialog = 'app:show-update-dialog',
|
|
||||||
App_CheckForUpdate = 'app:check-for-update',
|
App_CheckForUpdate = 'app:check-for-update',
|
||||||
|
App_QuitAndInstall = 'app:quit-and-install',
|
||||||
App_Reload = 'app:reload',
|
App_Reload = 'app:reload',
|
||||||
App_Quit = 'app:quit',
|
App_Quit = 'app:quit',
|
||||||
App_Info = 'app:info',
|
App_Info = 'app:info',
|
||||||
@@ -34,6 +34,7 @@ export enum IpcChannel {
|
|||||||
App_GetBinaryPath = 'app:get-binary-path',
|
App_GetBinaryPath = 'app:get-binary-path',
|
||||||
App_InstallUvBinary = 'app:install-uv-binary',
|
App_InstallUvBinary = 'app:install-uv-binary',
|
||||||
App_InstallBunBinary = 'app:install-bun-binary',
|
App_InstallBunBinary = 'app:install-bun-binary',
|
||||||
|
App_InstallOvmsBinary = 'app:install-ovms-binary',
|
||||||
App_LogToMain = 'app:log-to-main',
|
App_LogToMain = 'app:log-to-main',
|
||||||
App_SaveData = 'app:save-data',
|
App_SaveData = 'app:save-data',
|
||||||
App_GetDiskInfo = 'app:get-disk-info',
|
App_GetDiskInfo = 'app:get-disk-info',
|
||||||
@@ -220,6 +221,7 @@ export enum IpcChannel {
|
|||||||
// system
|
// system
|
||||||
System_GetDeviceType = 'system:getDeviceType',
|
System_GetDeviceType = 'system:getDeviceType',
|
||||||
System_GetHostname = 'system:getHostname',
|
System_GetHostname = 'system:getHostname',
|
||||||
|
System_GetCpuName = 'system:getCpuName',
|
||||||
|
|
||||||
// DevTools
|
// DevTools
|
||||||
System_ToggleDevTools = 'system:toggleDevTools',
|
System_ToggleDevTools = 'system:toggleDevTools',
|
||||||
@@ -227,7 +229,6 @@ export enum IpcChannel {
|
|||||||
// events
|
// events
|
||||||
BackupProgress = 'backup-progress',
|
BackupProgress = 'backup-progress',
|
||||||
ThemeUpdated = 'theme:updated',
|
ThemeUpdated = 'theme:updated',
|
||||||
UpdateDownloadedCancelled = 'update-downloaded-cancelled',
|
|
||||||
RestoreProgress = 'restore-progress',
|
RestoreProgress = 'restore-progress',
|
||||||
UpdateError = 'update-error',
|
UpdateError = 'update-error',
|
||||||
UpdateAvailable = 'update-available',
|
UpdateAvailable = 'update-available',
|
||||||
@@ -330,6 +331,15 @@ export enum IpcChannel {
|
|||||||
// OCR
|
// OCR
|
||||||
OCR_ocr = 'ocr:ocr',
|
OCR_ocr = 'ocr:ocr',
|
||||||
|
|
||||||
|
// OVMS
|
||||||
|
Ovms_AddModel = 'ovms:add-model',
|
||||||
|
Ovms_StopAddModel = 'ovms:stop-addmodel',
|
||||||
|
Ovms_GetModels = 'ovms:get-models',
|
||||||
|
Ovms_IsRunning = 'ovms:is-running',
|
||||||
|
Ovms_GetStatus = 'ovms:get-status',
|
||||||
|
Ovms_RunOVMS = 'ovms:run-ovms',
|
||||||
|
Ovms_StopOVMS = 'ovms:stop-ovms',
|
||||||
|
|
||||||
// CherryAI
|
// CherryAI
|
||||||
Cherryai_GetSignature = 'cherryai:get-signature'
|
Cherryai_GetSignature = 'cherryai:get-signature'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -217,7 +217,8 @@ export enum codeTools {
|
|||||||
claudeCode = 'claude-code',
|
claudeCode = 'claude-code',
|
||||||
geminiCli = 'gemini-cli',
|
geminiCli = 'gemini-cli',
|
||||||
openaiCodex = 'openai-codex',
|
openaiCodex = 'openai-codex',
|
||||||
iFlowCli = 'iflow-cli'
|
iFlowCli = 'iflow-cli',
|
||||||
|
githubCopilotCli = 'github-copilot-cli'
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum terminalApps {
|
export enum terminalApps {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
const https = require('https')
|
const https = require('https')
|
||||||
const fs = require('fs')
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
const { execSync } = require('child_process')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Downloads a file from a URL with redirect handling
|
* Downloads a file from a URL with redirect handling
|
||||||
@@ -32,4 +34,39 @@ async function downloadWithRedirects(url, destinationPath) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { downloadWithRedirects }
|
/**
|
||||||
|
* Downloads a file using PowerShell Invoke-WebRequest command
|
||||||
|
* @param {string} url The URL to download from
|
||||||
|
* @param {string} destinationPath The path to save the file to
|
||||||
|
* @returns {Promise<boolean>} Promise that resolves to true if download succeeds
|
||||||
|
*/
|
||||||
|
async function downloadWithPowerShell(url, destinationPath) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
// Only support windows platform for PowerShell download
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
return reject(new Error('PowerShell download is only supported on Windows'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputDir = path.dirname(destinationPath)
|
||||||
|
fs.mkdirSync(outputDir, { recursive: true })
|
||||||
|
|
||||||
|
// PowerShell command to download the file with progress disabled for faster download
|
||||||
|
const psCommand = `powershell -Command "$ProgressPreference = 'SilentlyContinue'; Invoke-WebRequest '${url}' -OutFile '${destinationPath}'"`
|
||||||
|
|
||||||
|
console.log(`Downloading with PowerShell: ${url}`)
|
||||||
|
execSync(psCommand, { stdio: 'inherit' })
|
||||||
|
|
||||||
|
if (fs.existsSync(destinationPath)) {
|
||||||
|
console.log(`Download completed: ${destinationPath}`)
|
||||||
|
resolve(true)
|
||||||
|
} else {
|
||||||
|
reject(new Error('Download failed: File not found after download'))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
reject(new Error(`PowerShell download failed: ${error.message}`))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { downloadWithRedirects, downloadWithPowerShell }
|
||||||
|
|||||||
177
resources/scripts/install-ovms.js
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
const os = require('os')
|
||||||
|
const { execSync } = require('child_process')
|
||||||
|
const { downloadWithPowerShell } = require('./download')
|
||||||
|
|
||||||
|
// Base URL for downloading OVMS binaries
|
||||||
|
const OVMS_PKG_NAME = 'ovms250911.zip'
|
||||||
|
const OVMS_RELEASE_BASE_URL = [`https://gitcode.com/gcw_ggDjjkY3/kjfile/releases/download/download/${OVMS_PKG_NAME}`]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads and extracts the OVMS binary for the specified platform
|
||||||
|
*/
|
||||||
|
async function downloadOvmsBinary() {
|
||||||
|
// Create output directory structure - OVMS goes into its own subdirectory
|
||||||
|
const csDir = path.join(os.homedir(), '.cherrystudio')
|
||||||
|
|
||||||
|
// Ensure directories exist
|
||||||
|
fs.mkdirSync(csDir, { recursive: true })
|
||||||
|
|
||||||
|
const csOvmsDir = path.join(csDir, 'ovms')
|
||||||
|
// Delete existing OVMS directory if it exists
|
||||||
|
if (fs.existsSync(csOvmsDir)) {
|
||||||
|
fs.rmSync(csOvmsDir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempdir = os.tmpdir()
|
||||||
|
const tempFilename = path.join(tempdir, 'ovms.zip')
|
||||||
|
|
||||||
|
// Try each URL until one succeeds
|
||||||
|
let downloadSuccess = false
|
||||||
|
let lastError = null
|
||||||
|
|
||||||
|
for (let i = 0; i < OVMS_RELEASE_BASE_URL.length; i++) {
|
||||||
|
const downloadUrl = OVMS_RELEASE_BASE_URL[i]
|
||||||
|
console.log(`Attempting download from URL ${i + 1}/${OVMS_RELEASE_BASE_URL.length}: ${downloadUrl}`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`Downloading OVMS from ${downloadUrl} to ${tempFilename}...`)
|
||||||
|
|
||||||
|
// Try PowerShell download first, fallback to Node.js download if it fails
|
||||||
|
await downloadWithPowerShell(downloadUrl, tempFilename)
|
||||||
|
|
||||||
|
// If we get here, download was successful
|
||||||
|
downloadSuccess = true
|
||||||
|
console.log(`Successfully downloaded from: ${downloadUrl}`)
|
||||||
|
break
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Download failed from ${downloadUrl}: ${error.message}`)
|
||||||
|
lastError = error
|
||||||
|
|
||||||
|
// Clean up failed download file if it exists
|
||||||
|
if (fs.existsSync(tempFilename)) {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(tempFilename)
|
||||||
|
} catch (cleanupError) {
|
||||||
|
console.warn(`Failed to clean up temporary file: ${cleanupError.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continue to next URL if this one failed
|
||||||
|
if (i < OVMS_RELEASE_BASE_URL.length - 1) {
|
||||||
|
console.log(`Trying next URL...`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any download succeeded
|
||||||
|
if (!downloadSuccess) {
|
||||||
|
console.error(`All download URLs failed. Last error: ${lastError?.message || 'Unknown error'}`)
|
||||||
|
return 103
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`Extracting to ${csDir}...`)
|
||||||
|
|
||||||
|
// Use tar.exe to extract the ZIP file
|
||||||
|
console.log(`Extracting OVMS to ${csDir}...`)
|
||||||
|
execSync(`tar -xf ${tempFilename} -C ${csDir}`, { stdio: 'inherit' })
|
||||||
|
console.log(`OVMS extracted to ${csDir}`)
|
||||||
|
|
||||||
|
// Clean up temporary file
|
||||||
|
fs.unlinkSync(tempFilename)
|
||||||
|
console.log(`Installation directory: ${csDir}`)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error installing OVMS: ${error.message}`)
|
||||||
|
if (fs.existsSync(tempFilename)) {
|
||||||
|
fs.unlinkSync(tempFilename)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if ovmsDir is empty and remove it if so
|
||||||
|
try {
|
||||||
|
const ovmsDir = path.join(csDir, 'ovms')
|
||||||
|
const files = fs.readdirSync(ovmsDir)
|
||||||
|
if (files.length === 0) {
|
||||||
|
fs.rmSync(ovmsDir, { recursive: true })
|
||||||
|
console.log(`Removed empty directory: ${ovmsDir}`)
|
||||||
|
}
|
||||||
|
} catch (cleanupError) {
|
||||||
|
console.warn(`Warning: Failed to clean up directory: ${cleanupError.message}`)
|
||||||
|
return 105
|
||||||
|
}
|
||||||
|
|
||||||
|
return 104
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the CPU Name and ID
|
||||||
|
*/
|
||||||
|
function getCpuInfo() {
|
||||||
|
const cpuInfo = {
|
||||||
|
name: '',
|
||||||
|
id: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use PowerShell to get CPU information
|
||||||
|
try {
|
||||||
|
const psCommand = `powershell -Command "Get-CimInstance -ClassName Win32_Processor | Select-Object Name, DeviceID | ConvertTo-Json"`
|
||||||
|
const psOutput = execSync(psCommand).toString()
|
||||||
|
const cpuData = JSON.parse(psOutput)
|
||||||
|
|
||||||
|
if (Array.isArray(cpuData)) {
|
||||||
|
cpuInfo.name = cpuData[0].Name || ''
|
||||||
|
cpuInfo.id = cpuData[0].DeviceID || ''
|
||||||
|
} else {
|
||||||
|
cpuInfo.name = cpuData.Name || ''
|
||||||
|
cpuInfo.id = cpuData.DeviceID || ''
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to get CPU info: ${error.message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cpuInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main function to install OVMS
|
||||||
|
*/
|
||||||
|
async function installOvms() {
|
||||||
|
const platform = os.platform()
|
||||||
|
console.log(`Detected platform: ${platform}`)
|
||||||
|
|
||||||
|
const cpuName = getCpuInfo().name
|
||||||
|
console.log(`CPU Name: ${cpuName}`)
|
||||||
|
|
||||||
|
// Check if CPU name contains "Ultra"
|
||||||
|
if (!cpuName.toLowerCase().includes('intel') || !cpuName.toLowerCase().includes('ultra')) {
|
||||||
|
console.error('OVMS installation requires an Intel(R) Core(TM) Ultra CPU.')
|
||||||
|
return 101
|
||||||
|
}
|
||||||
|
|
||||||
|
// only support windows
|
||||||
|
if (platform !== 'win32') {
|
||||||
|
console.error('OVMS installation is only supported on Windows.')
|
||||||
|
return 102
|
||||||
|
}
|
||||||
|
|
||||||
|
return await downloadOvmsBinary()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the installation
|
||||||
|
installOvms()
|
||||||
|
.then((retcode) => {
|
||||||
|
if (retcode === 0) {
|
||||||
|
console.log('OVMS installation successful')
|
||||||
|
} else {
|
||||||
|
console.error('OVMS installation failed')
|
||||||
|
}
|
||||||
|
process.exit(retcode)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('OVMS installation failed:', error)
|
||||||
|
process.exit(100)
|
||||||
|
})
|
||||||
@@ -35,6 +35,7 @@ import NotificationService from './services/NotificationService'
|
|||||||
import * as NutstoreService from './services/NutstoreService'
|
import * as NutstoreService from './services/NutstoreService'
|
||||||
import ObsidianVaultService from './services/ObsidianVaultService'
|
import ObsidianVaultService from './services/ObsidianVaultService'
|
||||||
import { ocrService } from './services/ocr/OcrService'
|
import { ocrService } from './services/ocr/OcrService'
|
||||||
|
import OvmsManager from './services/OvmsManager'
|
||||||
import { proxyManager } from './services/ProxyManager'
|
import { proxyManager } from './services/ProxyManager'
|
||||||
import { pythonService } from './services/PythonService'
|
import { pythonService } from './services/PythonService'
|
||||||
import { FileServiceManager } from './services/remotefile/FileServiceManager'
|
import { FileServiceManager } from './services/remotefile/FileServiceManager'
|
||||||
@@ -81,6 +82,7 @@ const obsidianVaultService = new ObsidianVaultService()
|
|||||||
const vertexAIService = VertexAIService.getInstance()
|
const vertexAIService = VertexAIService.getInstance()
|
||||||
const memoryService = MemoryService.getInstance()
|
const memoryService = MemoryService.getInstance()
|
||||||
const dxtService = new DxtService()
|
const dxtService = new DxtService()
|
||||||
|
const ovmsManager = new OvmsManager()
|
||||||
|
|
||||||
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||||
const appUpdater = new AppUpdater()
|
const appUpdater = new AppUpdater()
|
||||||
@@ -130,7 +132,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
|||||||
ipcMain.handle(IpcChannel.Open_Website, (_, url: string) => shell.openExternal(url))
|
ipcMain.handle(IpcChannel.Open_Website, (_, url: string) => shell.openExternal(url))
|
||||||
|
|
||||||
// Update
|
// Update
|
||||||
ipcMain.handle(IpcChannel.App_ShowUpdateDialog, () => appUpdater.showUpdateDialog(mainWindow))
|
ipcMain.handle(IpcChannel.App_QuitAndInstall, () => appUpdater.quitAndInstall())
|
||||||
|
|
||||||
// language
|
// language
|
||||||
ipcMain.handle(IpcChannel.App_SetLanguage, (_, language) => {
|
ipcMain.handle(IpcChannel.App_SetLanguage, (_, language) => {
|
||||||
@@ -432,6 +434,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
|||||||
// system
|
// system
|
||||||
ipcMain.handle(IpcChannel.System_GetDeviceType, () => (isMac ? 'mac' : isWin ? 'windows' : 'linux'))
|
ipcMain.handle(IpcChannel.System_GetDeviceType, () => (isMac ? 'mac' : isWin ? 'windows' : 'linux'))
|
||||||
ipcMain.handle(IpcChannel.System_GetHostname, () => require('os').hostname())
|
ipcMain.handle(IpcChannel.System_GetHostname, () => require('os').hostname())
|
||||||
|
ipcMain.handle(IpcChannel.System_GetCpuName, () => require('os').cpus()[0].model)
|
||||||
ipcMain.handle(IpcChannel.System_ToggleDevTools, (e) => {
|
ipcMain.handle(IpcChannel.System_ToggleDevTools, (e) => {
|
||||||
const win = BrowserWindow.fromWebContents(e.sender)
|
const win = BrowserWindow.fromWebContents(e.sender)
|
||||||
win && win.webContents.toggleDevTools()
|
win && win.webContents.toggleDevTools()
|
||||||
@@ -710,6 +713,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
|||||||
ipcMain.handle(IpcChannel.App_GetBinaryPath, (_, name: string) => getBinaryPath(name))
|
ipcMain.handle(IpcChannel.App_GetBinaryPath, (_, name: string) => getBinaryPath(name))
|
||||||
ipcMain.handle(IpcChannel.App_InstallUvBinary, () => runInstallScript('install-uv.js'))
|
ipcMain.handle(IpcChannel.App_InstallUvBinary, () => runInstallScript('install-uv.js'))
|
||||||
ipcMain.handle(IpcChannel.App_InstallBunBinary, () => runInstallScript('install-bun.js'))
|
ipcMain.handle(IpcChannel.App_InstallBunBinary, () => runInstallScript('install-bun.js'))
|
||||||
|
ipcMain.handle(IpcChannel.App_InstallOvmsBinary, () => runInstallScript('install-ovms.js'))
|
||||||
|
|
||||||
//copilot
|
//copilot
|
||||||
ipcMain.handle(IpcChannel.Copilot_GetAuthMessage, CopilotService.getAuthMessage.bind(CopilotService))
|
ipcMain.handle(IpcChannel.Copilot_GetAuthMessage, CopilotService.getAuthMessage.bind(CopilotService))
|
||||||
@@ -841,6 +845,17 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
|||||||
ocrService.ocr(file, provider)
|
ocrService.ocr(file, provider)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// OVMS
|
||||||
|
ipcMain.handle(IpcChannel.Ovms_AddModel, (_, modelName: string, modelId: string, modelSource: string, task: string) =>
|
||||||
|
ovmsManager.addModel(modelName, modelId, modelSource, task)
|
||||||
|
)
|
||||||
|
ipcMain.handle(IpcChannel.Ovms_StopAddModel, () => ovmsManager.stopAddModel())
|
||||||
|
ipcMain.handle(IpcChannel.Ovms_GetModels, () => ovmsManager.getModels())
|
||||||
|
ipcMain.handle(IpcChannel.Ovms_IsRunning, () => ovmsManager.initializeOvms())
|
||||||
|
ipcMain.handle(IpcChannel.Ovms_GetStatus, () => ovmsManager.getOvmsStatus())
|
||||||
|
ipcMain.handle(IpcChannel.Ovms_RunOVMS, () => ovmsManager.runOvms())
|
||||||
|
ipcMain.handle(IpcChannel.Ovms_StopOVMS, () => ovmsManager.stopOvms())
|
||||||
|
|
||||||
// CherryAI
|
// CherryAI
|
||||||
ipcMain.handle(IpcChannel.Cherryai_GetSignature, (_, params) => generateSignature(params))
|
ipcMain.handle(IpcChannel.Cherryai_GetSignature, (_, params) => generateSignature(params))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
import { isWin } from '@main/constant'
|
import { isWin } from '@main/constant'
|
||||||
import { getIpCountry } from '@main/utils/ipService'
|
import { getIpCountry } from '@main/utils/ipService'
|
||||||
import { locales } from '@main/utils/locales'
|
|
||||||
import { generateUserAgent } from '@main/utils/systemInfo'
|
import { generateUserAgent } from '@main/utils/systemInfo'
|
||||||
import { FeedUrl, UpgradeChannel } from '@shared/config/constant'
|
import { FeedUrl, UpgradeChannel } from '@shared/config/constant'
|
||||||
import { IpcChannel } from '@shared/IpcChannel'
|
import { IpcChannel } from '@shared/IpcChannel'
|
||||||
import { CancellationToken, UpdateInfo } from 'builder-util-runtime'
|
import { CancellationToken, UpdateInfo } from 'builder-util-runtime'
|
||||||
import { app, BrowserWindow, dialog, net } from 'electron'
|
import { app, net } from 'electron'
|
||||||
import { AppUpdater as _AppUpdater, autoUpdater, Logger, NsisUpdater, UpdateCheckResult } from 'electron-updater'
|
import { AppUpdater as _AppUpdater, autoUpdater, Logger, NsisUpdater, UpdateCheckResult } from 'electron-updater'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import semver from 'semver'
|
import semver from 'semver'
|
||||||
|
|
||||||
import icon from '../../../build/icon.png?asset'
|
|
||||||
import { configManager } from './ConfigManager'
|
import { configManager } from './ConfigManager'
|
||||||
import { windowService } from './WindowService'
|
import { windowService } from './WindowService'
|
||||||
|
|
||||||
@@ -26,7 +24,6 @@ const LANG_MARKERS = {
|
|||||||
|
|
||||||
export default class AppUpdater {
|
export default class AppUpdater {
|
||||||
autoUpdater: _AppUpdater = autoUpdater
|
autoUpdater: _AppUpdater = autoUpdater
|
||||||
private releaseInfo: UpdateInfo | undefined
|
|
||||||
private cancellationToken: CancellationToken = new CancellationToken()
|
private cancellationToken: CancellationToken = new CancellationToken()
|
||||||
private updateCheckResult: UpdateCheckResult | null = null
|
private updateCheckResult: UpdateCheckResult | null = null
|
||||||
|
|
||||||
@@ -66,7 +63,6 @@ export default class AppUpdater {
|
|||||||
autoUpdater.on('update-downloaded', (releaseInfo: UpdateInfo) => {
|
autoUpdater.on('update-downloaded', (releaseInfo: UpdateInfo) => {
|
||||||
const processedReleaseInfo = this.processReleaseInfo(releaseInfo)
|
const processedReleaseInfo = this.processReleaseInfo(releaseInfo)
|
||||||
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateDownloaded, processedReleaseInfo)
|
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateDownloaded, processedReleaseInfo)
|
||||||
this.releaseInfo = processedReleaseInfo
|
|
||||||
logger.info('update downloaded', processedReleaseInfo)
|
logger.info('update downloaded', processedReleaseInfo)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -247,37 +243,9 @@ export default class AppUpdater {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async showUpdateDialog(mainWindow: BrowserWindow) {
|
public quitAndInstall() {
|
||||||
if (!this.releaseInfo) {
|
app.isQuitting = true
|
||||||
return
|
setImmediate(() => autoUpdater.quitAndInstall())
|
||||||
}
|
|
||||||
const locale = locales[configManager.getLanguage()]
|
|
||||||
const { update: updateLocale } = locale.translation
|
|
||||||
|
|
||||||
let detail = this.formatReleaseNotes(this.releaseInfo.releaseNotes)
|
|
||||||
if (detail === '') {
|
|
||||||
detail = updateLocale.noReleaseNotes
|
|
||||||
}
|
|
||||||
|
|
||||||
dialog
|
|
||||||
.showMessageBox({
|
|
||||||
type: 'info',
|
|
||||||
title: updateLocale.title,
|
|
||||||
icon,
|
|
||||||
message: updateLocale.message.replace('{{version}}', this.releaseInfo.version),
|
|
||||||
detail,
|
|
||||||
buttons: [updateLocale.later, updateLocale.install],
|
|
||||||
defaultId: 1,
|
|
||||||
cancelId: 0
|
|
||||||
})
|
|
||||||
.then(({ response }) => {
|
|
||||||
if (response === 1) {
|
|
||||||
app.isQuitting = true
|
|
||||||
setImmediate(() => autoUpdater.quitAndInstall())
|
|
||||||
} else {
|
|
||||||
mainWindow.webContents.send(IpcChannel.UpdateDownloadedCancelled)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -349,38 +317,9 @@ export default class AppUpdater {
|
|||||||
|
|
||||||
return processedInfo
|
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
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(releaseNotes)) {
|
|
||||||
return releaseNotes.map((note) => note.note).join('\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
interface GithubReleaseInfo {
|
interface GithubReleaseInfo {
|
||||||
draft: boolean
|
draft: boolean
|
||||||
prerelease: boolean
|
prerelease: boolean
|
||||||
tag_name: string
|
tag_name: string
|
||||||
}
|
}
|
||||||
interface ReleaseNoteInfo {
|
|
||||||
readonly version: string
|
|
||||||
readonly note: string | null
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ interface VersionInfo {
|
|||||||
|
|
||||||
class CodeToolsService {
|
class CodeToolsService {
|
||||||
private versionCache: Map<string, { version: string; timestamp: number }> = new Map()
|
private versionCache: Map<string, { version: string; timestamp: number }> = new Map()
|
||||||
private terminalsCache: { terminals: TerminalConfig[]; timestamp: number } | null = null
|
private terminalsCache: {
|
||||||
|
terminals: TerminalConfig[]
|
||||||
|
timestamp: number
|
||||||
|
} | null = null
|
||||||
private customTerminalPaths: Map<string, string> = new Map() // Store user-configured terminal paths
|
private customTerminalPaths: Map<string, string> = new Map() // Store user-configured terminal paths
|
||||||
private readonly CACHE_DURATION = 1000 * 60 * 30 // 30 minutes cache
|
private readonly CACHE_DURATION = 1000 * 60 * 30 // 30 minutes cache
|
||||||
private readonly TERMINALS_CACHE_DURATION = 1000 * 60 * 5 // 5 minutes cache for terminals
|
private readonly TERMINALS_CACHE_DURATION = 1000 * 60 * 5 // 5 minutes cache for terminals
|
||||||
@@ -82,6 +85,8 @@ class CodeToolsService {
|
|||||||
return '@qwen-code/qwen-code'
|
return '@qwen-code/qwen-code'
|
||||||
case codeTools.iFlowCli:
|
case codeTools.iFlowCli:
|
||||||
return '@iflow-ai/iflow-cli'
|
return '@iflow-ai/iflow-cli'
|
||||||
|
case codeTools.githubCopilotCli:
|
||||||
|
return '@github/copilot'
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unsupported CLI tool: ${cliTool}`)
|
throw new Error(`Unsupported CLI tool: ${cliTool}`)
|
||||||
}
|
}
|
||||||
@@ -99,6 +104,8 @@ class CodeToolsService {
|
|||||||
return 'qwen'
|
return 'qwen'
|
||||||
case codeTools.iFlowCli:
|
case codeTools.iFlowCli:
|
||||||
return 'iflow'
|
return 'iflow'
|
||||||
|
case codeTools.githubCopilotCli:
|
||||||
|
return 'copilot'
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unsupported CLI tool: ${cliTool}`)
|
throw new Error(`Unsupported CLI tool: ${cliTool}`)
|
||||||
}
|
}
|
||||||
@@ -144,7 +151,9 @@ class CodeToolsService {
|
|||||||
case terminalApps.powershell:
|
case terminalApps.powershell:
|
||||||
// Check for PowerShell in PATH
|
// Check for PowerShell in PATH
|
||||||
try {
|
try {
|
||||||
await execAsync('powershell -Command "Get-Host"', { timeout: 3000 })
|
await execAsync('powershell -Command "Get-Host"', {
|
||||||
|
timeout: 3000
|
||||||
|
})
|
||||||
return terminal
|
return terminal
|
||||||
} catch {
|
} catch {
|
||||||
try {
|
try {
|
||||||
@@ -384,7 +393,9 @@ class CodeToolsService {
|
|||||||
const binDir = path.join(os.homedir(), '.cherrystudio', 'bin')
|
const binDir = path.join(os.homedir(), '.cherrystudio', 'bin')
|
||||||
const executablePath = path.join(binDir, executableName + (isWin ? '.exe' : ''))
|
const executablePath = path.join(binDir, executableName + (isWin ? '.exe' : ''))
|
||||||
|
|
||||||
const { stdout } = await execAsync(`"${executablePath}" --version`, { timeout: 10000 })
|
const { stdout } = await execAsync(`"${executablePath}" --version`, {
|
||||||
|
timeout: 10000
|
||||||
|
})
|
||||||
// Extract version number from output (format may vary by tool)
|
// Extract version number from output (format may vary by tool)
|
||||||
const versionMatch = stdout.trim().match(/\d+\.\d+\.\d+/)
|
const versionMatch = stdout.trim().match(/\d+\.\d+\.\d+/)
|
||||||
installedVersion = versionMatch ? versionMatch[0] : stdout.trim().split(' ')[0]
|
installedVersion = versionMatch ? versionMatch[0] : stdout.trim().split(' ')[0]
|
||||||
@@ -425,7 +436,10 @@ class CodeToolsService {
|
|||||||
logger.info(`${packageName} latest version: ${latestVersion}`)
|
logger.info(`${packageName} latest version: ${latestVersion}`)
|
||||||
|
|
||||||
// Cache the result
|
// Cache the result
|
||||||
this.versionCache.set(cacheKey, { version: latestVersion!, timestamp: now })
|
this.versionCache.set(cacheKey, {
|
||||||
|
version: latestVersion!,
|
||||||
|
timestamp: now
|
||||||
|
})
|
||||||
logger.debug(`Cached latest version for ${packageName}`)
|
logger.debug(`Cached latest version for ${packageName}`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(`Failed to get latest version for ${packageName}:`, error as Error)
|
logger.warn(`Failed to get latest version for ${packageName}:`, error as Error)
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import Reranker from '@main/knowledge/reranker/Reranker'
|
|||||||
import { fileStorage } from '@main/services/FileStorage'
|
import { fileStorage } from '@main/services/FileStorage'
|
||||||
import { windowService } from '@main/services/WindowService'
|
import { windowService } from '@main/services/WindowService'
|
||||||
import { getDataPath } from '@main/utils'
|
import { getDataPath } from '@main/utils'
|
||||||
import { getAllFiles } from '@main/utils/file'
|
import { getAllFiles, sanitizeFilename } from '@main/utils/file'
|
||||||
import { TraceMethod } from '@mcp-trace/trace-core'
|
import { TraceMethod } from '@mcp-trace/trace-core'
|
||||||
import { MB } from '@shared/config/constant'
|
import { MB } from '@shared/config/constant'
|
||||||
import type { LoaderReturn } from '@shared/config/types'
|
import type { LoaderReturn } from '@shared/config/types'
|
||||||
@@ -147,11 +147,16 @@ class KnowledgeService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getDbPath = (id: string): string => {
|
||||||
|
// 消除网络搜索requestI d中的特殊字符
|
||||||
|
return path.join(this.storageDir, sanitizeFilename(id, '_'))
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete knowledge base file
|
* Delete knowledge base file
|
||||||
*/
|
*/
|
||||||
private deleteKnowledgeFile = (id: string): boolean => {
|
private deleteKnowledgeFile = (id: string): boolean => {
|
||||||
const dbPath = path.join(this.storageDir, id)
|
const dbPath = this.getDbPath(id)
|
||||||
if (fs.existsSync(dbPath)) {
|
if (fs.existsSync(dbPath)) {
|
||||||
try {
|
try {
|
||||||
fs.rmSync(dbPath, { recursive: true })
|
fs.rmSync(dbPath, { recursive: true })
|
||||||
@@ -244,7 +249,8 @@ class KnowledgeService {
|
|||||||
dimensions
|
dimensions
|
||||||
})
|
})
|
||||||
try {
|
try {
|
||||||
const libSqlDb = new LibSqlDb({ path: path.join(this.storageDir, id) })
|
const dbPath = this.getDbPath(id)
|
||||||
|
const libSqlDb = new LibSqlDb({ path: dbPath })
|
||||||
// Save database instance for later closing
|
// Save database instance for later closing
|
||||||
this.dbInstances.set(id, libSqlDb)
|
this.dbInstances.set(id, libSqlDb)
|
||||||
|
|
||||||
|
|||||||
586
src/main/services/OvmsManager.ts
Normal file
@@ -0,0 +1,586 @@
|
|||||||
|
import { exec } from 'node:child_process'
|
||||||
|
import { homedir } from 'node:os'
|
||||||
|
import { promisify } from 'node:util'
|
||||||
|
|
||||||
|
import { loggerService } from '@logger'
|
||||||
|
import * as fs from 'fs-extra'
|
||||||
|
import * as path from 'path'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('OvmsManager')
|
||||||
|
|
||||||
|
const execAsync = promisify(exec)
|
||||||
|
|
||||||
|
interface OvmsProcess {
|
||||||
|
pid: number
|
||||||
|
path: string
|
||||||
|
workingDirectory: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModelConfig {
|
||||||
|
name: string
|
||||||
|
base_path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OvmsConfig {
|
||||||
|
mediapipe_config_list: ModelConfig[]
|
||||||
|
}
|
||||||
|
|
||||||
|
class OvmsManager {
|
||||||
|
private ovms: OvmsProcess | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively terminate a process and all its child processes
|
||||||
|
* @param pid Process ID to terminate
|
||||||
|
* @returns Promise<{ success: boolean; message?: string }>
|
||||||
|
*/
|
||||||
|
private async terminalProcess(pid: number): Promise<{ success: boolean; message?: string }> {
|
||||||
|
try {
|
||||||
|
// Check if the process is running
|
||||||
|
const processCheckCommand = `Get-Process -Id ${pid} -ErrorAction SilentlyContinue | Select-Object Id | ConvertTo-Json`
|
||||||
|
const { stdout: processStdout } = await execAsync(`powershell -Command "${processCheckCommand}"`)
|
||||||
|
|
||||||
|
if (!processStdout.trim()) {
|
||||||
|
logger.info(`Process with PID ${pid} is not running`)
|
||||||
|
return { success: true, message: `Process with PID ${pid} is not running` }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find child processes
|
||||||
|
const childProcessCommand = `Get-WmiObject -Class Win32_Process | Where-Object { $_.ParentProcessId -eq ${pid} } | Select-Object ProcessId | ConvertTo-Json`
|
||||||
|
const { stdout: childStdout } = await execAsync(`powershell -Command "${childProcessCommand}"`)
|
||||||
|
|
||||||
|
// If there are child processes, terminate them first
|
||||||
|
if (childStdout.trim()) {
|
||||||
|
const childProcesses = JSON.parse(childStdout)
|
||||||
|
const childList = Array.isArray(childProcesses) ? childProcesses : [childProcesses]
|
||||||
|
|
||||||
|
logger.info(`Found ${childList.length} child processes for PID ${pid}`)
|
||||||
|
|
||||||
|
// Recursively terminate each child process
|
||||||
|
for (const childProcess of childList) {
|
||||||
|
const childPid = childProcess.ProcessId
|
||||||
|
logger.info(`Terminating child process PID: ${childPid}`)
|
||||||
|
await this.terminalProcess(childPid)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.info(`No child processes found for PID ${pid}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finally, terminate the parent process
|
||||||
|
const killCommand = `Stop-Process -Id ${pid} -Force -ErrorAction SilentlyContinue`
|
||||||
|
await execAsync(`powershell -Command "${killCommand}"`)
|
||||||
|
logger.info(`Terminated process with PID: ${pid}`)
|
||||||
|
|
||||||
|
// Wait for the process to disappear with 5-second timeout
|
||||||
|
const timeout = 5000 // 5 seconds
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
while (Date.now() - startTime < timeout) {
|
||||||
|
const checkCommand = `Get-Process -Id ${pid} -ErrorAction SilentlyContinue | Select-Object Id | ConvertTo-Json`
|
||||||
|
const { stdout: checkStdout } = await execAsync(`powershell -Command "${checkCommand}"`)
|
||||||
|
|
||||||
|
if (!checkStdout.trim()) {
|
||||||
|
logger.info(`Process with PID ${pid} has disappeared`)
|
||||||
|
return { success: true, message: `Process ${pid} and all child processes terminated successfully` }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait 300ms before checking again
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn(`Process with PID ${pid} did not disappear within timeout`)
|
||||||
|
return { success: false, message: `Process ${pid} did not disappear within 5 seconds` }
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to terminate process ${pid}:`, error as Error)
|
||||||
|
return { success: false, message: `Failed to terminate process ${pid}` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop OVMS process if it's running
|
||||||
|
* @returns Promise<{ success: boolean; message?: string }>
|
||||||
|
*/
|
||||||
|
public async stopOvms(): Promise<{ success: boolean; message?: string }> {
|
||||||
|
try {
|
||||||
|
// Check if OVMS process is running
|
||||||
|
const psCommand = `Get-Process -Name "ovms" -ErrorAction SilentlyContinue | Select-Object Id, Path | ConvertTo-Json`
|
||||||
|
const { stdout } = await execAsync(`powershell -Command "${psCommand}"`)
|
||||||
|
|
||||||
|
if (!stdout.trim()) {
|
||||||
|
logger.info('OVMS process is not running')
|
||||||
|
return { success: true, message: 'OVMS process is not running' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const processes = JSON.parse(stdout)
|
||||||
|
const processList = Array.isArray(processes) ? processes : [processes]
|
||||||
|
|
||||||
|
if (processList.length === 0) {
|
||||||
|
logger.info('OVMS process is not running')
|
||||||
|
return { success: true, message: 'OVMS process is not running' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terminate all OVMS processes using terminalProcess
|
||||||
|
for (const process of processList) {
|
||||||
|
const result = await this.terminalProcess(process.Id)
|
||||||
|
if (!result.success) {
|
||||||
|
logger.error(`Failed to terminate OVMS process with PID: ${process.Id}, ${result.message}`)
|
||||||
|
return { success: false, message: `Failed to terminate OVMS process: ${result.message}` }
|
||||||
|
}
|
||||||
|
logger.info(`Terminated OVMS process with PID: ${process.Id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset the ovms instance
|
||||||
|
this.ovms = null
|
||||||
|
|
||||||
|
logger.info('OVMS process stopped successfully')
|
||||||
|
return { success: true, message: 'OVMS process stopped successfully' }
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to stop OVMS process: ${error}`)
|
||||||
|
return { success: false, message: 'Failed to stop OVMS process' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run OVMS by ensuring config.json exists and executing run.bat
|
||||||
|
* @returns Promise<{ success: boolean; message?: string }>
|
||||||
|
*/
|
||||||
|
public async runOvms(): Promise<{ success: boolean; message?: string }> {
|
||||||
|
const homeDir = homedir()
|
||||||
|
const ovmsDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms')
|
||||||
|
const configPath = path.join(ovmsDir, 'models', 'config.json')
|
||||||
|
const runBatPath = path.join(ovmsDir, 'run.bat')
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if config.json exists, if not create it with default content
|
||||||
|
if (!(await fs.pathExists(configPath))) {
|
||||||
|
logger.info(`Config file does not exist, creating: ${configPath}`)
|
||||||
|
|
||||||
|
// Ensure the models directory exists
|
||||||
|
await fs.ensureDir(path.dirname(configPath))
|
||||||
|
|
||||||
|
// Create config.json with default content
|
||||||
|
const defaultConfig = {
|
||||||
|
mediapipe_config_list: [],
|
||||||
|
model_config_list: []
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.writeJson(configPath, defaultConfig, { spaces: 2 })
|
||||||
|
logger.info(`Config file created: ${configPath}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if run.bat exists
|
||||||
|
if (!(await fs.pathExists(runBatPath))) {
|
||||||
|
logger.error(`run.bat not found at: ${runBatPath}`)
|
||||||
|
return { success: false, message: 'run.bat not found' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run run.bat without waiting for it to complete
|
||||||
|
logger.info(`Starting OVMS with run.bat: ${runBatPath}`)
|
||||||
|
exec(`"${runBatPath}"`, { cwd: ovmsDir }, (error) => {
|
||||||
|
if (error) {
|
||||||
|
logger.error(`Error running run.bat: ${error}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info('OVMS started successfully')
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to run OVMS: ${error}`)
|
||||||
|
return { success: false, message: 'Failed to run OVMS' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get OVMS status - checks installation and running status
|
||||||
|
* @returns 'not-installed' | 'not-running' | 'running'
|
||||||
|
*/
|
||||||
|
public async getOvmsStatus(): Promise<'not-installed' | 'not-running' | 'running'> {
|
||||||
|
const homeDir = homedir()
|
||||||
|
const ovmsPath = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms', 'ovms.exe')
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if OVMS executable exists
|
||||||
|
if (!(await fs.pathExists(ovmsPath))) {
|
||||||
|
logger.info(`OVMS executable not found at: ${ovmsPath}`)
|
||||||
|
return 'not-installed'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if OVMS process is running
|
||||||
|
//const psCommand = `Get-Process -Name "ovms" -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq "${ovmsPath.replace(/\\/g, '\\\\')}" } | Select-Object Id | ConvertTo-Json`;
|
||||||
|
//const { stdout } = await execAsync(`powershell -Command "${psCommand}"`);
|
||||||
|
const psCommand = `Get-Process -Name "ovms" -ErrorAction SilentlyContinue | Select-Object Id, Path | ConvertTo-Json`
|
||||||
|
const { stdout } = await execAsync(`powershell -Command "${psCommand}"`)
|
||||||
|
|
||||||
|
if (!stdout.trim()) {
|
||||||
|
logger.info('OVMS process not running')
|
||||||
|
return 'not-running'
|
||||||
|
}
|
||||||
|
|
||||||
|
const processes = JSON.parse(stdout)
|
||||||
|
const processList = Array.isArray(processes) ? processes : [processes]
|
||||||
|
|
||||||
|
if (processList.length > 0) {
|
||||||
|
logger.info('OVMS process is running')
|
||||||
|
return 'running'
|
||||||
|
} else {
|
||||||
|
logger.info('OVMS process not running')
|
||||||
|
return 'not-running'
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.info(`Failed to check OVMS status: ${error}`)
|
||||||
|
return 'not-running'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize OVMS by finding the executable path and working directory
|
||||||
|
*/
|
||||||
|
public async initializeOvms(): Promise<boolean> {
|
||||||
|
// Use PowerShell to find ovms.exe processes with their paths
|
||||||
|
const psCommand = `Get-Process -Name "ovms" -ErrorAction SilentlyContinue | Select-Object Id, Path | ConvertTo-Json`
|
||||||
|
const { stdout } = await execAsync(`powershell -Command "${psCommand}"`)
|
||||||
|
|
||||||
|
if (!stdout.trim()) {
|
||||||
|
logger.error('Command to find OVMS process returned no output')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
logger.debug(`OVMS process output: ${stdout}`)
|
||||||
|
|
||||||
|
const processes = JSON.parse(stdout)
|
||||||
|
const processList = Array.isArray(processes) ? processes : [processes]
|
||||||
|
|
||||||
|
// Find the first process with a valid path
|
||||||
|
for (const process of processList) {
|
||||||
|
this.ovms = {
|
||||||
|
pid: process.Id,
|
||||||
|
path: process.Path,
|
||||||
|
workingDirectory: path.dirname(process.Path)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.ovms !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the Model Name and ID are valid, they are valid only if they are not used in the config.json
|
||||||
|
* @param modelName Name of the model to check
|
||||||
|
* @param modelId ID of the model to check
|
||||||
|
*/
|
||||||
|
public async isNameAndIDAvalid(modelName: string, modelId: string): Promise<boolean> {
|
||||||
|
if (!modelName || !modelId) {
|
||||||
|
logger.error('Model name and ID cannot be empty')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const homeDir = homedir()
|
||||||
|
const configPath = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms', 'models', 'config.json')
|
||||||
|
try {
|
||||||
|
if (!(await fs.pathExists(configPath))) {
|
||||||
|
logger.warn(`Config file does not exist: ${configPath}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: OvmsConfig = await fs.readJson(configPath)
|
||||||
|
if (!config.mediapipe_config_list) {
|
||||||
|
logger.warn(`No mediapipe_config_list found in config: ${configPath}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the model name or ID already exists in the config
|
||||||
|
const exists = config.mediapipe_config_list.some(
|
||||||
|
(model) => model.name === modelName || model.base_path === modelId
|
||||||
|
)
|
||||||
|
if (exists) {
|
||||||
|
logger.warn(`Model with name "${modelName}" or ID "${modelId}" already exists in the config`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to check model existence: ${error}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private async applyModelPath(modelDirPath: string): Promise<boolean> {
|
||||||
|
const homeDir = homedir()
|
||||||
|
const patchDir = path.join(homeDir, '.cherrystudio', 'ovms', 'patch')
|
||||||
|
if (!(await fs.pathExists(patchDir))) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelId = path.basename(modelDirPath)
|
||||||
|
|
||||||
|
// get all sub directories in patchDir
|
||||||
|
const patchs = await fs.readdir(patchDir)
|
||||||
|
for (const patch of patchs) {
|
||||||
|
const fullPatchPath = path.join(patchDir, patch)
|
||||||
|
|
||||||
|
if (fs.lstatSync(fullPatchPath).isDirectory()) {
|
||||||
|
if (modelId.toLowerCase().includes(patch.toLowerCase())) {
|
||||||
|
// copy all files from fullPath to modelDirPath
|
||||||
|
try {
|
||||||
|
const files = await fs.readdir(fullPatchPath)
|
||||||
|
for (const file of files) {
|
||||||
|
const srcFile = path.join(fullPatchPath, file)
|
||||||
|
const destFile = path.join(modelDirPath, file)
|
||||||
|
await fs.copyFile(srcFile, destFile)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to copy files from ${fullPatchPath} to ${modelDirPath}: ${error}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
logger.info(`Applied patchs for model ${modelId}`)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a model to OVMS by downloading it
|
||||||
|
* @param modelName Name of the model to add
|
||||||
|
* @param modelId ID of the model to download
|
||||||
|
* @param modelSource Model Source: huggingface, hf-mirror and modelscope, default is huggingface
|
||||||
|
* @param task Task type: text_generation, embedding, rerank, image_generation
|
||||||
|
*/
|
||||||
|
public async addModel(
|
||||||
|
modelName: string,
|
||||||
|
modelId: string,
|
||||||
|
modelSource: string,
|
||||||
|
task: string = 'text_generation'
|
||||||
|
): Promise<{ success: boolean; message?: string }> {
|
||||||
|
logger.info(`Adding model: ${modelName} with ID: ${modelId}, Source: ${modelSource}, Task: ${task}`)
|
||||||
|
|
||||||
|
const homeDir = homedir()
|
||||||
|
const ovdndDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms')
|
||||||
|
const pathModel = path.join(ovdndDir, 'models', modelId)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// check the ovdnDir+'models'+modelId exist or not
|
||||||
|
if (await fs.pathExists(pathModel)) {
|
||||||
|
logger.error(`Model with ID ${modelId} already exists`)
|
||||||
|
return { success: false, message: 'Model ID already exists!' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove the model directory if it exists
|
||||||
|
if (await fs.pathExists(pathModel)) {
|
||||||
|
logger.info(`Removing existing model directory: ${pathModel}`)
|
||||||
|
await fs.remove(pathModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use ovdnd.exe for downloading instead of ovms.exe
|
||||||
|
const ovdndPath = path.join(ovdndDir, 'ovdnd.exe')
|
||||||
|
const command =
|
||||||
|
`"${ovdndPath}" --pull ` +
|
||||||
|
`--model_repository_path "${ovdndDir}/models" ` +
|
||||||
|
`--source_model "${modelId}" ` +
|
||||||
|
`--model_name "${modelName}" ` +
|
||||||
|
`--target_device GPU ` +
|
||||||
|
`--task ${task} ` +
|
||||||
|
`--overwrite_models`
|
||||||
|
|
||||||
|
const env: Record<string, string | undefined> = {
|
||||||
|
...process.env,
|
||||||
|
OVMS_DIR: ovdndDir,
|
||||||
|
PYTHONHOME: path.join(ovdndDir, 'python'),
|
||||||
|
PATH: `${process.env.PATH};${ovdndDir};${path.join(ovdndDir, 'python')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modelSource) {
|
||||||
|
env.HF_ENDPOINT = modelSource
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Running command: ${command} from ${modelSource}`)
|
||||||
|
const { stdout } = await execAsync(command, { env: env, cwd: ovdndDir })
|
||||||
|
|
||||||
|
logger.info('Model download completed')
|
||||||
|
logger.debug(`Command output: ${stdout}`)
|
||||||
|
} catch (error) {
|
||||||
|
// remove ovdnDir+'models'+modelId if it exists
|
||||||
|
if (await fs.pathExists(pathModel)) {
|
||||||
|
logger.info(`Removing failed model directory: ${pathModel}`)
|
||||||
|
await fs.remove(pathModel)
|
||||||
|
}
|
||||||
|
logger.error(`Failed to add model: ${error}`)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Download model ${modelId} failed, please check following items and try it again:<p>- the model id</p><p>- network connection and proxy</p>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update config file
|
||||||
|
if (!(await this.updateModelConfig(modelName, modelId))) {
|
||||||
|
logger.error('Failed to update model config')
|
||||||
|
return { success: false, message: 'Failed to update model config' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await this.applyModelPath(pathModel))) {
|
||||||
|
logger.error('Failed to apply model patchs')
|
||||||
|
return { success: false, message: 'Failed to apply model patchs' }
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Model ${modelName} added successfully with ID ${modelId}`)
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the model download process if it's running
|
||||||
|
* @returns Promise<{ success: boolean; message?: string }>
|
||||||
|
*/
|
||||||
|
public async stopAddModel(): Promise<{ success: boolean; message?: string }> {
|
||||||
|
try {
|
||||||
|
// Check if ovdnd.exe process is running
|
||||||
|
const psCommand = `Get-Process -Name "ovdnd" -ErrorAction SilentlyContinue | Select-Object Id, Path | ConvertTo-Json`
|
||||||
|
const { stdout } = await execAsync(`powershell -Command "${psCommand}"`)
|
||||||
|
|
||||||
|
if (!stdout.trim()) {
|
||||||
|
logger.info('ovdnd process is not running')
|
||||||
|
return { success: true, message: 'Model download process is not running' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const processes = JSON.parse(stdout)
|
||||||
|
const processList = Array.isArray(processes) ? processes : [processes]
|
||||||
|
|
||||||
|
if (processList.length === 0) {
|
||||||
|
logger.info('ovdnd process is not running')
|
||||||
|
return { success: true, message: 'Model download process is not running' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terminate all ovdnd processes
|
||||||
|
for (const process of processList) {
|
||||||
|
this.terminalProcess(process.Id)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Model download process stopped successfully')
|
||||||
|
return { success: true, message: 'Model download process stopped successfully' }
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to stop model download process: ${error}`)
|
||||||
|
return { success: false, message: 'Failed to stop model download process' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* check if the model id exists in the OVMS configuration
|
||||||
|
* @param modelId ID of the model to check
|
||||||
|
*/
|
||||||
|
public async checkModelExists(modelId: string): Promise<boolean> {
|
||||||
|
const homeDir = homedir()
|
||||||
|
const ovmsDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms')
|
||||||
|
const configPath = path.join(ovmsDir, 'models', 'config.json')
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!(await fs.pathExists(configPath))) {
|
||||||
|
logger.warn(`Config file does not exist: ${configPath}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: OvmsConfig = await fs.readJson(configPath)
|
||||||
|
if (!config.mediapipe_config_list) {
|
||||||
|
logger.warn('No mediapipe_config_list found in config')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return config.mediapipe_config_list.some((model) => model.base_path === modelId)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to check model existence: ${error}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the model configuration file
|
||||||
|
*/
|
||||||
|
public async updateModelConfig(modelName: string, modelId: string): Promise<boolean> {
|
||||||
|
const homeDir = homedir()
|
||||||
|
const ovmsDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms')
|
||||||
|
const configPath = path.join(ovmsDir, 'models', 'config.json')
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Ensure the models directory exists
|
||||||
|
await fs.ensureDir(path.dirname(configPath))
|
||||||
|
let config: OvmsConfig
|
||||||
|
|
||||||
|
// Read existing config or create new one
|
||||||
|
if (await fs.pathExists(configPath)) {
|
||||||
|
config = await fs.readJson(configPath)
|
||||||
|
} else {
|
||||||
|
config = { mediapipe_config_list: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure mediapipe_config_list exists
|
||||||
|
if (!config.mediapipe_config_list) {
|
||||||
|
config.mediapipe_config_list = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new model config
|
||||||
|
const newModelConfig: ModelConfig = {
|
||||||
|
name: modelName,
|
||||||
|
base_path: modelId
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if model already exists, if so, update it
|
||||||
|
const existingIndex = config.mediapipe_config_list.findIndex((model) => model.base_path === modelId)
|
||||||
|
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
config.mediapipe_config_list[existingIndex] = newModelConfig
|
||||||
|
logger.info(`Updated existing model config: ${modelName}`)
|
||||||
|
} else {
|
||||||
|
config.mediapipe_config_list.push(newModelConfig)
|
||||||
|
logger.info(`Added new model config: ${modelName}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write config back to file
|
||||||
|
await fs.writeJson(configPath, config, { spaces: 2 })
|
||||||
|
logger.info(`Config file updated: ${configPath}`)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to update model config: ${error}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all models from OVMS config, filtered for image generation models
|
||||||
|
* @returns Array of model configurations
|
||||||
|
*/
|
||||||
|
public async getModels(): Promise<ModelConfig[]> {
|
||||||
|
const homeDir = homedir()
|
||||||
|
const ovmsDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms')
|
||||||
|
const configPath = path.join(ovmsDir, 'models', 'config.json')
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!(await fs.pathExists(configPath))) {
|
||||||
|
logger.warn(`Config file does not exist: ${configPath}`)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: OvmsConfig = await fs.readJson(configPath)
|
||||||
|
if (!config.mediapipe_config_list) {
|
||||||
|
logger.warn('No mediapipe_config_list found in config')
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter models for image generation (SD, Stable-Diffusion, Stable Diffusion, FLUX)
|
||||||
|
const imageGenerationModels = config.mediapipe_config_list.filter((model) => {
|
||||||
|
const modelName = model.name.toLowerCase()
|
||||||
|
return (
|
||||||
|
modelName.startsWith('sd') ||
|
||||||
|
modelName.startsWith('stable-diffusion') ||
|
||||||
|
modelName.startsWith('stable diffusion') ||
|
||||||
|
modelName.startsWith('flux')
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(`Found ${imageGenerationModels.length} image generation models`)
|
||||||
|
return imageGenerationModels
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to get models: ${error}`)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OvmsManager
|
||||||
@@ -274,46 +274,4 @@ describe('AppUpdater', () => {
|
|||||||
expect(result.releaseNotes).toBeNull()
|
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 * as fs from 'node:fs'
|
||||||
import { readFile } from 'node:fs/promises'
|
import { readFile } from 'node:fs/promises'
|
||||||
import os from 'node:os'
|
import os from 'node:os'
|
||||||
@@ -264,11 +265,12 @@ export async function scanDir(dirPath: string, depth = 0, basePath?: string): Pr
|
|||||||
|
|
||||||
if (entry.isDirectory() && options.includeDirectories) {
|
if (entry.isDirectory() && options.includeDirectories) {
|
||||||
const stats = await fs.promises.stat(entryPath)
|
const stats = await fs.promises.stat(entryPath)
|
||||||
|
const externalDirPath = entryPath.replace(/\\/g, '/')
|
||||||
const dirTreeNode: NotesTreeNode = {
|
const dirTreeNode: NotesTreeNode = {
|
||||||
id: uuidv4(),
|
id: createHash('sha1').update(externalDirPath).digest('hex'),
|
||||||
name: entry.name,
|
name: entry.name,
|
||||||
treePath: treePath,
|
treePath: treePath,
|
||||||
externalPath: entryPath,
|
externalPath: externalDirPath,
|
||||||
createdAt: stats.birthtime.toISOString(),
|
createdAt: stats.birthtime.toISOString(),
|
||||||
updatedAt: stats.mtime.toISOString(),
|
updatedAt: stats.mtime.toISOString(),
|
||||||
type: 'folder',
|
type: 'folder',
|
||||||
@@ -299,11 +301,12 @@ export async function scanDir(dirPath: string, depth = 0, basePath?: string): Pr
|
|||||||
? `/${dirRelativePath.replace(/\\/g, '/')}/${nameWithoutExt}`
|
? `/${dirRelativePath.replace(/\\/g, '/')}/${nameWithoutExt}`
|
||||||
: `/${nameWithoutExt}`
|
: `/${nameWithoutExt}`
|
||||||
|
|
||||||
|
const externalFilePath = entryPath.replace(/\\/g, '/')
|
||||||
const fileTreeNode: NotesTreeNode = {
|
const fileTreeNode: NotesTreeNode = {
|
||||||
id: uuidv4(),
|
id: createHash('sha1').update(externalFilePath).digest('hex'),
|
||||||
name: name,
|
name: name,
|
||||||
treePath: fileTreePath,
|
treePath: fileTreePath,
|
||||||
externalPath: entryPath,
|
externalPath: externalFilePath,
|
||||||
createdAt: stats.birthtime.toISOString(),
|
createdAt: stats.birthtime.toISOString(),
|
||||||
updatedAt: stats.mtime.toISOString(),
|
updatedAt: stats.mtime.toISOString(),
|
||||||
type: 'file'
|
type: 'file'
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ const api = {
|
|||||||
setProxy: (proxy: string | undefined, bypassRules?: string) =>
|
setProxy: (proxy: string | undefined, bypassRules?: string) =>
|
||||||
ipcRenderer.invoke(IpcChannel.App_Proxy, proxy, bypassRules),
|
ipcRenderer.invoke(IpcChannel.App_Proxy, proxy, bypassRules),
|
||||||
checkForUpdate: () => ipcRenderer.invoke(IpcChannel.App_CheckForUpdate),
|
checkForUpdate: () => ipcRenderer.invoke(IpcChannel.App_CheckForUpdate),
|
||||||
showUpdateDialog: () => ipcRenderer.invoke(IpcChannel.App_ShowUpdateDialog),
|
quitAndInstall: () => ipcRenderer.invoke(IpcChannel.App_QuitAndInstall),
|
||||||
setLanguage: (lang: string) => ipcRenderer.invoke(IpcChannel.App_SetLanguage, lang),
|
setLanguage: (lang: string) => ipcRenderer.invoke(IpcChannel.App_SetLanguage, lang),
|
||||||
setEnableSpellCheck: (isEnable: boolean) => ipcRenderer.invoke(IpcChannel.App_SetEnableSpellCheck, isEnable),
|
setEnableSpellCheck: (isEnable: boolean) => ipcRenderer.invoke(IpcChannel.App_SetEnableSpellCheck, isEnable),
|
||||||
setSpellCheckLanguages: (languages: string[]) => ipcRenderer.invoke(IpcChannel.App_SetSpellCheckLanguages, languages),
|
setSpellCheckLanguages: (languages: string[]) => ipcRenderer.invoke(IpcChannel.App_SetSpellCheckLanguages, languages),
|
||||||
@@ -95,7 +95,8 @@ const api = {
|
|||||||
},
|
},
|
||||||
system: {
|
system: {
|
||||||
getDeviceType: () => ipcRenderer.invoke(IpcChannel.System_GetDeviceType),
|
getDeviceType: () => ipcRenderer.invoke(IpcChannel.System_GetDeviceType),
|
||||||
getHostname: () => ipcRenderer.invoke(IpcChannel.System_GetHostname)
|
getHostname: () => ipcRenderer.invoke(IpcChannel.System_GetHostname),
|
||||||
|
getCpuName: () => ipcRenderer.invoke(IpcChannel.System_GetCpuName)
|
||||||
},
|
},
|
||||||
devTools: {
|
devTools: {
|
||||||
toggle: () => ipcRenderer.invoke(IpcChannel.System_ToggleDevTools)
|
toggle: () => ipcRenderer.invoke(IpcChannel.System_ToggleDevTools)
|
||||||
@@ -220,7 +221,7 @@ const api = {
|
|||||||
create: (base: KnowledgeBaseParams, context?: SpanContext) =>
|
create: (base: KnowledgeBaseParams, context?: SpanContext) =>
|
||||||
tracedInvoke(IpcChannel.KnowledgeBase_Create, context, base),
|
tracedInvoke(IpcChannel.KnowledgeBase_Create, context, base),
|
||||||
reset: (base: KnowledgeBaseParams) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Reset, base),
|
reset: (base: KnowledgeBaseParams) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Reset, base),
|
||||||
delete: (base: KnowledgeBaseParams, id: string) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Delete, base, id),
|
delete: (id: string) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Delete, id),
|
||||||
add: ({
|
add: ({
|
||||||
base,
|
base,
|
||||||
item,
|
item,
|
||||||
@@ -285,6 +286,16 @@ const api = {
|
|||||||
clearAuthCache: (projectId: string, clientEmail?: string) =>
|
clearAuthCache: (projectId: string, clientEmail?: string) =>
|
||||||
ipcRenderer.invoke(IpcChannel.VertexAI_ClearAuthCache, projectId, clientEmail)
|
ipcRenderer.invoke(IpcChannel.VertexAI_ClearAuthCache, projectId, clientEmail)
|
||||||
},
|
},
|
||||||
|
ovms: {
|
||||||
|
addModel: (modelName: string, modelId: string, modelSource: string, task: string) =>
|
||||||
|
ipcRenderer.invoke(IpcChannel.Ovms_AddModel, modelName, modelId, modelSource, task),
|
||||||
|
stopAddModel: () => ipcRenderer.invoke(IpcChannel.Ovms_StopAddModel),
|
||||||
|
getModels: () => ipcRenderer.invoke(IpcChannel.Ovms_GetModels),
|
||||||
|
isRunning: () => ipcRenderer.invoke(IpcChannel.Ovms_IsRunning),
|
||||||
|
getStatus: () => ipcRenderer.invoke(IpcChannel.Ovms_GetStatus),
|
||||||
|
runOvms: () => ipcRenderer.invoke(IpcChannel.Ovms_RunOVMS),
|
||||||
|
stopOvms: () => ipcRenderer.invoke(IpcChannel.Ovms_StopOVMS)
|
||||||
|
},
|
||||||
config: {
|
config: {
|
||||||
set: (key: string, value: any, isNotify: boolean = false) =>
|
set: (key: string, value: any, isNotify: boolean = false) =>
|
||||||
ipcRenderer.invoke(IpcChannel.Config_Set, key, value, isNotify),
|
ipcRenderer.invoke(IpcChannel.Config_Set, key, value, isNotify),
|
||||||
@@ -350,6 +361,7 @@ const api = {
|
|||||||
getBinaryPath: (name: string) => ipcRenderer.invoke(IpcChannel.App_GetBinaryPath, name),
|
getBinaryPath: (name: string) => ipcRenderer.invoke(IpcChannel.App_GetBinaryPath, name),
|
||||||
installUVBinary: () => ipcRenderer.invoke(IpcChannel.App_InstallUvBinary),
|
installUVBinary: () => ipcRenderer.invoke(IpcChannel.App_InstallUvBinary),
|
||||||
installBunBinary: () => ipcRenderer.invoke(IpcChannel.App_InstallBunBinary),
|
installBunBinary: () => ipcRenderer.invoke(IpcChannel.App_InstallBunBinary),
|
||||||
|
installOvmsBinary: () => ipcRenderer.invoke(IpcChannel.App_InstallOvmsBinary),
|
||||||
protocol: {
|
protocol: {
|
||||||
onReceiveData: (callback: (data: { url: string; params: any }) => void) => {
|
onReceiveData: (callback: (data: { url: string; params: any }) => void) => {
|
||||||
const listener = (_event: Electron.IpcRendererEvent, data: { url: string; params: any }) => {
|
const listener = (_event: Electron.IpcRendererEvent, data: { url: string; params: any }) => {
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export class AiSdkToChunkAdapter {
|
|||||||
private accumulate: boolean | undefined
|
private accumulate: boolean | undefined
|
||||||
private isFirstChunk = true
|
private isFirstChunk = true
|
||||||
private enableWebSearch: boolean = false
|
private enableWebSearch: boolean = false
|
||||||
|
private responseStartTimestamp: number | null = null
|
||||||
|
private firstTokenTimestamp: number | null = null
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private onChunk: (chunk: Chunk) => void,
|
private onChunk: (chunk: Chunk) => void,
|
||||||
@@ -34,6 +36,17 @@ export class AiSdkToChunkAdapter {
|
|||||||
this.enableWebSearch = enableWebSearch || false
|
this.enableWebSearch = enableWebSearch || false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private markFirstTokenIfNeeded() {
|
||||||
|
if (this.firstTokenTimestamp === null && this.responseStartTimestamp !== null) {
|
||||||
|
this.firstTokenTimestamp = Date.now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetTimingState() {
|
||||||
|
this.responseStartTimestamp = null
|
||||||
|
this.firstTokenTimestamp = null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理 AI SDK 流结果
|
* 处理 AI SDK 流结果
|
||||||
* @param aiSdkResult AI SDK 的流结果对象
|
* @param aiSdkResult AI SDK 的流结果对象
|
||||||
@@ -61,6 +74,8 @@ export class AiSdkToChunkAdapter {
|
|||||||
webSearchResults: [],
|
webSearchResults: [],
|
||||||
reasoningId: ''
|
reasoningId: ''
|
||||||
}
|
}
|
||||||
|
this.resetTimingState()
|
||||||
|
this.responseStartTimestamp = Date.now()
|
||||||
// Reset link converter state at the start of stream
|
// Reset link converter state at the start of stream
|
||||||
this.isFirstChunk = true
|
this.isFirstChunk = true
|
||||||
|
|
||||||
@@ -73,6 +88,7 @@ export class AiSdkToChunkAdapter {
|
|||||||
if (this.enableWebSearch) {
|
if (this.enableWebSearch) {
|
||||||
const remainingText = flushLinkConverterBuffer()
|
const remainingText = flushLinkConverterBuffer()
|
||||||
if (remainingText) {
|
if (remainingText) {
|
||||||
|
this.markFirstTokenIfNeeded()
|
||||||
this.onChunk({
|
this.onChunk({
|
||||||
type: ChunkType.TEXT_DELTA,
|
type: ChunkType.TEXT_DELTA,
|
||||||
text: remainingText
|
text: remainingText
|
||||||
@@ -87,6 +103,7 @@ export class AiSdkToChunkAdapter {
|
|||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
reader.releaseLock()
|
reader.releaseLock()
|
||||||
|
this.resetTimingState()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,6 +154,7 @@ export class AiSdkToChunkAdapter {
|
|||||||
|
|
||||||
// Only emit chunk if there's text to send
|
// Only emit chunk if there's text to send
|
||||||
if (finalText) {
|
if (finalText) {
|
||||||
|
this.markFirstTokenIfNeeded()
|
||||||
this.onChunk({
|
this.onChunk({
|
||||||
type: ChunkType.TEXT_DELTA,
|
type: ChunkType.TEXT_DELTA,
|
||||||
text: this.accumulate ? final.text : finalText
|
text: this.accumulate ? final.text : finalText
|
||||||
@@ -161,16 +179,18 @@ export class AiSdkToChunkAdapter {
|
|||||||
break
|
break
|
||||||
case 'reasoning-delta':
|
case 'reasoning-delta':
|
||||||
final.reasoningContent += chunk.text || ''
|
final.reasoningContent += chunk.text || ''
|
||||||
|
if (chunk.text) {
|
||||||
|
this.markFirstTokenIfNeeded()
|
||||||
|
}
|
||||||
this.onChunk({
|
this.onChunk({
|
||||||
type: ChunkType.THINKING_DELTA,
|
type: ChunkType.THINKING_DELTA,
|
||||||
text: final.reasoningContent || '',
|
text: final.reasoningContent || ''
|
||||||
thinking_millsec: (chunk.providerMetadata?.metadata?.thinking_millsec as number) || 0
|
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
case 'reasoning-end':
|
case 'reasoning-end':
|
||||||
this.onChunk({
|
this.onChunk({
|
||||||
type: ChunkType.THINKING_COMPLETE,
|
type: ChunkType.THINKING_COMPLETE,
|
||||||
text: (chunk.providerMetadata?.metadata?.thinking_content as string) || final.reasoningContent
|
text: final.reasoningContent || ''
|
||||||
})
|
})
|
||||||
final.reasoningContent = ''
|
final.reasoningContent = ''
|
||||||
break
|
break
|
||||||
@@ -261,44 +281,37 @@ export class AiSdkToChunkAdapter {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'finish':
|
case 'finish': {
|
||||||
|
const usage = {
|
||||||
|
completion_tokens: chunk.totalUsage?.outputTokens || 0,
|
||||||
|
prompt_tokens: chunk.totalUsage?.inputTokens || 0,
|
||||||
|
total_tokens: chunk.totalUsage?.totalTokens || 0
|
||||||
|
}
|
||||||
|
const metrics = this.buildMetrics(chunk.totalUsage)
|
||||||
|
const baseResponse = {
|
||||||
|
text: final.text || '',
|
||||||
|
reasoning_content: final.reasoningContent || ''
|
||||||
|
}
|
||||||
|
|
||||||
this.onChunk({
|
this.onChunk({
|
||||||
type: ChunkType.BLOCK_COMPLETE,
|
type: ChunkType.BLOCK_COMPLETE,
|
||||||
response: {
|
response: {
|
||||||
text: final.text || '',
|
...baseResponse,
|
||||||
reasoning_content: final.reasoningContent || '',
|
usage: { ...usage },
|
||||||
usage: {
|
metrics: metrics ? { ...metrics } : undefined
|
||||||
completion_tokens: chunk.totalUsage.outputTokens || 0,
|
|
||||||
prompt_tokens: chunk.totalUsage.inputTokens || 0,
|
|
||||||
total_tokens: chunk.totalUsage.totalTokens || 0
|
|
||||||
},
|
|
||||||
metrics: chunk.totalUsage
|
|
||||||
? {
|
|
||||||
completion_tokens: chunk.totalUsage.outputTokens || 0,
|
|
||||||
time_completion_millsec: 0
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
this.onChunk({
|
this.onChunk({
|
||||||
type: ChunkType.LLM_RESPONSE_COMPLETE,
|
type: ChunkType.LLM_RESPONSE_COMPLETE,
|
||||||
response: {
|
response: {
|
||||||
text: final.text || '',
|
...baseResponse,
|
||||||
reasoning_content: final.reasoningContent || '',
|
usage: { ...usage },
|
||||||
usage: {
|
metrics: metrics ? { ...metrics } : undefined
|
||||||
completion_tokens: chunk.totalUsage.outputTokens || 0,
|
|
||||||
prompt_tokens: chunk.totalUsage.inputTokens || 0,
|
|
||||||
total_tokens: chunk.totalUsage.totalTokens || 0
|
|
||||||
},
|
|
||||||
metrics: chunk.totalUsage
|
|
||||||
? {
|
|
||||||
completion_tokens: chunk.totalUsage.outputTokens || 0,
|
|
||||||
time_completion_millsec: 0
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
this.resetTimingState()
|
||||||
break
|
break
|
||||||
|
}
|
||||||
|
|
||||||
// === 源和文件相关事件 ===
|
// === 源和文件相关事件 ===
|
||||||
case 'source':
|
case 'source':
|
||||||
@@ -334,6 +347,34 @@ export class AiSdkToChunkAdapter {
|
|||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildMetrics(totalUsage?: {
|
||||||
|
inputTokens?: number | null
|
||||||
|
outputTokens?: number | null
|
||||||
|
totalTokens?: number | null
|
||||||
|
}) {
|
||||||
|
if (!totalUsage) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const completionTokens = totalUsage.outputTokens ?? 0
|
||||||
|
const now = Date.now()
|
||||||
|
const start = this.responseStartTimestamp ?? now
|
||||||
|
const firstToken = this.firstTokenTimestamp
|
||||||
|
const timeFirstToken = Math.max(firstToken != null ? firstToken - start : 0, 0)
|
||||||
|
const baseForCompletion = firstToken ?? start
|
||||||
|
let timeCompletion = Math.max(now - baseForCompletion, 0)
|
||||||
|
|
||||||
|
if (timeCompletion === 0 && completionTokens > 0) {
|
||||||
|
timeCompletion = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
completion_tokens: completionTokens,
|
||||||
|
time_first_token_millsec: timeFirstToken,
|
||||||
|
time_completion_millsec: timeCompletion
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default AiSdkToChunkAdapter
|
export default AiSdkToChunkAdapter
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { VertexAPIClient } from './gemini/VertexAPIClient'
|
|||||||
import { NewAPIClient } from './newapi/NewAPIClient'
|
import { NewAPIClient } from './newapi/NewAPIClient'
|
||||||
import { OpenAIAPIClient } from './openai/OpenAIApiClient'
|
import { OpenAIAPIClient } from './openai/OpenAIApiClient'
|
||||||
import { OpenAIResponseAPIClient } from './openai/OpenAIResponseAPIClient'
|
import { OpenAIResponseAPIClient } from './openai/OpenAIResponseAPIClient'
|
||||||
|
import { OVMSClient } from './ovms/OVMSClient'
|
||||||
import { PPIOAPIClient } from './ppio/PPIOAPIClient'
|
import { PPIOAPIClient } from './ppio/PPIOAPIClient'
|
||||||
import { ZhipuAPIClient } from './zhipu/ZhipuAPIClient'
|
import { ZhipuAPIClient } from './zhipu/ZhipuAPIClient'
|
||||||
|
|
||||||
@@ -63,6 +64,12 @@ export class ApiClientFactory {
|
|||||||
return instance
|
return instance
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (provider.id === 'ovms') {
|
||||||
|
logger.debug(`Creating OVMSClient for provider: ${provider.id}`)
|
||||||
|
instance = new OVMSClient(provider) as BaseApiClient
|
||||||
|
return instance
|
||||||
|
}
|
||||||
|
|
||||||
// 然后检查标准的 Provider Type
|
// 然后检查标准的 Provider Type
|
||||||
switch (provider.type) {
|
switch (provider.type) {
|
||||||
case 'openai':
|
case 'openai':
|
||||||
|
|||||||
56
src/renderer/src/aiCore/legacy/clients/ovms/OVMSClient.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { loggerService } from '@logger'
|
||||||
|
import { isSupportedModel } from '@renderer/config/models'
|
||||||
|
import { objectKeys, Provider } from '@renderer/types'
|
||||||
|
import OpenAI from 'openai'
|
||||||
|
|
||||||
|
import { OpenAIAPIClient } from '../openai/OpenAIApiClient'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('OVMSClient')
|
||||||
|
|
||||||
|
export class OVMSClient extends OpenAIAPIClient {
|
||||||
|
constructor(provider: Provider) {
|
||||||
|
super(provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
override async listModels(): Promise<OpenAI.Models.Model[]> {
|
||||||
|
try {
|
||||||
|
const sdk = await this.getSdkInstance()
|
||||||
|
|
||||||
|
const chatModelsResponse = await sdk.request({
|
||||||
|
method: 'get',
|
||||||
|
path: '../v1/config'
|
||||||
|
})
|
||||||
|
logger.debug(`Chat models response: ${JSON.stringify(chatModelsResponse)}`)
|
||||||
|
|
||||||
|
// Parse the config response to extract model information
|
||||||
|
const config = chatModelsResponse as Record<string, any>
|
||||||
|
const models = objectKeys(config)
|
||||||
|
.map((modelName) => {
|
||||||
|
const modelInfo = config[modelName]
|
||||||
|
|
||||||
|
// Check if model has at least one version with "AVAILABLE" state
|
||||||
|
const hasAvailableVersion = modelInfo?.model_version_status?.some(
|
||||||
|
(versionStatus: any) => versionStatus?.state === 'AVAILABLE'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (hasAvailableVersion) {
|
||||||
|
return {
|
||||||
|
id: modelName,
|
||||||
|
object: 'model' as const,
|
||||||
|
owned_by: 'ovms',
|
||||||
|
created: Date.now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null // Skip models without available versions
|
||||||
|
})
|
||||||
|
.filter(Boolean) // Remove null entries
|
||||||
|
logger.debug(`Processed models: ${JSON.stringify(models)}`)
|
||||||
|
|
||||||
|
// Filter out unsupported models
|
||||||
|
return models.filter((model): model is OpenAI.Models.Model => model !== null && isSupportedModel(model))
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error listing OVMS models: ${error}`)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ import { getEnableDeveloperMode } from '@renderer/hooks/useSettings'
|
|||||||
import { Assistant } from '@renderer/types'
|
import { Assistant } from '@renderer/types'
|
||||||
|
|
||||||
import { AiSdkMiddlewareConfig } from '../middleware/AiSdkMiddlewareBuilder'
|
import { AiSdkMiddlewareConfig } from '../middleware/AiSdkMiddlewareBuilder'
|
||||||
import reasoningTimePlugin from './reasoningTimePlugin'
|
|
||||||
import { searchOrchestrationPlugin } from './searchOrchestrationPlugin'
|
import { searchOrchestrationPlugin } from './searchOrchestrationPlugin'
|
||||||
import { createTelemetryPlugin } from './telemetryPlugin'
|
import { createTelemetryPlugin } from './telemetryPlugin'
|
||||||
|
|
||||||
@@ -39,9 +38,9 @@ export function buildPlugins(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. 推理模型时添加推理插件
|
// 3. 推理模型时添加推理插件
|
||||||
if (middlewareConfig.enableReasoning) {
|
// if (middlewareConfig.enableReasoning) {
|
||||||
plugins.push(reasoningTimePlugin)
|
// plugins.push(reasoningTimePlugin)
|
||||||
}
|
// }
|
||||||
|
|
||||||
// 4. 启用Prompt工具调用时添加工具插件
|
// 4. 启用Prompt工具调用时添加工具插件
|
||||||
if (middlewareConfig.isPromptToolUse) {
|
if (middlewareConfig.isPromptToolUse) {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { CherryWebSearchConfig } from '@renderer/store/websearch'
|
|||||||
import { type Assistant, type MCPTool, type Provider } from '@renderer/types'
|
import { type Assistant, type MCPTool, type Provider } from '@renderer/types'
|
||||||
import type { StreamTextParams } from '@renderer/types/aiCoreTypes'
|
import type { StreamTextParams } from '@renderer/types/aiCoreTypes'
|
||||||
import { mapRegexToPatterns } from '@renderer/utils/blacklistMatchPattern'
|
import { mapRegexToPatterns } from '@renderer/utils/blacklistMatchPattern'
|
||||||
|
import { replacePromptVariables } from '@renderer/utils/prompt'
|
||||||
import type { ModelMessage, Tool } from 'ai'
|
import type { ModelMessage, Tool } from 'ai'
|
||||||
import { stepCountIs } from 'ai'
|
import { stepCountIs } from 'ai'
|
||||||
|
|
||||||
@@ -166,7 +167,7 @@ export async function buildStreamTextParams(
|
|||||||
params.tools = tools
|
params.tools = tools
|
||||||
}
|
}
|
||||||
if (assistant.prompt) {
|
if (assistant.prompt) {
|
||||||
params.system = assistant.prompt
|
params.system = await replacePromptVariables(assistant.prompt, model.name)
|
||||||
}
|
}
|
||||||
logger.debug('params', params)
|
logger.debug('params', params)
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { loggerService } from '@renderer/services/LoggerService'
|
|||||||
import store from '@renderer/store'
|
import store from '@renderer/store'
|
||||||
import { isSystemProvider, type Model, type Provider } from '@renderer/types'
|
import { isSystemProvider, type Model, type Provider } from '@renderer/types'
|
||||||
import { formatApiHost } from '@renderer/utils/api'
|
import { formatApiHost } from '@renderer/utils/api'
|
||||||
import { cloneDeep, isEmpty } from 'lodash'
|
import { cloneDeep, trim } from 'lodash'
|
||||||
|
|
||||||
import { aihubmixProviderCreator, newApiResolverCreator, vertexAnthropicProviderCreator } from './config'
|
import { aihubmixProviderCreator, newApiResolverCreator, vertexAnthropicProviderCreator } from './config'
|
||||||
import { getAiSdkProviderId } from './factory'
|
import { getAiSdkProviderId } from './factory'
|
||||||
@@ -120,7 +120,7 @@ export function providerToAiSdkConfig(
|
|||||||
|
|
||||||
// 构建基础配置
|
// 构建基础配置
|
||||||
const baseConfig = {
|
const baseConfig = {
|
||||||
baseURL: actualProvider.apiHost,
|
baseURL: trim(actualProvider.apiHost),
|
||||||
apiKey: getRotatedApiKey(actualProvider)
|
apiKey: getRotatedApiKey(actualProvider)
|
||||||
}
|
}
|
||||||
// 处理OpenAI模式
|
// 处理OpenAI模式
|
||||||
@@ -195,7 +195,10 @@ export function providerToAiSdkConfig(
|
|||||||
} else if (baseConfig.baseURL.endsWith('/v1')) {
|
} else if (baseConfig.baseURL.endsWith('/v1')) {
|
||||||
baseConfig.baseURL = baseConfig.baseURL.slice(0, -3)
|
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,使用原生配置
|
// 如果AI SDK支持该provider,使用原生配置
|
||||||
|
|||||||
@@ -18,12 +18,13 @@ export const knowledgeSearchTool = (
|
|||||||
) => {
|
) => {
|
||||||
return tool({
|
return tool({
|
||||||
name: 'builtin_knowledge_search',
|
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(', ')}"
|
This tool has been configured with search parameters based on the conversation context:
|
||||||
Rewritten query: "${extractedKeywords.rewrite}"
|
- 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({
|
inputSchema: z.object({
|
||||||
additionalContext: z
|
additionalContext: z
|
||||||
|
|||||||
@@ -21,16 +21,17 @@ export const webSearchToolWithPreExtractedKeywords = (
|
|||||||
|
|
||||||
return tool({
|
return tool({
|
||||||
name: 'builtin_web_search',
|
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(', ')}"${
|
This tool has been configured with search parameters based on the conversation context:
|
||||||
extractedKeywords.links
|
- 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({
|
inputSchema: z.object({
|
||||||
additionalContext: z
|
additionalContext: z
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 9.3 KiB |
BIN
src/renderer/src/assets/images/apps/stepfun.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
BIN
src/renderer/src/assets/images/models/gpt-5-codex.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
src/renderer/src/assets/images/providers/intel.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
src/renderer/src/assets/images/providers/longcat.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
@@ -75,10 +75,15 @@ export interface CodeEditorProps {
|
|||||||
/** CSS class name appended to the default `code-editor` class. */
|
/** CSS class name appended to the default `code-editor` class. */
|
||||||
className?: string
|
className?: string
|
||||||
/**
|
/**
|
||||||
* Whether the editor is editable.
|
* Whether the editor view is editable.
|
||||||
* @default true
|
* @default true
|
||||||
*/
|
*/
|
||||||
editable?: boolean
|
editable?: boolean
|
||||||
|
/**
|
||||||
|
* Set the editor state to read only but keep some user interactions, e.g., keymaps.
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
readOnly?: boolean
|
||||||
/**
|
/**
|
||||||
* Whether the editor is expanded.
|
* Whether the editor is expanded.
|
||||||
* If true, the height and maxHeight props are ignored.
|
* If true, the height and maxHeight props are ignored.
|
||||||
@@ -114,6 +119,7 @@ const CodeEditor = ({
|
|||||||
style,
|
style,
|
||||||
className,
|
className,
|
||||||
editable = true,
|
editable = true,
|
||||||
|
readOnly = false,
|
||||||
expanded = true,
|
expanded = true,
|
||||||
wrapped = true
|
wrapped = true
|
||||||
}: CodeEditorProps) => {
|
}: CodeEditorProps) => {
|
||||||
@@ -189,6 +195,7 @@ const CodeEditor = ({
|
|||||||
maxHeight={expanded ? undefined : maxHeight}
|
maxHeight={expanded ? undefined : maxHeight}
|
||||||
minHeight={minHeight}
|
minHeight={minHeight}
|
||||||
editable={editable}
|
editable={editable}
|
||||||
|
readOnly={readOnly}
|
||||||
// @ts-ignore 强制使用,见 react-codemirror 的 Example.tsx
|
// @ts-ignore 强制使用,见 react-codemirror 的 Example.tsx
|
||||||
theme={activeCmTheme}
|
theme={activeCmTheme}
|
||||||
extensions={customExtensions}
|
extensions={customExtensions}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { cn } from '@heroui/react'
|
||||||
import Scrollbar from '@renderer/components/Scrollbar'
|
import Scrollbar from '@renderer/components/Scrollbar'
|
||||||
import { ChevronRight } from 'lucide-react'
|
import { ChevronRight } from 'lucide-react'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
@@ -17,6 +18,10 @@ export interface HorizontalScrollContainerProps {
|
|||||||
dependencies?: readonly unknown[]
|
dependencies?: readonly unknown[]
|
||||||
scrollDistance?: number
|
scrollDistance?: number
|
||||||
className?: string
|
className?: string
|
||||||
|
classNames?: {
|
||||||
|
container?: string
|
||||||
|
content?: string
|
||||||
|
}
|
||||||
gap?: string
|
gap?: string
|
||||||
expandable?: boolean
|
expandable?: boolean
|
||||||
}
|
}
|
||||||
@@ -26,6 +31,7 @@ const HorizontalScrollContainer: React.FC<HorizontalScrollContainerProps> = ({
|
|||||||
dependencies = [],
|
dependencies = [],
|
||||||
scrollDistance = 200,
|
scrollDistance = 200,
|
||||||
className,
|
className,
|
||||||
|
classNames,
|
||||||
gap = '8px',
|
gap = '8px',
|
||||||
expandable = false
|
expandable = false
|
||||||
}) => {
|
}) => {
|
||||||
@@ -95,11 +101,16 @@ const HorizontalScrollContainer: React.FC<HorizontalScrollContainerProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Container
|
<Container
|
||||||
className={className}
|
className={cn(className, classNames?.container)}
|
||||||
$expandable={expandable}
|
$expandable={expandable}
|
||||||
$disableHoverButton={isScrolledToEnd}
|
$disableHoverButton={isScrolledToEnd}
|
||||||
onClick={expandable ? handleContainerClick : undefined}>
|
onClick={expandable ? handleContainerClick : undefined}>
|
||||||
<ScrollContent ref={scrollRef} $gap={gap} $isExpanded={isExpanded} $expandable={expandable}>
|
<ScrollContent
|
||||||
|
ref={scrollRef}
|
||||||
|
$gap={gap}
|
||||||
|
$isExpanded={isExpanded}
|
||||||
|
$expandable={expandable}
|
||||||
|
className={cn(classNames?.content)}>
|
||||||
{children}
|
{children}
|
||||||
</ScrollContent>
|
</ScrollContent>
|
||||||
{canScroll && !isExpanded && !isScrolledToEnd && (
|
{canScroll && !isExpanded && !isScrolledToEnd && (
|
||||||
|
|||||||
@@ -549,7 +549,7 @@ const MinappPopupContainer: React.FC = () => {
|
|||||||
{/* 在所有小程序中显示GoogleLoginTip */}
|
{/* 在所有小程序中显示GoogleLoginTip */}
|
||||||
<GoogleLoginTip isReady={isReady} currentUrl={currentUrl} currentAppId={currentMinappId} />
|
<GoogleLoginTip isReady={isReady} currentUrl={currentUrl} currentAppId={currentMinappId} />
|
||||||
{!isReady && (
|
{!isReady && (
|
||||||
<EmptyView>
|
<EmptyView style={{ backgroundColor: 'var(--color-background-soft)' }}>
|
||||||
<Avatar
|
<Avatar
|
||||||
src={currentAppInfo?.logo}
|
src={currentAppInfo?.logo}
|
||||||
size={80}
|
size={80}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ interface PopupContainerProps {
|
|||||||
message?: Message
|
message?: Message
|
||||||
messages?: Message[]
|
messages?: Message[]
|
||||||
topic?: Topic
|
topic?: Topic
|
||||||
|
rawContent?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转换文件信息数组为树形结构
|
// 转换文件信息数组为树形结构
|
||||||
@@ -140,7 +141,8 @@ const PopupContainer: React.FC<PopupContainerProps> = ({
|
|||||||
resolve,
|
resolve,
|
||||||
message,
|
message,
|
||||||
messages,
|
messages,
|
||||||
topic
|
topic,
|
||||||
|
rawContent
|
||||||
}) => {
|
}) => {
|
||||||
const defaultObsidianVault = store.getState().settings.defaultObsidianVault
|
const defaultObsidianVault = store.getState().settings.defaultObsidianVault
|
||||||
const [state, setState] = useState({
|
const [state, setState] = useState({
|
||||||
@@ -229,7 +231,9 @@ const PopupContainer: React.FC<PopupContainerProps> = ({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
let markdown = ''
|
let markdown = ''
|
||||||
if (topic) {
|
if (rawContent) {
|
||||||
|
markdown = rawContent
|
||||||
|
} else if (topic) {
|
||||||
markdown = await topicToMarkdown(topic, exportReasoning)
|
markdown = await topicToMarkdown(topic, exportReasoning)
|
||||||
} else if (messages && messages.length > 0) {
|
} else if (messages && messages.length > 0) {
|
||||||
markdown = messagesToMarkdown(messages, exportReasoning)
|
markdown = messagesToMarkdown(messages, exportReasoning)
|
||||||
@@ -299,7 +303,6 @@ const PopupContainer: React.FC<PopupContainerProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title={i18n.t('chat.topics.export.obsidian_atributes')}
|
title={i18n.t('chat.topics.export.obsidian_atributes')}
|
||||||
@@ -410,9 +413,11 @@ const PopupContainer: React.FC<PopupContainerProps> = ({
|
|||||||
</Option>
|
</Option>
|
||||||
</Select>
|
</Select>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label={i18n.t('chat.topics.export.obsidian_reasoning')}>
|
{!rawContent && (
|
||||||
<Switch checked={exportReasoning} onChange={setExportReasoning} />
|
<Form.Item label={i18n.t('chat.topics.export.obsidian_reasoning')}>
|
||||||
</Form.Item>
|
<Switch checked={exportReasoning} onChange={setExportReasoning} />
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ interface ObsidianExportOptions {
|
|||||||
topic?: Topic
|
topic?: Topic
|
||||||
message?: Message
|
message?: Message
|
||||||
messages?: Message[]
|
messages?: Message[]
|
||||||
|
rawContent?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class ObsidianExportPopup {
|
export default class ObsidianExportPopup {
|
||||||
@@ -24,6 +25,7 @@ export default class ObsidianExportPopup {
|
|||||||
topic={options.topic}
|
topic={options.topic}
|
||||||
message={options.message}
|
message={options.message}
|
||||||
messages={options.messages}
|
messages={options.messages}
|
||||||
|
rawContent={options.rawContent}
|
||||||
obsidianTags={''}
|
obsidianTags={''}
|
||||||
open={true}
|
open={true}
|
||||||
resolve={(v) => {
|
resolve={(v) => {
|
||||||
|
|||||||
@@ -253,12 +253,39 @@ const PopupContainer: React.FC<Props> = ({ source, title, resolve }) => {
|
|||||||
let savedCount = 0
|
let savedCount = 0
|
||||||
|
|
||||||
try {
|
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) {
|
if (isNoteMode) {
|
||||||
const note = source.data as NotesTreeNode
|
const note = source.data as NotesTreeNode
|
||||||
const content = note.externalPath
|
if (!note.externalPath) {
|
||||||
? await window.api.file.readExternal(note.externalPath)
|
throw new Error('Note external path is required for export')
|
||||||
: await window.api.file.read(note.id + '.md')
|
}
|
||||||
logger.debug('Note content:', content)
|
|
||||||
|
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)
|
await addNote(content)
|
||||||
savedCount = 1
|
savedCount = 1
|
||||||
} else {
|
} else {
|
||||||
@@ -283,9 +310,23 @@ const PopupContainer: React.FC<Props> = ({ source, title, resolve }) => {
|
|||||||
resolve({ success: true, savedCount })
|
resolve({ success: true, savedCount })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('save failed:', error as 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)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,12 +55,15 @@ const PopupContainer: React.FC<Props> = ({ text, title, extension, resolve }) =>
|
|||||||
footer={null}>
|
footer={null}>
|
||||||
{extension !== undefined ? (
|
{extension !== undefined ? (
|
||||||
<Editor
|
<Editor
|
||||||
editable={false}
|
readOnly={true}
|
||||||
expanded={false}
|
expanded={false}
|
||||||
height="100%"
|
height="100%"
|
||||||
style={{ height: '100%' }}
|
style={{ height: '100%' }}
|
||||||
value={text}
|
value={text}
|
||||||
language={extension}
|
language={extension}
|
||||||
|
options={{
|
||||||
|
keymap: true
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Text>{text}</Text>
|
<Text>{text}</Text>
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ const RichEditor = ({
|
|||||||
enableContentSearch = false,
|
enableContentSearch = false,
|
||||||
isFullWidth = false,
|
isFullWidth = false,
|
||||||
fontFamily = 'default',
|
fontFamily = 'default',
|
||||||
fontSize = 16
|
fontSize = 16,
|
||||||
|
enableSpellCheck = false
|
||||||
// toolbarItems: _toolbarItems // TODO: Implement custom toolbar items
|
// toolbarItems: _toolbarItems // TODO: Implement custom toolbar items
|
||||||
}: RichEditorProps & { ref?: React.RefObject<RichEditorRef | null> }) => {
|
}: RichEditorProps & { ref?: React.RefObject<RichEditorRef | null> }) => {
|
||||||
// Use the rich editor hook for complete editor management
|
// Use the rich editor hook for complete editor management
|
||||||
@@ -71,6 +72,7 @@ const RichEditor = ({
|
|||||||
onBlur,
|
onBlur,
|
||||||
placeholder,
|
placeholder,
|
||||||
editable,
|
editable,
|
||||||
|
enableSpellCheck,
|
||||||
scrollParent: () => scrollContainerRef.current,
|
scrollParent: () => scrollContainerRef.current,
|
||||||
onShowTableActionMenu: ({ position, actions }) => {
|
onShowTableActionMenu: ({ position, actions }) => {
|
||||||
const iconMap: Record<string, React.ReactNode> = {
|
const iconMap: Record<string, React.ReactNode> = {
|
||||||
|
|||||||
@@ -14,6 +14,31 @@ export const RichEditorWrapper = styled.div<{
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: var(--color-background);
|
background: var(--color-background);
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
|
.ProseMirror table,
|
||||||
|
.tiptap table {
|
||||||
|
table-layout: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror table th,
|
||||||
|
.ProseMirror table td,
|
||||||
|
.tiptap th,
|
||||||
|
.tiptap td {
|
||||||
|
white-space: normal !important;
|
||||||
|
word-wrap: break-word !important;
|
||||||
|
word-break: break-word !important;
|
||||||
|
overflow-wrap: break-word !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
text-overflow: clip !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror table th > *,
|
||||||
|
.ProseMirror table td > *,
|
||||||
|
.tiptap td > *,
|
||||||
|
.tiptap th > * {
|
||||||
|
white-space: normal !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
text-overflow: clip !important;
|
||||||
|
}
|
||||||
width: ${({ $isFullWidth }) => ($isFullWidth ? '100%' : '60%')};
|
width: ${({ $isFullWidth }) => ($isFullWidth ? '100%' : '60%')};
|
||||||
margin: ${({ $isFullWidth }) => ($isFullWidth ? '0' : '0 auto')};
|
margin: ${({ $isFullWidth }) => ($isFullWidth ? '0' : '0 auto')};
|
||||||
font-family: ${({ $fontFamily }) => ($fontFamily === 'serif' ? 'var(--font-family-serif)' : 'var(--font-family)')};
|
font-family: ${({ $fontFamily }) => ($fontFamily === 'serif' ? 'var(--font-family-serif)' : 'var(--font-family)')};
|
||||||
@@ -21,6 +46,7 @@ export const RichEditorWrapper = styled.div<{
|
|||||||
|
|
||||||
${({ $minHeight }) => $minHeight && `min-height: ${$minHeight}px;`}
|
${({ $minHeight }) => $minHeight && `min-height: ${$minHeight}px;`}
|
||||||
${({ $maxHeight }) => $maxHeight && `max-height: ${$maxHeight}px;`}
|
${({ $maxHeight }) => $maxHeight && `max-height: ${$maxHeight}px;`}
|
||||||
|
|
||||||
`
|
`
|
||||||
|
|
||||||
export const ToolbarWrapper = styled.div`
|
export const ToolbarWrapper = styled.div`
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ export interface RichEditorProps {
|
|||||||
fontFamily?: 'default' | 'serif'
|
fontFamily?: 'default' | 'serif'
|
||||||
/** Font size in pixels */
|
/** Font size in pixels */
|
||||||
fontSize?: number
|
fontSize?: number
|
||||||
|
/** Whether to enable spell check */
|
||||||
|
enableSpellCheck?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolbarItem {
|
export interface ToolbarItem {
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ export interface UseRichEditorOptions {
|
|||||||
editable?: boolean
|
editable?: boolean
|
||||||
/** Whether to enable table of contents functionality */
|
/** Whether to enable table of contents functionality */
|
||||||
enableTableOfContents?: boolean
|
enableTableOfContents?: boolean
|
||||||
|
/** Whether to enable spell check */
|
||||||
|
enableSpellCheck?: boolean
|
||||||
/** Show table action menu (row/column) with concrete actions and position */
|
/** Show table action menu (row/column) with concrete actions and position */
|
||||||
onShowTableActionMenu?: (payload: {
|
onShowTableActionMenu?: (payload: {
|
||||||
type: 'row' | 'column'
|
type: 'row' | 'column'
|
||||||
@@ -126,6 +128,7 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
|
|||||||
previewLength = 50,
|
previewLength = 50,
|
||||||
placeholder = '',
|
placeholder = '',
|
||||||
editable = true,
|
editable = true,
|
||||||
|
enableSpellCheck = false,
|
||||||
onShowTableActionMenu,
|
onShowTableActionMenu,
|
||||||
scrollParent
|
scrollParent
|
||||||
} = options
|
} = options
|
||||||
@@ -410,7 +413,9 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
|
|||||||
// Allow text selection even when not editable
|
// Allow text selection even when not editable
|
||||||
style: editable
|
style: editable
|
||||||
? ''
|
? ''
|
||||||
: 'user-select: text; -webkit-user-select: text; -moz-user-select: text; -ms-user-select: text;'
|
: 'user-select: text; -webkit-user-select: text; -moz-user-select: text; -ms-user-select: text;',
|
||||||
|
// Set spellcheck attribute on the contenteditable element
|
||||||
|
spellcheck: enableSpellCheck ? 'true' : 'false'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onUpdate: ({ editor }) => {
|
onUpdate: ({ editor }) => {
|
||||||
|
|||||||
@@ -237,7 +237,17 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
|
|||||||
onSortEnd={onSortEnd}
|
onSortEnd={onSortEnd}
|
||||||
className="tabs-sortable"
|
className="tabs-sortable"
|
||||||
renderItem={(tab) => (
|
renderItem={(tab) => (
|
||||||
<Tab key={tab.id} active={tab.id === activeTabId} onClick={() => handleTabClick(tab)}>
|
<Tab
|
||||||
|
key={tab.id}
|
||||||
|
active={tab.id === activeTabId}
|
||||||
|
onClick={() => handleTabClick(tab)}
|
||||||
|
onAuxClick={(e) => {
|
||||||
|
if (e.button === 1 && tab.id !== 'home') {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
closeTab(tab.id)
|
||||||
|
}
|
||||||
|
}}>
|
||||||
<TabHeader>
|
<TabHeader>
|
||||||
{tab.id && <TabIcon>{getTabIcon(tab.id, minapps, minAppsCache)}</TabIcon>}
|
{tab.id && <TabIcon>{getTabIcon(tab.id, minapps, minAppsCache)}</TabIcon>}
|
||||||
<TabTitle>{getTabTitle(tab.id)}</TabTitle>
|
<TabTitle>{getTabTitle(tab.id)}</TabTitle>
|
||||||
|
|||||||
101
src/renderer/src/components/UpdateDialog.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, ScrollShadow } from '@heroui/react'
|
||||||
|
import { loggerService } from '@logger'
|
||||||
|
import { handleSaveData } from '@renderer/store'
|
||||||
|
import { ReleaseNoteInfo, UpdateInfo } from 'builder-util-runtime'
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import Markdown from 'react-markdown'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('UpdateDialog')
|
||||||
|
|
||||||
|
interface UpdateDialogProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
releaseInfo: UpdateInfo | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const UpdateDialog: React.FC<UpdateDialogProps> = ({ isOpen, onClose, releaseInfo }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [isInstalling, setIsInstalling] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && releaseInfo) {
|
||||||
|
logger.info('Update dialog opened', { version: releaseInfo.version })
|
||||||
|
}
|
||||||
|
}, [isOpen, releaseInfo])
|
||||||
|
|
||||||
|
const handleInstall = async () => {
|
||||||
|
setIsInstalling(true)
|
||||||
|
try {
|
||||||
|
await handleSaveData()
|
||||||
|
await window.api.quitAndInstall()
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to save data before update', error as Error)
|
||||||
|
setIsInstalling(false)
|
||||||
|
window.toast.error(t('update.saveDataError'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const releaseNotes = releaseInfo?.releaseNotes
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
size="2xl"
|
||||||
|
scrollBehavior="inside"
|
||||||
|
classNames={{
|
||||||
|
base: 'max-h-[85vh]',
|
||||||
|
header: 'border-b border-divider',
|
||||||
|
footer: 'border-t border-divider'
|
||||||
|
}}>
|
||||||
|
<ModalContent>
|
||||||
|
{(onModalClose) => (
|
||||||
|
<>
|
||||||
|
<ModalHeader className="flex flex-col gap-1">
|
||||||
|
<h3 className="font-semibold text-lg">{t('update.title')}</h3>
|
||||||
|
<p className="text-default-500 text-small">
|
||||||
|
{t('update.message').replace('{{version}}', releaseInfo?.version || '')}
|
||||||
|
</p>
|
||||||
|
</ModalHeader>
|
||||||
|
|
||||||
|
<ModalBody>
|
||||||
|
<ScrollShadow className="max-h-[450px]" hideScrollBar>
|
||||||
|
<div className="markdown rounded-lg bg-default-50 p-4">
|
||||||
|
<Markdown>
|
||||||
|
{typeof releaseNotes === 'string'
|
||||||
|
? releaseNotes
|
||||||
|
: Array.isArray(releaseNotes)
|
||||||
|
? releaseNotes
|
||||||
|
.map((note: ReleaseNoteInfo) => note.note)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n\n')
|
||||||
|
: t('update.noReleaseNotes')}
|
||||||
|
</Markdown>
|
||||||
|
</div>
|
||||||
|
</ScrollShadow>
|
||||||
|
</ModalBody>
|
||||||
|
|
||||||
|
<ModalFooter>
|
||||||
|
<Button variant="light" onPress={onModalClose} isDisabled={isInstalling}>
|
||||||
|
{t('update.later')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
onPress={async () => {
|
||||||
|
await handleInstall()
|
||||||
|
onModalClose()
|
||||||
|
}}
|
||||||
|
isLoading={isInstalling}>
|
||||||
|
{t('update.install')}
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UpdateDialog
|
||||||
@@ -39,6 +39,7 @@ import PoeAppLogo from '@renderer/assets/images/apps/poe.webp?url'
|
|||||||
import QwenlmAppLogo from '@renderer/assets/images/apps/qwenlm.webp?url'
|
import QwenlmAppLogo from '@renderer/assets/images/apps/qwenlm.webp?url'
|
||||||
import SensetimeAppLogo from '@renderer/assets/images/apps/sensetime.png?url'
|
import SensetimeAppLogo from '@renderer/assets/images/apps/sensetime.png?url'
|
||||||
import SparkDeskAppLogo from '@renderer/assets/images/apps/sparkdesk.webp?url'
|
import SparkDeskAppLogo from '@renderer/assets/images/apps/sparkdesk.webp?url'
|
||||||
|
import StepfunAppLogo from '@renderer/assets/images/apps/stepfun.png?url'
|
||||||
import ThinkAnyLogo from '@renderer/assets/images/apps/thinkany.webp?url'
|
import ThinkAnyLogo from '@renderer/assets/images/apps/thinkany.webp?url'
|
||||||
import TiangongAiLogo from '@renderer/assets/images/apps/tiangong.png?url'
|
import TiangongAiLogo from '@renderer/assets/images/apps/tiangong.png?url'
|
||||||
import WanZhiAppLogo from '@renderer/assets/images/apps/wanzhi.jpg?url'
|
import WanZhiAppLogo from '@renderer/assets/images/apps/wanzhi.jpg?url'
|
||||||
@@ -46,7 +47,6 @@ import WPSLingXiLogo from '@renderer/assets/images/apps/wpslingxi.webp?url'
|
|||||||
import XiaoYiAppLogo from '@renderer/assets/images/apps/xiaoyi.webp?url'
|
import XiaoYiAppLogo from '@renderer/assets/images/apps/xiaoyi.webp?url'
|
||||||
import YouLogo from '@renderer/assets/images/apps/you.jpg?url'
|
import YouLogo from '@renderer/assets/images/apps/you.jpg?url'
|
||||||
import TencentYuanbaoAppLogo from '@renderer/assets/images/apps/yuanbao.webp?url'
|
import TencentYuanbaoAppLogo from '@renderer/assets/images/apps/yuanbao.webp?url'
|
||||||
import YuewenAppLogo from '@renderer/assets/images/apps/yuewen.png?url'
|
|
||||||
import ZaiAppLogo from '@renderer/assets/images/apps/zai.png?url'
|
import ZaiAppLogo from '@renderer/assets/images/apps/zai.png?url'
|
||||||
import ZhihuAppLogo from '@renderer/assets/images/apps/zhihu.png?url'
|
import ZhihuAppLogo from '@renderer/assets/images/apps/zhihu.png?url'
|
||||||
import ClaudeAppLogo from '@renderer/assets/images/models/claude.png?url'
|
import ClaudeAppLogo from '@renderer/assets/images/models/claude.png?url'
|
||||||
@@ -145,14 +145,14 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
|
|||||||
{
|
{
|
||||||
id: 'dashscope',
|
id: 'dashscope',
|
||||||
name: i18n.t('minapps.qwen'),
|
name: i18n.t('minapps.qwen'),
|
||||||
url: 'https://tongyi.aliyun.com/qianwen/',
|
url: 'https://www.tongyi.com/',
|
||||||
logo: QwenModelLogo
|
logo: QwenModelLogo
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'stepfun',
|
id: 'stepfun',
|
||||||
name: i18n.t('minapps.yuewen'),
|
name: i18n.t('minapps.stepfun'),
|
||||||
url: 'https://yuewen.cn/chats/new',
|
url: 'https://stepfun.com',
|
||||||
logo: YuewenAppLogo,
|
logo: StepfunAppLogo,
|
||||||
bodered: true
|
bodered: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
|
|||||||
// Default quick assistant model
|
// Default quick assistant model
|
||||||
glm45FlashModel
|
glm45FlashModel
|
||||||
],
|
],
|
||||||
// cherryin: [],
|
cherryin: [],
|
||||||
vertexai: [],
|
vertexai: [],
|
||||||
'302ai': [
|
'302ai': [
|
||||||
{
|
{
|
||||||
@@ -260,6 +260,7 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
|
|||||||
{ id: 'deepseek-r1', name: 'DeepSeek-R1', provider: 'burncloud', group: 'deepseek-ai' },
|
{ id: 'deepseek-r1', name: 'DeepSeek-R1', provider: 'burncloud', group: 'deepseek-ai' },
|
||||||
{ id: 'deepseek-v3', name: 'DeepSeek-V3', provider: 'burncloud', group: 'deepseek-ai' }
|
{ id: 'deepseek-v3', name: 'DeepSeek-V3', provider: 'burncloud', group: 'deepseek-ai' }
|
||||||
],
|
],
|
||||||
|
ovms: [],
|
||||||
ollama: [],
|
ollama: [],
|
||||||
lmstudio: [],
|
lmstudio: [],
|
||||||
silicon: [
|
silicon: [
|
||||||
@@ -429,6 +430,12 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
anthropic: [
|
anthropic: [
|
||||||
|
{
|
||||||
|
id: 'claude-sonnet-4-5-20250929',
|
||||||
|
provider: 'anthropic',
|
||||||
|
name: 'Claude Sonnet 4.5',
|
||||||
|
group: 'Claude 4.5'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'claude-sonnet-4-20250514',
|
id: 'claude-sonnet-4-20250514',
|
||||||
provider: 'anthropic',
|
provider: 'anthropic',
|
||||||
@@ -697,6 +704,12 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
|
|||||||
name: 'GLM-4.5-Flash',
|
name: 'GLM-4.5-Flash',
|
||||||
group: 'GLM-4.5'
|
group: 'GLM-4.5'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'glm-4.6',
|
||||||
|
provider: 'zhipu',
|
||||||
|
name: 'GLM-4.6',
|
||||||
|
group: 'GLM-4.6'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'glm-4.5',
|
id: 'glm-4.5',
|
||||||
provider: 'zhipu',
|
provider: 'zhipu',
|
||||||
@@ -1804,5 +1817,19 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
|
|||||||
provider: 'aionly',
|
provider: 'aionly',
|
||||||
group: 'gemini'
|
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'
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ import ChatGPTImageModelLogo from '@renderer/assets/images/models/gpt_image_1.pn
|
|||||||
import ChatGPTo1ModelLogo from '@renderer/assets/images/models/gpt_o1.png'
|
import ChatGPTo1ModelLogo from '@renderer/assets/images/models/gpt_o1.png'
|
||||||
import GPT5ModelLogo from '@renderer/assets/images/models/gpt-5.png'
|
import GPT5ModelLogo from '@renderer/assets/images/models/gpt-5.png'
|
||||||
import GPT5ChatModelLogo from '@renderer/assets/images/models/gpt-5-chat.png'
|
import GPT5ChatModelLogo from '@renderer/assets/images/models/gpt-5-chat.png'
|
||||||
|
import GPT5CodexModelLogo from '@renderer/assets/images/models/gpt-5-codex.png'
|
||||||
import GPT5MiniModelLogo from '@renderer/assets/images/models/gpt-5-mini.png'
|
import GPT5MiniModelLogo from '@renderer/assets/images/models/gpt-5-mini.png'
|
||||||
import GPT5NanoModelLogo from '@renderer/assets/images/models/gpt-5-nano.png'
|
import GPT5NanoModelLogo from '@renderer/assets/images/models/gpt-5-nano.png'
|
||||||
import GrokModelLogo from '@renderer/assets/images/models/grok.png'
|
import GrokModelLogo from '@renderer/assets/images/models/grok.png'
|
||||||
@@ -162,6 +163,7 @@ export function getModelLogo(modelId: string) {
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// key is regex
|
||||||
const logoMap = {
|
const logoMap = {
|
||||||
pixtral: isLight ? PixtralModelLogo : PixtralModelLogoDark,
|
pixtral: isLight ? PixtralModelLogo : PixtralModelLogoDark,
|
||||||
jina: isLight ? JinaModelLogo : JinaModelLogoDark,
|
jina: isLight ? JinaModelLogo : JinaModelLogoDark,
|
||||||
@@ -177,6 +179,7 @@ export function getModelLogo(modelId: string) {
|
|||||||
'gpt-5-mini': GPT5MiniModelLogo,
|
'gpt-5-mini': GPT5MiniModelLogo,
|
||||||
'gpt-5-nano': GPT5NanoModelLogo,
|
'gpt-5-nano': GPT5NanoModelLogo,
|
||||||
'gpt-5-chat': GPT5ChatModelLogo,
|
'gpt-5-chat': GPT5ChatModelLogo,
|
||||||
|
'gpt-5-codex': GPT5CodexModelLogo,
|
||||||
'gpt-5': GPT5ModelLogo,
|
'gpt-5': GPT5ModelLogo,
|
||||||
gpts: isLight ? ChatGPT4ModelLogo : ChatGPT4ModelLogoDark,
|
gpts: isLight ? ChatGPT4ModelLogo : ChatGPT4ModelLogoDark,
|
||||||
'gpt-oss(?:-[\\w-]+)': isLight ? ChatGptModelLogo : ChatGptModelLogoDark,
|
'gpt-oss(?:-[\\w-]+)': isLight ? ChatGptModelLogo : ChatGptModelLogoDark,
|
||||||
@@ -286,7 +289,7 @@ export function getModelLogo(modelId: string) {
|
|||||||
longcat: LongCatAppLogo,
|
longcat: LongCatAppLogo,
|
||||||
bytedance: BytedanceModelLogo,
|
bytedance: BytedanceModelLogo,
|
||||||
'(V_1|V_1_TURBO|V_2|V_2A|V_2_TURBO|DESCRIBE|UPSCALE)': IdeogramModelLogo
|
'(V_1|V_1_TURBO|V_2|V_2A|V_2_TURBO|DESCRIBE|UPSCALE)': IdeogramModelLogo
|
||||||
}
|
} as const
|
||||||
|
|
||||||
for (const key in logoMap) {
|
for (const key in logoMap) {
|
||||||
const regex = new RegExp(key, 'i')
|
const regex = new RegExp(key, 'i')
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const MODEL_SUPPORTED_REASONING_EFFORT: ReasoningEffortConfig = {
|
|||||||
default: ['low', 'medium', 'high'] as const,
|
default: ['low', 'medium', 'high'] as const,
|
||||||
o: ['low', 'medium', 'high'] as const,
|
o: ['low', 'medium', 'high'] as const,
|
||||||
gpt5: ['minimal', 'low', 'medium', 'high'] as const,
|
gpt5: ['minimal', 'low', 'medium', 'high'] as const,
|
||||||
|
gpt5_codex: ['low', 'medium', 'high'] as const,
|
||||||
grok: ['low', 'high'] as const,
|
grok: ['low', 'high'] as const,
|
||||||
gemini: ['low', 'medium', 'high', 'auto'] as const,
|
gemini: ['low', 'medium', 'high', 'auto'] as const,
|
||||||
gemini_pro: ['low', 'medium', 'high', 'auto'] as const,
|
gemini_pro: ['low', 'medium', 'high', 'auto'] as const,
|
||||||
@@ -40,6 +41,7 @@ export const MODEL_SUPPORTED_OPTIONS: ThinkingOptionConfig = {
|
|||||||
default: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.default] as const,
|
default: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.default] as const,
|
||||||
o: MODEL_SUPPORTED_REASONING_EFFORT.o,
|
o: MODEL_SUPPORTED_REASONING_EFFORT.o,
|
||||||
gpt5: [...MODEL_SUPPORTED_REASONING_EFFORT.gpt5] as const,
|
gpt5: [...MODEL_SUPPORTED_REASONING_EFFORT.gpt5] as const,
|
||||||
|
gpt5_codex: MODEL_SUPPORTED_REASONING_EFFORT.gpt5_codex,
|
||||||
grok: MODEL_SUPPORTED_REASONING_EFFORT.grok,
|
grok: MODEL_SUPPORTED_REASONING_EFFORT.grok,
|
||||||
gemini: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.gemini] as const,
|
gemini: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.gemini] as const,
|
||||||
gemini_pro: MODEL_SUPPORTED_REASONING_EFFORT.gemini_pro,
|
gemini_pro: MODEL_SUPPORTED_REASONING_EFFORT.gemini_pro,
|
||||||
@@ -55,8 +57,13 @@ export const MODEL_SUPPORTED_OPTIONS: ThinkingOptionConfig = {
|
|||||||
|
|
||||||
export const getThinkModelType = (model: Model): ThinkingModelType => {
|
export const getThinkModelType = (model: Model): ThinkingModelType => {
|
||||||
let thinkingModelType: ThinkingModelType = 'default'
|
let thinkingModelType: ThinkingModelType = 'default'
|
||||||
|
const modelId = getLowerBaseModelName(model.id)
|
||||||
if (isGPT5SeriesModel(model)) {
|
if (isGPT5SeriesModel(model)) {
|
||||||
thinkingModelType = 'gpt5'
|
if (modelId.includes('codex')) {
|
||||||
|
thinkingModelType = 'gpt5_codex'
|
||||||
|
} else {
|
||||||
|
thinkingModelType = 'gpt5'
|
||||||
|
}
|
||||||
} else if (isSupportedReasoningEffortOpenAIModel(model)) {
|
} else if (isSupportedReasoningEffortOpenAIModel(model)) {
|
||||||
thinkingModelType = 'o'
|
thinkingModelType = 'o'
|
||||||
} else if (isSupportedThinkingTokenGeminiModel(model)) {
|
} else if (isSupportedThinkingTokenGeminiModel(model)) {
|
||||||
@@ -171,9 +178,13 @@ export function isGeminiReasoningModel(model?: Model): boolean {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Gemini 支持思考模式的模型正则
|
||||||
|
export const GEMINI_THINKING_MODEL_REGEX =
|
||||||
|
/gemini-(?:2\.5.*(?:-latest)?|flash-latest|pro-latest|flash-lite-latest)(?:-[\w-]+)*$/i
|
||||||
|
|
||||||
export const isSupportedThinkingTokenGeminiModel = (model: Model): boolean => {
|
export const isSupportedThinkingTokenGeminiModel = (model: Model): boolean => {
|
||||||
const modelId = getLowerBaseModelName(model.id, '/')
|
const modelId = getLowerBaseModelName(model.id, '/')
|
||||||
if (modelId.includes('gemini-2.5')) {
|
if (GEMINI_THINKING_MODEL_REGEX.test(modelId)) {
|
||||||
if (modelId.includes('image') || modelId.includes('tts')) {
|
if (modelId.includes('image') || modelId.includes('tts')) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -328,14 +339,20 @@ export const isSupportedReasoningEffortPerplexityModel = (model: Model): boolean
|
|||||||
|
|
||||||
export const isSupportedThinkingTokenZhipuModel = (model: Model): boolean => {
|
export const isSupportedThinkingTokenZhipuModel = (model: Model): boolean => {
|
||||||
const modelId = getLowerBaseModelName(model.id, '/')
|
const modelId = getLowerBaseModelName(model.id, '/')
|
||||||
return modelId.includes('glm-4.5')
|
return ['glm-4.5', 'glm-4.6'].some((id) => modelId.includes(id))
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isDeepSeekHybridInferenceModel = (model: Model) => {
|
export const isDeepSeekHybridInferenceModel = (model: Model) => {
|
||||||
const modelId = getLowerBaseModelName(model.id)
|
const modelId = getLowerBaseModelName(model.id)
|
||||||
// deepseek官方使用chat和reasoner做推理控制,其他provider需要单独判断,id可能会有所差别
|
// deepseek官方使用chat和reasoner做推理控制,其他provider需要单独判断,id可能会有所差别
|
||||||
// openrouter: deepseek/deepseek-chat-v3.1 不知道会不会有其他provider仿照ds官方分出一个同id的作为非思考模式的模型,这里有风险
|
// openrouter: deepseek/deepseek-chat-v3.1 不知道会不会有其他provider仿照ds官方分出一个同id的作为非思考模式的模型,这里有风险
|
||||||
return /deepseek-v3(?:\.1|-1-\d+)?/.test(modelId) || modelId.includes('deepseek-chat-v3.1')
|
// Matches: "deepseek-v3" followed by ".digit" or "-digit".
|
||||||
|
// Optionally, this can be followed by ".alphanumeric_sequence" or "-alphanumeric_sequence"
|
||||||
|
// until the end of the string.
|
||||||
|
// Examples: deepseek-v3.1, deepseek-v3-1, deepseek-v3.1.2, deepseek-v3.1-alpha
|
||||||
|
// Does NOT match: deepseek-v3.123 (missing separator after '1'), deepseek-v3.x (x isn't a digit)
|
||||||
|
// TODO: move to utils and add test cases
|
||||||
|
return /deepseek-v3(?:\.\d|-\d)(?:(\.|-)\w+)?$/.test(modelId) || modelId.includes('deepseek-chat-v3.1')
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isSupportedThinkingTokenDeepSeekModel = isDeepSeekHybridInferenceModel
|
export const isSupportedThinkingTokenDeepSeekModel = isDeepSeekHybridInferenceModel
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const visionAllowedModels = [
|
|||||||
'gemini-1\\.5',
|
'gemini-1\\.5',
|
||||||
'gemini-2\\.0',
|
'gemini-2\\.0',
|
||||||
'gemini-2\\.5',
|
'gemini-2\\.5',
|
||||||
|
'gemini-(flash|pro|flash-lite)-latest',
|
||||||
'gemini-exp',
|
'gemini-exp',
|
||||||
'claude-3',
|
'claude-3',
|
||||||
'claude-sonnet-4',
|
'claude-sonnet-4',
|
||||||
@@ -21,7 +22,9 @@ const visionAllowedModels = [
|
|||||||
'qwen-vl',
|
'qwen-vl',
|
||||||
'qwen2-vl',
|
'qwen2-vl',
|
||||||
'qwen2.5-vl',
|
'qwen2.5-vl',
|
||||||
|
'qwen3-vl',
|
||||||
'qwen2.5-omni',
|
'qwen2.5-omni',
|
||||||
|
'qwen3-omni',
|
||||||
'qvq',
|
'qvq',
|
||||||
'internvl2',
|
'internvl2',
|
||||||
'grok-vision-beta',
|
'grok-vision-beta',
|
||||||
|
|||||||
@@ -11,9 +11,12 @@ export const CLAUDE_SUPPORTED_WEBSEARCH_REGEX = new RegExp(
|
|||||||
'i'
|
'i'
|
||||||
)
|
)
|
||||||
|
|
||||||
export const GEMINI_FLASH_MODEL_REGEX = new RegExp('gemini-.*-flash.*$')
|
export const GEMINI_FLASH_MODEL_REGEX = new RegExp('gemini.*-flash.*$')
|
||||||
|
|
||||||
export const GEMINI_SEARCH_REGEX = new RegExp('gemini-2\\..*', 'i')
|
export const GEMINI_SEARCH_REGEX = new RegExp(
|
||||||
|
'gemini-(?:2.*(?:-latest)?|flash-latest|pro-latest|flash-lite-latest)(?:-[\\w-]+)*$',
|
||||||
|
'i'
|
||||||
|
)
|
||||||
|
|
||||||
export const PERPLEXITY_SEARCH_MODELS = [
|
export const PERPLEXITY_SEARCH_MODELS = [
|
||||||
'sonar-pro',
|
'sonar-pro',
|
||||||
|
|||||||
@@ -24,9 +24,11 @@ import GrokProviderLogo from '@renderer/assets/images/providers/grok.png'
|
|||||||
import GroqProviderLogo from '@renderer/assets/images/providers/groq.png'
|
import GroqProviderLogo from '@renderer/assets/images/providers/groq.png'
|
||||||
import HyperbolicProviderLogo from '@renderer/assets/images/providers/hyperbolic.png'
|
import HyperbolicProviderLogo from '@renderer/assets/images/providers/hyperbolic.png'
|
||||||
import InfiniProviderLogo from '@renderer/assets/images/providers/infini.png'
|
import InfiniProviderLogo from '@renderer/assets/images/providers/infini.png'
|
||||||
|
import IntelOvmsLogo from '@renderer/assets/images/providers/intel.png'
|
||||||
import JinaProviderLogo from '@renderer/assets/images/providers/jina.png'
|
import JinaProviderLogo from '@renderer/assets/images/providers/jina.png'
|
||||||
import LanyunProviderLogo from '@renderer/assets/images/providers/lanyun.png'
|
import LanyunProviderLogo from '@renderer/assets/images/providers/lanyun.png'
|
||||||
import LMStudioProviderLogo from '@renderer/assets/images/providers/lmstudio.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 MinimaxProviderLogo from '@renderer/assets/images/providers/minimax.png'
|
||||||
import MistralProviderLogo from '@renderer/assets/images/providers/mistral.png'
|
import MistralProviderLogo from '@renderer/assets/images/providers/mistral.png'
|
||||||
import ModelScopeProviderLogo from '@renderer/assets/images/providers/modelscope.png'
|
import ModelScopeProviderLogo from '@renderer/assets/images/providers/modelscope.png'
|
||||||
@@ -78,16 +80,16 @@ export const CHERRYAI_PROVIDER: SystemProvider = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> = {
|
export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> = {
|
||||||
// cherryin: {
|
cherryin: {
|
||||||
// id: 'cherryin',
|
id: 'cherryin',
|
||||||
// name: 'CherryIN',
|
name: 'CherryIN',
|
||||||
// type: 'openai',
|
type: 'openai',
|
||||||
// apiKey: '',
|
apiKey: '',
|
||||||
// apiHost: 'https://open.cherryin.ai',
|
apiHost: 'https://open.cherryin.net',
|
||||||
// models: [],
|
models: [],
|
||||||
// isSystem: true,
|
isSystem: true,
|
||||||
// enabled: true
|
enabled: true
|
||||||
// },
|
},
|
||||||
silicon: {
|
silicon: {
|
||||||
id: 'silicon',
|
id: 'silicon',
|
||||||
name: 'Silicon',
|
name: 'Silicon',
|
||||||
@@ -108,6 +110,16 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
|
|||||||
isSystem: true,
|
isSystem: true,
|
||||||
enabled: false
|
enabled: false
|
||||||
},
|
},
|
||||||
|
ovms: {
|
||||||
|
id: 'ovms',
|
||||||
|
name: 'OpenVINO Model Server',
|
||||||
|
type: 'openai',
|
||||||
|
apiKey: '',
|
||||||
|
apiHost: 'http://localhost:8000/v3/',
|
||||||
|
models: SYSTEM_MODELS.ovms,
|
||||||
|
isSystem: true,
|
||||||
|
enabled: false
|
||||||
|
},
|
||||||
ocoolai: {
|
ocoolai: {
|
||||||
id: 'ocoolai',
|
id: 'ocoolai',
|
||||||
name: 'ocoolAI',
|
name: 'ocoolAI',
|
||||||
@@ -622,6 +634,16 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
|
|||||||
models: SYSTEM_MODELS['poe'],
|
models: SYSTEM_MODELS['poe'],
|
||||||
isSystem: true,
|
isSystem: true,
|
||||||
enabled: false
|
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
|
} as const
|
||||||
|
|
||||||
@@ -638,6 +660,7 @@ export const PROVIDER_LOGO_MAP: AtLeast<SystemProviderId, string> = {
|
|||||||
yi: ZeroOneProviderLogo,
|
yi: ZeroOneProviderLogo,
|
||||||
groq: GroqProviderLogo,
|
groq: GroqProviderLogo,
|
||||||
zhipu: ZhipuProviderLogo,
|
zhipu: ZhipuProviderLogo,
|
||||||
|
ovms: IntelOvmsLogo,
|
||||||
ollama: OllamaProviderLogo,
|
ollama: OllamaProviderLogo,
|
||||||
lmstudio: LMStudioProviderLogo,
|
lmstudio: LMStudioProviderLogo,
|
||||||
moonshot: MoonshotProviderLogo,
|
moonshot: MoonshotProviderLogo,
|
||||||
@@ -684,7 +707,8 @@ export const PROVIDER_LOGO_MAP: AtLeast<SystemProviderId, string> = {
|
|||||||
'new-api': NewAPIProviderLogo,
|
'new-api': NewAPIProviderLogo,
|
||||||
'aws-bedrock': AwsProviderLogo,
|
'aws-bedrock': AwsProviderLogo,
|
||||||
poe: 'poe', // use svg icon component
|
poe: 'poe', // use svg icon component
|
||||||
aionly: AiOnlyProviderLogo
|
aionly: AiOnlyProviderLogo,
|
||||||
|
longcat: LongCatProviderLogo
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export function getProviderLogo(providerId: string) {
|
export function getProviderLogo(providerId: string) {
|
||||||
@@ -708,17 +732,17 @@ type ProviderUrls = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const PROVIDER_URLS: Record<SystemProviderId, ProviderUrls> = {
|
export const PROVIDER_URLS: Record<SystemProviderId, ProviderUrls> = {
|
||||||
// cherryin: {
|
cherryin: {
|
||||||
// api: {
|
api: {
|
||||||
// url: 'https://open.cherryin.ai'
|
url: 'https://open.cherryin.net'
|
||||||
// },
|
},
|
||||||
// websites: {
|
websites: {
|
||||||
// official: 'https://open.cherryin.ai',
|
official: 'https://open.cherryin.ai',
|
||||||
// apiKey: 'https://open.cherryin.ai/console/token',
|
apiKey: 'https://open.cherryin.ai/console/token',
|
||||||
// docs: 'https://open.cherryin.ai',
|
docs: 'https://open.cherryin.ai',
|
||||||
// models: 'https://open.cherryin.ai/pricing'
|
models: 'https://open.cherryin.ai/pricing'
|
||||||
// }
|
}
|
||||||
// },
|
},
|
||||||
ph8: {
|
ph8: {
|
||||||
api: {
|
api: {
|
||||||
url: 'https://ph8.co'
|
url: 'https://ph8.co'
|
||||||
@@ -1022,6 +1046,16 @@ export const PROVIDER_URLS: Record<SystemProviderId, ProviderUrls> = {
|
|||||||
models: 'https://console.groq.com/docs/models'
|
models: 'https://console.groq.com/docs/models'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
ovms: {
|
||||||
|
api: {
|
||||||
|
url: 'http://localhost:8000/v3/'
|
||||||
|
},
|
||||||
|
websites: {
|
||||||
|
official: 'https://www.intel.com/content/www/us/en/developer/tools/openvino-toolkit/overview.html',
|
||||||
|
docs: 'https://docs.openvino.ai/2025/model-server/ovms_what_is_openvino_model_server.html',
|
||||||
|
models: 'https://www.modelscope.cn/organization/OpenVINO'
|
||||||
|
}
|
||||||
|
},
|
||||||
ollama: {
|
ollama: {
|
||||||
api: {
|
api: {
|
||||||
url: 'http://localhost:11434'
|
url: 'http://localhost:11434'
|
||||||
@@ -1290,6 +1324,17 @@ export const PROVIDER_URLS: Record<SystemProviderId, ProviderUrls> = {
|
|||||||
docs: 'https://www.aiionly.com/document',
|
docs: 'https://www.aiionly.com/document',
|
||||||
models: 'https://www.aiionly.com'
|
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 {
|
|||||||
} from '@renderer/types'
|
} from '@renderer/types'
|
||||||
// Import necessary types for blocks and new message structure
|
// Import necessary types for blocks and new message structure
|
||||||
import type { Message as NewMessage, MessageBlock } from '@renderer/types/newMessage'
|
import type { Message as NewMessage, MessageBlock } from '@renderer/types/newMessage'
|
||||||
import { NotesTreeNode } from '@renderer/types/note'
|
|
||||||
import { Dexie, type EntityTable } from 'dexie'
|
import { Dexie, type EntityTable } from 'dexie'
|
||||||
|
|
||||||
import { upgradeToV5, upgradeToV7, upgradeToV8 } from './upgrades'
|
import { upgradeToV5, upgradeToV7, upgradeToV8 } from './upgrades'
|
||||||
@@ -24,7 +23,6 @@ export const db = new Dexie('CherryStudio', {
|
|||||||
quick_phrases: EntityTable<QuickPhrase, 'id'>
|
quick_phrases: EntityTable<QuickPhrase, 'id'>
|
||||||
message_blocks: EntityTable<MessageBlock, 'id'> // Correct type for message_blocks
|
message_blocks: EntityTable<MessageBlock, 'id'> // Correct type for message_blocks
|
||||||
translate_languages: EntityTable<CustomTranslateLanguage, 'id'>
|
translate_languages: EntityTable<CustomTranslateLanguage, 'id'>
|
||||||
notes_tree: EntityTable<{ id: string; tree: NotesTreeNode[] }, 'id'>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
db.version(1).stores({
|
db.version(1).stores({
|
||||||
@@ -118,8 +116,7 @@ db.version(10).stores({
|
|||||||
translate_history: '&id, sourceText, targetText, sourceLanguage, targetLanguage, createdAt',
|
translate_history: '&id, sourceText, targetText, sourceLanguage, targetLanguage, createdAt',
|
||||||
translate_languages: '&id, langCode',
|
translate_languages: '&id, langCode',
|
||||||
quick_phrases: 'id',
|
quick_phrases: 'id',
|
||||||
message_blocks: 'id, messageId, file.id',
|
message_blocks: 'id, messageId, file.id'
|
||||||
notes_tree: '&id'
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export default db
|
export default db
|
||||||
|
|||||||
@@ -108,7 +108,11 @@ export const useCodeTools = () => {
|
|||||||
const environmentVariables = codeToolsState?.environmentVariables?.[codeToolsState.selectedCliTool] || ''
|
const environmentVariables = codeToolsState?.environmentVariables?.[codeToolsState.selectedCliTool] || ''
|
||||||
|
|
||||||
// 检查是否可以启动(所有必需字段都已填写)
|
// 检查是否可以启动(所有必需字段都已填写)
|
||||||
const canLaunch = Boolean(codeToolsState.selectedCliTool && selectedModel && codeToolsState.currentDirectory)
|
const canLaunch = Boolean(
|
||||||
|
codeToolsState.selectedCliTool &&
|
||||||
|
codeToolsState.currentDirectory &&
|
||||||
|
(codeToolsState.selectedCliTool === codeTools.githubCopilotCli || selectedModel)
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// 状态
|
// 状态
|
||||||
|
|||||||
@@ -360,7 +360,7 @@ export const useKnowledgeBases = () => {
|
|||||||
const deleteKnowledgeBase = (baseId: string) => {
|
const deleteKnowledgeBase = (baseId: string) => {
|
||||||
const base = bases.find((b) => b.id === baseId)
|
const base = bases.find((b) => b.id === baseId)
|
||||||
if (!base) return
|
if (!base) return
|
||||||
dispatch(deleteBase({ baseId, baseParams: getKnowledgeBaseParams(base) }))
|
dispatch(deleteBase({ baseId }))
|
||||||
|
|
||||||
// remove assistant knowledge_base
|
// remove assistant knowledge_base
|
||||||
const _assistants = assistants.map((assistant) => {
|
const _assistants = assistants.map((assistant) => {
|
||||||
|
|||||||
@@ -48,6 +48,17 @@ export function useActiveTopic(assistantId: string, topic?: Topic) {
|
|||||||
}
|
}
|
||||||
}, [activeTopic?.id, assistant])
|
}, [activeTopic?.id, assistant])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!assistant?.topics?.length || !activeTopic) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestTopic = assistant.topics.find((item) => item.id === activeTopic.id)
|
||||||
|
if (latestTopic && latestTopic !== activeTopic) {
|
||||||
|
setActiveTopic(latestTopic)
|
||||||
|
}
|
||||||
|
}, [assistant?.topics, activeTopic])
|
||||||
|
|
||||||
return { activeTopic, setActiveTopic }
|
return { activeTopic, setActiveTopic }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ const providerKeyMap = {
|
|||||||
nvidia: 'provider.nvidia',
|
nvidia: 'provider.nvidia',
|
||||||
o3: 'provider.o3',
|
o3: 'provider.o3',
|
||||||
ocoolai: 'provider.ocoolai',
|
ocoolai: 'provider.ocoolai',
|
||||||
|
ovms: 'provider.ovms',
|
||||||
ollama: 'provider.ollama',
|
ollama: 'provider.ollama',
|
||||||
openai: 'provider.openai',
|
openai: 'provider.openai',
|
||||||
openrouter: 'provider.openrouter',
|
openrouter: 'provider.openrouter',
|
||||||
|
|||||||
@@ -251,6 +251,7 @@
|
|||||||
"added": "Added",
|
"added": "Added",
|
||||||
"case_sensitive": "Case Sensitive",
|
"case_sensitive": "Case Sensitive",
|
||||||
"collapse": "Collapse",
|
"collapse": "Collapse",
|
||||||
|
"download": "Download",
|
||||||
"includes_user_questions": "Include Your Questions",
|
"includes_user_questions": "Include Your Questions",
|
||||||
"manage": "Manage",
|
"manage": "Manage",
|
||||||
"select_model": "Select Model",
|
"select_model": "Select Model",
|
||||||
@@ -1582,13 +1583,13 @@
|
|||||||
"nami-ai-search": "Nami AI Search",
|
"nami-ai-search": "Nami AI Search",
|
||||||
"qwen": "Qwen",
|
"qwen": "Qwen",
|
||||||
"sensechat": "SenseChat",
|
"sensechat": "SenseChat",
|
||||||
|
"stepfun": "Stepfun",
|
||||||
"tencent-yuanbao": "Yuanbao",
|
"tencent-yuanbao": "Yuanbao",
|
||||||
"tiangong-ai": "Skywork",
|
"tiangong-ai": "Skywork",
|
||||||
"wanzhi": "Wanzhi",
|
"wanzhi": "Wanzhi",
|
||||||
"wenxin": "ERNIE",
|
"wenxin": "ERNIE",
|
||||||
"wps-copilot": "WPS Copilot",
|
"wps-copilot": "WPS Copilot",
|
||||||
"xiaoyi": "Xiaoyi",
|
"xiaoyi": "Xiaoyi",
|
||||||
"yuewen": "Yuewen",
|
|
||||||
"zhihu": "Zhihu"
|
"zhihu": "Zhihu"
|
||||||
},
|
},
|
||||||
"miniwindow": {
|
"miniwindow": {
|
||||||
@@ -1696,6 +1697,12 @@
|
|||||||
"provider_settings": "Go to provider settings"
|
"provider_settings": "Go to provider settings"
|
||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
|
"auto_rename": {
|
||||||
|
"empty_note": "Note is empty, cannot generate name",
|
||||||
|
"failed": "Failed to generate note name",
|
||||||
|
"label": "Generate Note Name",
|
||||||
|
"success": "Note name generated successfully"
|
||||||
|
},
|
||||||
"characters": "Characters",
|
"characters": "Characters",
|
||||||
"collapse": "Collapse",
|
"collapse": "Collapse",
|
||||||
"content_placeholder": "Please enter the note content...",
|
"content_placeholder": "Please enter the note content...",
|
||||||
@@ -1777,6 +1784,8 @@
|
|||||||
"sort_updated_asc": "Update time (oldest first)",
|
"sort_updated_asc": "Update time (oldest first)",
|
||||||
"sort_updated_desc": "Update time (newest first)",
|
"sort_updated_desc": "Update time (newest first)",
|
||||||
"sort_z2a": "File name (Z-A)",
|
"sort_z2a": "File name (Z-A)",
|
||||||
|
"spell_check": "Spell Check",
|
||||||
|
"spell_check_tooltip": "Enable/Disable spell check",
|
||||||
"star": "Favorite note",
|
"star": "Favorite note",
|
||||||
"starred_notes": "Collected notes",
|
"starred_notes": "Collected notes",
|
||||||
"title": "Notes",
|
"title": "Notes",
|
||||||
@@ -1826,6 +1835,57 @@
|
|||||||
},
|
},
|
||||||
"title": "Ollama"
|
"title": "Ollama"
|
||||||
},
|
},
|
||||||
|
"ovms": {
|
||||||
|
"action": {
|
||||||
|
"install": "Install",
|
||||||
|
"installing": "Installing",
|
||||||
|
"reinstall": "Re-Install",
|
||||||
|
"run": "Run OVMS",
|
||||||
|
"starting": "Starting",
|
||||||
|
"stop": "Stop OVMS",
|
||||||
|
"stopping": "Stopping"
|
||||||
|
},
|
||||||
|
"description": "<div><p>1. Download OV Models.</p><p>2. Add Models in 'Manager'.</p><p>Support Windows Only!</p><p>OVMS Install Path: '%USERPROFILE%\\.cherrystudio\\ovms' .</p><p>Please refer to <a href=https://github.com/openvinotoolkit/model_server/blob/c55551763d02825829337b62c2dcef9339706f79/docs/deploying_server_baremetal.md>Intel OVMS Guide</a></p></dev>",
|
||||||
|
"download": {
|
||||||
|
"button": "Download",
|
||||||
|
"error": "Download Error",
|
||||||
|
"model_id": {
|
||||||
|
"label": "Model ID:",
|
||||||
|
"model_id_pattern": "Model ID must start with OpenVINO/",
|
||||||
|
"placeholder": "Required e.g. OpenVINO/Qwen3-8B-int4-ov",
|
||||||
|
"required": "Please enter the model ID"
|
||||||
|
},
|
||||||
|
"model_name": {
|
||||||
|
"label": "Model Name:",
|
||||||
|
"placeholder": "Required e.g. Qwen3-8B-int4-ov",
|
||||||
|
"required": "Please enter the model name"
|
||||||
|
},
|
||||||
|
"model_source": "Model Source:",
|
||||||
|
"model_task": "Model Task:",
|
||||||
|
"success": "Download successful",
|
||||||
|
"success_desc": "Model \"{{modelName}}\"-\"{{modelId}}\" downloaded successfully, please go to the OVMS management interface to add the model",
|
||||||
|
"tip": "The model is downloading, sometimes it takes hours. Please be patient...",
|
||||||
|
"title": "Download Intel OpenVINO Model"
|
||||||
|
},
|
||||||
|
"failed": {
|
||||||
|
"install": "Install OVMS failed:",
|
||||||
|
"install_code_100": "Unknown Error",
|
||||||
|
"install_code_101": "Only supports Intel(R) Core(TM) Ultra CPU",
|
||||||
|
"install_code_102": "Only supports Windows",
|
||||||
|
"install_code_103": "Download OVMS runtime failed",
|
||||||
|
"install_code_104": "Uncompress OVMS runtime failed",
|
||||||
|
"install_code_105": "Clean OVMS runtime failed",
|
||||||
|
"run": "Run OVMS failed:",
|
||||||
|
"stop": "Stop OVMS failed:"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"not_installed": "OVMS is not installed",
|
||||||
|
"not_running": "OVMS is not running",
|
||||||
|
"running": "OVMS is running",
|
||||||
|
"unknown": "OVMS status unknown"
|
||||||
|
},
|
||||||
|
"title": "Intel OVMS"
|
||||||
|
},
|
||||||
"paintings": {
|
"paintings": {
|
||||||
"aspect_ratio": "Aspect Ratio",
|
"aspect_ratio": "Aspect Ratio",
|
||||||
"aspect_ratios": {
|
"aspect_ratios": {
|
||||||
@@ -2057,6 +2117,7 @@
|
|||||||
"ollama": "Ollama",
|
"ollama": "Ollama",
|
||||||
"openai": "OpenAI",
|
"openai": "OpenAI",
|
||||||
"openrouter": "OpenRouter",
|
"openrouter": "OpenRouter",
|
||||||
|
"ovms": "Intel OVMS",
|
||||||
"perplexity": "Perplexity",
|
"perplexity": "Perplexity",
|
||||||
"ph8": "PH8",
|
"ph8": "PH8",
|
||||||
"poe": "Poe",
|
"poe": "Poe",
|
||||||
@@ -4351,6 +4412,7 @@
|
|||||||
"later": "Later",
|
"later": "Later",
|
||||||
"message": "New version {{version}} is ready, do you want to install it now?",
|
"message": "New version {{version}} is ready, do you want to install it now?",
|
||||||
"noReleaseNotes": "No release notes",
|
"noReleaseNotes": "No release notes",
|
||||||
|
"saveDataError": "Failed to save data, please try again.",
|
||||||
"title": "Update"
|
"title": "Update"
|
||||||
},
|
},
|
||||||
"warning": {
|
"warning": {
|
||||||
|
|||||||
@@ -251,6 +251,7 @@
|
|||||||
"added": "已添加",
|
"added": "已添加",
|
||||||
"case_sensitive": "区分大小写",
|
"case_sensitive": "区分大小写",
|
||||||
"collapse": "收起",
|
"collapse": "收起",
|
||||||
|
"download": "下载",
|
||||||
"includes_user_questions": "包含用户提问",
|
"includes_user_questions": "包含用户提问",
|
||||||
"manage": "管理",
|
"manage": "管理",
|
||||||
"select_model": "选择模型",
|
"select_model": "选择模型",
|
||||||
@@ -1582,13 +1583,13 @@
|
|||||||
"nami-ai-search": "纳米AI搜索",
|
"nami-ai-search": "纳米AI搜索",
|
||||||
"qwen": "通义千问",
|
"qwen": "通义千问",
|
||||||
"sensechat": "商量",
|
"sensechat": "商量",
|
||||||
|
"stepfun": "阶跃AI",
|
||||||
"tencent-yuanbao": "腾讯元宝",
|
"tencent-yuanbao": "腾讯元宝",
|
||||||
"tiangong-ai": "天工AI",
|
"tiangong-ai": "天工AI",
|
||||||
"wanzhi": "万知",
|
"wanzhi": "万知",
|
||||||
"wenxin": "文心一言",
|
"wenxin": "文心一言",
|
||||||
"wps-copilot": "WPS灵犀",
|
"wps-copilot": "WPS灵犀",
|
||||||
"xiaoyi": "小艺",
|
"xiaoyi": "小艺",
|
||||||
"yuewen": "跃问",
|
|
||||||
"zhihu": "知乎直答"
|
"zhihu": "知乎直答"
|
||||||
},
|
},
|
||||||
"miniwindow": {
|
"miniwindow": {
|
||||||
@@ -1696,6 +1697,12 @@
|
|||||||
"provider_settings": "跳转到服务商设置界面"
|
"provider_settings": "跳转到服务商设置界面"
|
||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
|
"auto_rename": {
|
||||||
|
"empty_note": "笔记为空,无法生成名称",
|
||||||
|
"failed": "生成笔记名称失败",
|
||||||
|
"label": "生成笔记名称",
|
||||||
|
"success": "笔记名称生成成功"
|
||||||
|
},
|
||||||
"characters": "字符",
|
"characters": "字符",
|
||||||
"collapse": "收起",
|
"collapse": "收起",
|
||||||
"content_placeholder": "请输入笔记内容...",
|
"content_placeholder": "请输入笔记内容...",
|
||||||
@@ -1777,6 +1784,8 @@
|
|||||||
"sort_updated_asc": "更新时间(从旧到新)",
|
"sort_updated_asc": "更新时间(从旧到新)",
|
||||||
"sort_updated_desc": "更新时间(从新到旧)",
|
"sort_updated_desc": "更新时间(从新到旧)",
|
||||||
"sort_z2a": "文件名(Z-A)",
|
"sort_z2a": "文件名(Z-A)",
|
||||||
|
"spell_check": "拼写检查",
|
||||||
|
"spell_check_tooltip": "启用/禁用拼写检查",
|
||||||
"star": "收藏笔记",
|
"star": "收藏笔记",
|
||||||
"starred_notes": "收藏的笔记",
|
"starred_notes": "收藏的笔记",
|
||||||
"title": "笔记",
|
"title": "笔记",
|
||||||
@@ -1826,6 +1835,57 @@
|
|||||||
},
|
},
|
||||||
"title": "Ollama"
|
"title": "Ollama"
|
||||||
},
|
},
|
||||||
|
"ovms": {
|
||||||
|
"action": {
|
||||||
|
"install": "安装",
|
||||||
|
"installing": "正在安装",
|
||||||
|
"reinstall": "重装",
|
||||||
|
"run": "运行 OVMS",
|
||||||
|
"starting": "启动中",
|
||||||
|
"stop": "停止 OVMS",
|
||||||
|
"stopping": "停止中"
|
||||||
|
},
|
||||||
|
"description": "<div><p>1. 下载 OV 模型.</p><p>2. 在 'Manager' 中添加模型.</p><p>仅支持 Windows!</p><p>OVMS 安装路径: '%USERPROFILE%\\.cherrystudio\\ovms' .</p><p>请参考 <a href=https://github.com/openvinotoolkit/model_server/blob/c55551763d02825829337b62c2dcef9339706f79/docs/deploying_server_baremetal.md>Intel OVMS 指南</a></p></dev>",
|
||||||
|
"download": {
|
||||||
|
"button": "下载",
|
||||||
|
"error": "选择失败",
|
||||||
|
"model_id": {
|
||||||
|
"label": "模型 ID",
|
||||||
|
"model_id_pattern": "模型 ID 必须以 OpenVINO/ 开头",
|
||||||
|
"placeholder": "必填,例如 OpenVINO/Qwen3-8B-int4-ov",
|
||||||
|
"required": "请输入模型 ID"
|
||||||
|
},
|
||||||
|
"model_name": {
|
||||||
|
"label": "模型名称",
|
||||||
|
"placeholder": "必填,例如 Qwen3-8B-int4-ov",
|
||||||
|
"required": "请输入模型名称"
|
||||||
|
},
|
||||||
|
"model_source": "模型来源:",
|
||||||
|
"model_task": "模型任务:",
|
||||||
|
"success": "下载成功",
|
||||||
|
"success_desc": "模型\"{{modelName}}\"-\"{{modelId}}\"下载成功,请前往 OVMS 管理界面添加模型",
|
||||||
|
"tip": "模型正在下载,有时需要几个小时。请耐心等待...",
|
||||||
|
"title": "下载 Intel OpenVINO 模型"
|
||||||
|
},
|
||||||
|
"failed": {
|
||||||
|
"install": "安装 OVMS 失败:",
|
||||||
|
"install_code_100": "未知错误",
|
||||||
|
"install_code_101": "仅支持 Intel(R) Core(TM) Ultra CPU",
|
||||||
|
"install_code_102": "仅支持 Windows",
|
||||||
|
"install_code_103": "下载 OVMS runtime 失败",
|
||||||
|
"install_code_104": "解压 OVMS runtime 失败",
|
||||||
|
"install_code_105": "清理 OVMS runtime 失败",
|
||||||
|
"run": "运行 OVMS 失败:",
|
||||||
|
"stop": "停止 OVMS 失败:"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"not_installed": "OVMS 未安装",
|
||||||
|
"not_running": "OVMS 未运行",
|
||||||
|
"running": "OVMS 正在运行",
|
||||||
|
"unknown": "OVMS 状态未知"
|
||||||
|
},
|
||||||
|
"title": "Intel OVMS"
|
||||||
|
},
|
||||||
"paintings": {
|
"paintings": {
|
||||||
"aspect_ratio": "画幅比例",
|
"aspect_ratio": "画幅比例",
|
||||||
"aspect_ratios": {
|
"aspect_ratios": {
|
||||||
@@ -2057,6 +2117,7 @@
|
|||||||
"ollama": "Ollama",
|
"ollama": "Ollama",
|
||||||
"openai": "OpenAI",
|
"openai": "OpenAI",
|
||||||
"openrouter": "OpenRouter",
|
"openrouter": "OpenRouter",
|
||||||
|
"ovms": "Intel OVMS",
|
||||||
"perplexity": "Perplexity",
|
"perplexity": "Perplexity",
|
||||||
"ph8": "PH8 大模型开放平台",
|
"ph8": "PH8 大模型开放平台",
|
||||||
"poe": "Poe",
|
"poe": "Poe",
|
||||||
@@ -4351,6 +4412,7 @@
|
|||||||
"later": "稍后",
|
"later": "稍后",
|
||||||
"message": "发现新版本 {{version}},是否立即安装?",
|
"message": "发现新版本 {{version}},是否立即安装?",
|
||||||
"noReleaseNotes": "暂无更新日志",
|
"noReleaseNotes": "暂无更新日志",
|
||||||
|
"saveDataError": "保存数据失败,请重试",
|
||||||
"title": "更新提示"
|
"title": "更新提示"
|
||||||
},
|
},
|
||||||
"warning": {
|
"warning": {
|
||||||
|
|||||||
@@ -251,6 +251,7 @@
|
|||||||
"added": "已新增",
|
"added": "已新增",
|
||||||
"case_sensitive": "區分大小寫",
|
"case_sensitive": "區分大小寫",
|
||||||
"collapse": "折疊",
|
"collapse": "折疊",
|
||||||
|
"download": "下載",
|
||||||
"includes_user_questions": "包含使用者提問",
|
"includes_user_questions": "包含使用者提問",
|
||||||
"manage": "管理",
|
"manage": "管理",
|
||||||
"select_model": "選擇模型",
|
"select_model": "選擇模型",
|
||||||
@@ -1582,13 +1583,13 @@
|
|||||||
"nami-ai-search": "納米AI搜索",
|
"nami-ai-search": "納米AI搜索",
|
||||||
"qwen": "通義千問",
|
"qwen": "通義千問",
|
||||||
"sensechat": "商量",
|
"sensechat": "商量",
|
||||||
|
"stepfun": "階躍AI",
|
||||||
"tencent-yuanbao": "騰訊元寶",
|
"tencent-yuanbao": "騰訊元寶",
|
||||||
"tiangong-ai": "天工AI",
|
"tiangong-ai": "天工AI",
|
||||||
"wanzhi": "萬知",
|
"wanzhi": "萬知",
|
||||||
"wenxin": "文心一言",
|
"wenxin": "文心一言",
|
||||||
"wps-copilot": "WPS靈犀",
|
"wps-copilot": "WPS靈犀",
|
||||||
"xiaoyi": "小藝",
|
"xiaoyi": "小藝",
|
||||||
"yuewen": "躍問",
|
|
||||||
"zhihu": "知乎直答"
|
"zhihu": "知乎直答"
|
||||||
},
|
},
|
||||||
"miniwindow": {
|
"miniwindow": {
|
||||||
@@ -1696,6 +1697,12 @@
|
|||||||
"provider_settings": "跳轉到服務商設置界面"
|
"provider_settings": "跳轉到服務商設置界面"
|
||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
|
"auto_rename": {
|
||||||
|
"empty_note": "筆記為空,無法生成名稱",
|
||||||
|
"failed": "生成筆記名稱失敗",
|
||||||
|
"label": "生成筆記名稱",
|
||||||
|
"success": "筆記名稱生成成功"
|
||||||
|
},
|
||||||
"characters": "字符",
|
"characters": "字符",
|
||||||
"collapse": "收起",
|
"collapse": "收起",
|
||||||
"content_placeholder": "請輸入筆記內容...",
|
"content_placeholder": "請輸入筆記內容...",
|
||||||
@@ -1777,6 +1784,8 @@
|
|||||||
"sort_updated_asc": "更新時間(從舊到新)",
|
"sort_updated_asc": "更新時間(從舊到新)",
|
||||||
"sort_updated_desc": "更新時間(從新到舊)",
|
"sort_updated_desc": "更新時間(從新到舊)",
|
||||||
"sort_z2a": "文件名(Z-A)",
|
"sort_z2a": "文件名(Z-A)",
|
||||||
|
"spell_check": "拼寫檢查",
|
||||||
|
"spell_check_tooltip": "啟用/禁用拼寫檢查",
|
||||||
"star": "收藏筆記",
|
"star": "收藏筆記",
|
||||||
"starred_notes": "收藏的筆記",
|
"starred_notes": "收藏的筆記",
|
||||||
"title": "筆記",
|
"title": "筆記",
|
||||||
@@ -1826,6 +1835,57 @@
|
|||||||
},
|
},
|
||||||
"title": "Ollama"
|
"title": "Ollama"
|
||||||
},
|
},
|
||||||
|
"ovms": {
|
||||||
|
"action": {
|
||||||
|
"install": "安裝",
|
||||||
|
"installing": "正在安裝",
|
||||||
|
"reinstall": "重新安裝",
|
||||||
|
"run": "執行 OVMS",
|
||||||
|
"starting": "啟動中",
|
||||||
|
"stop": "停止 OVMS",
|
||||||
|
"stopping": "停止中"
|
||||||
|
},
|
||||||
|
"description": "<div><p>1. 下載 OV 模型。</p><p>2. 在 'Manager' 中新增模型。</p><p>僅支援 Windows!</p><p>OVMS 安裝路徑: '%USERPROFILE%\\.cherrystudio\\ovms' 。</p><p>請參考 <a href=https://github.com/openvinotoolkit/model_server/blob/c55551763d02825829337b62c2dcef9339706f79/docs/deploying_server_baremetal.md>Intel OVMS 指南</a></p></dev>",
|
||||||
|
"download": {
|
||||||
|
"button": "下載",
|
||||||
|
"error": "選擇失敗",
|
||||||
|
"model_id": {
|
||||||
|
"label": "模型 ID",
|
||||||
|
"model_id_pattern": "模型 ID 必須以 OpenVINO/ 開頭",
|
||||||
|
"placeholder": "必填,例如 OpenVINO/Qwen3-8B-int4-ov",
|
||||||
|
"required": "請輸入模型 ID"
|
||||||
|
},
|
||||||
|
"model_name": {
|
||||||
|
"label": "模型名稱",
|
||||||
|
"placeholder": "必填,例如 Qwen3-8B-int4-ov",
|
||||||
|
"required": "請輸入模型名稱"
|
||||||
|
},
|
||||||
|
"model_source": "模型來源:",
|
||||||
|
"model_task": "模型任務:",
|
||||||
|
"success": "下載成功",
|
||||||
|
"success_desc": "模型\"{{modelName}}\"-\"{{modelId}}\"下載成功,請前往 OVMS 管理界面添加模型",
|
||||||
|
"tip": "模型正在下載,有時需要幾個小時。請耐心等候...",
|
||||||
|
"title": "下載 Intel OpenVINO 模型"
|
||||||
|
},
|
||||||
|
"failed": {
|
||||||
|
"install": "安裝 OVMS 失敗:",
|
||||||
|
"install_code_100": "未知錯誤",
|
||||||
|
"install_code_101": "僅支援 Intel(R) Core(TM) Ultra CPU",
|
||||||
|
"install_code_102": "僅支援 Windows",
|
||||||
|
"install_code_103": "下載 OVMS runtime 失敗",
|
||||||
|
"install_code_104": "解壓 OVMS runtime 失敗",
|
||||||
|
"install_code_105": "清理 OVMS runtime 失敗",
|
||||||
|
"run": "執行 OVMS 失敗:",
|
||||||
|
"stop": "停止 OVMS 失敗:"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"not_installed": "OVMS 未安裝",
|
||||||
|
"not_running": "OVMS 未執行",
|
||||||
|
"running": "OVMS 正在執行",
|
||||||
|
"unknown": "OVMS 狀態未知"
|
||||||
|
},
|
||||||
|
"title": "Intel OVMS"
|
||||||
|
},
|
||||||
"paintings": {
|
"paintings": {
|
||||||
"aspect_ratio": "畫幅比例",
|
"aspect_ratio": "畫幅比例",
|
||||||
"aspect_ratios": {
|
"aspect_ratios": {
|
||||||
@@ -2057,6 +2117,7 @@
|
|||||||
"ollama": "Ollama",
|
"ollama": "Ollama",
|
||||||
"openai": "OpenAI",
|
"openai": "OpenAI",
|
||||||
"openrouter": "OpenRouter",
|
"openrouter": "OpenRouter",
|
||||||
|
"ovms": "Intel OVMS",
|
||||||
"perplexity": "Perplexity",
|
"perplexity": "Perplexity",
|
||||||
"ph8": "PH8 大模型開放平台",
|
"ph8": "PH8 大模型開放平台",
|
||||||
"poe": "Poe",
|
"poe": "Poe",
|
||||||
@@ -4351,6 +4412,7 @@
|
|||||||
"later": "稍後",
|
"later": "稍後",
|
||||||
"message": "新版本 {{version}} 已準備就緒,是否立即安裝?",
|
"message": "新版本 {{version}} 已準備就緒,是否立即安裝?",
|
||||||
"noReleaseNotes": "暫無更新日誌",
|
"noReleaseNotes": "暫無更新日誌",
|
||||||
|
"saveDataError": "保存數據失敗,請重試",
|
||||||
"title": "更新提示"
|
"title": "更新提示"
|
||||||
},
|
},
|
||||||
"warning": {
|
"warning": {
|
||||||
|
|||||||
@@ -251,6 +251,7 @@
|
|||||||
"added": "προστέθηκε",
|
"added": "προστέθηκε",
|
||||||
"case_sensitive": "Διάκριση πεζών/κεφαλαίων",
|
"case_sensitive": "Διάκριση πεζών/κεφαλαίων",
|
||||||
"collapse": "συμπεριλάβετε",
|
"collapse": "συμπεριλάβετε",
|
||||||
|
"download": "Λήψη",
|
||||||
"includes_user_questions": "Περιλαμβάνει ερωτήσεις χρήστη",
|
"includes_user_questions": "Περιλαμβάνει ερωτήσεις χρήστη",
|
||||||
"manage": "χειριστείτε",
|
"manage": "χειριστείτε",
|
||||||
"select_model": "επιλογή μοντέλου",
|
"select_model": "επιλογή μοντέλου",
|
||||||
@@ -333,6 +334,7 @@
|
|||||||
"new_topic": "Νέο θέμα {{Command}}",
|
"new_topic": "Νέο θέμα {{Command}}",
|
||||||
"pause": "Παύση",
|
"pause": "Παύση",
|
||||||
"placeholder": "Εισάγετε μήνυμα εδώ...",
|
"placeholder": "Εισάγετε μήνυμα εδώ...",
|
||||||
|
"placeholder_without_triggers": "Εδώ εισαγάγετε το μήνυμα, πατήστε {{key}} για αποστολή",
|
||||||
"send": "Αποστολή",
|
"send": "Αποστολή",
|
||||||
"settings": "Ρυθμίσεις",
|
"settings": "Ρυθμίσεις",
|
||||||
"thinking": {
|
"thinking": {
|
||||||
@@ -1581,13 +1583,13 @@
|
|||||||
"nami-ai-search": "Nami AI Search",
|
"nami-ai-search": "Nami AI Search",
|
||||||
"qwen": "Qwen",
|
"qwen": "Qwen",
|
||||||
"sensechat": "SenseChat",
|
"sensechat": "SenseChat",
|
||||||
|
"stepfun": "Stepfun",
|
||||||
"tencent-yuanbao": "Yuanbao",
|
"tencent-yuanbao": "Yuanbao",
|
||||||
"tiangong-ai": "Skywork",
|
"tiangong-ai": "Skywork",
|
||||||
"wanzhi": "Wanzhi",
|
"wanzhi": "Wanzhi",
|
||||||
"wenxin": "ERNIE",
|
"wenxin": "ERNIE",
|
||||||
"wps-copilot": "WPS Copilot",
|
"wps-copilot": "WPS Copilot",
|
||||||
"xiaoyi": "Xiaoyi",
|
"xiaoyi": "Xiaoyi",
|
||||||
"yuewen": "Yuewen",
|
|
||||||
"zhihu": "Zhihu"
|
"zhihu": "Zhihu"
|
||||||
},
|
},
|
||||||
"miniwindow": {
|
"miniwindow": {
|
||||||
@@ -1695,6 +1697,12 @@
|
|||||||
"provider_settings": "Μετάβαση στις ρυθμίσεις παρόχου"
|
"provider_settings": "Μετάβαση στις ρυθμίσεις παρόχου"
|
||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
|
"auto_rename": {
|
||||||
|
"empty_note": "Το σημείωμα είναι κενό, δεν μπορεί να δημιουργηθεί όνομα",
|
||||||
|
"failed": "Αποτυχία δημιουργίας ονόματος σημείωσης",
|
||||||
|
"label": "Δημιουργία ονόματος σημείωσης",
|
||||||
|
"success": "Η δημιουργία του ονόματος σημειώσεων ολοκληρώθηκε με επιτυχία"
|
||||||
|
},
|
||||||
"characters": "χαρακτήρας",
|
"characters": "χαρακτήρας",
|
||||||
"collapse": "σύμπτυξη",
|
"collapse": "σύμπτυξη",
|
||||||
"content_placeholder": "Παρακαλώ εισαγάγετε το περιεχόμενο των σημειώσεων...",
|
"content_placeholder": "Παρακαλώ εισαγάγετε το περιεχόμενο των σημειώσεων...",
|
||||||
@@ -1776,6 +1784,8 @@
|
|||||||
"sort_updated_asc": "χρόνος ενημέρωσης (από παλιά στα νέα)",
|
"sort_updated_asc": "χρόνος ενημέρωσης (από παλιά στα νέα)",
|
||||||
"sort_updated_desc": "χρόνος ενημέρωσης (από νεώτερο σε παλαιότερο)",
|
"sort_updated_desc": "χρόνος ενημέρωσης (από νεώτερο σε παλαιότερο)",
|
||||||
"sort_z2a": "όνομα αρχείου (Z-A)",
|
"sort_z2a": "όνομα αρχείου (Z-A)",
|
||||||
|
"spell_check": "Έλεγχος ορθογραφίας",
|
||||||
|
"spell_check_tooltip": "Ενεργοποίηση/Απενεργοποίηση ελέγχου ορθογραφίας",
|
||||||
"star": "Αγαπημένες σημειώσεις",
|
"star": "Αγαπημένες σημειώσεις",
|
||||||
"starred_notes": "Σημειώσεις συλλογής",
|
"starred_notes": "Σημειώσεις συλλογής",
|
||||||
"title": "σημειώσεις",
|
"title": "σημειώσεις",
|
||||||
@@ -1825,6 +1835,57 @@
|
|||||||
},
|
},
|
||||||
"title": "Ollama"
|
"title": "Ollama"
|
||||||
},
|
},
|
||||||
|
"ovms": {
|
||||||
|
"action": {
|
||||||
|
"install": "Εγκατάσταση",
|
||||||
|
"installing": "Εγκατάσταση σε εξέλιξη",
|
||||||
|
"reinstall": "Επανεγκατάσταση",
|
||||||
|
"run": "Εκτέλεση OVMS",
|
||||||
|
"starting": "Εκκίνηση σε εξέλιξη",
|
||||||
|
"stop": "Διακοπή OVMS",
|
||||||
|
"stopping": "Διακοπή σε εξέλιξη"
|
||||||
|
},
|
||||||
|
"description": "<div><p>1. Λήψη μοντέλου OV.</p><p>2. Προσθήκη μοντέλου στο 'Manager'.</p><p>Υποστηρίζεται μόνο στα Windows!</p><p>Διαδρομή εγκατάστασης OVMS: '%USERPROFILE%\\.cherrystudio\\ovms' .</p><p>Ανατρέξτε στον <a href=https://github.com/openvinotoolkit/model_server/blob/c55551763d02825829337b62c2dcef9339706f79/docs/deploying_server_baremetal.md>Οδηγό Intel OVMS</a></p></div>",
|
||||||
|
"download": {
|
||||||
|
"button": "Λήψη",
|
||||||
|
"error": "Η επιλογή απέτυχε",
|
||||||
|
"model_id": {
|
||||||
|
"label": "Αναγνωριστικό μοντέλου:",
|
||||||
|
"model_id_pattern": "Το αναγνωριστικό μοντέλου πρέπει να ξεκινά με OpenVINO/",
|
||||||
|
"placeholder": "Απαιτείται, π.χ. OpenVINO/Qwen3-8B-int4-ov",
|
||||||
|
"required": "Παρακαλώ εισάγετε το αναγνωριστικό μοντέλου"
|
||||||
|
},
|
||||||
|
"model_name": {
|
||||||
|
"label": "Όνομα μοντέλου:",
|
||||||
|
"placeholder": "Απαιτείται, π.χ. Qwen3-8B-int4-ov",
|
||||||
|
"required": "Παρακαλώ εισάγετε το όνομα του μοντέλου"
|
||||||
|
},
|
||||||
|
"model_source": "Πηγή μοντέλου:",
|
||||||
|
"model_task": "Εργασία μοντέλου:",
|
||||||
|
"success": "Η λήψη ολοκληρώθηκε με επιτυχία",
|
||||||
|
"success_desc": "Το μοντέλο \"{{modelName}}\"-\"{{modelId}}\" λήφθηκε επιτυχώς, παρακαλώ μεταβείτε στη διεπαφή διαχείρισης OVMS για να προσθέσετε το μοντέλο",
|
||||||
|
"tip": "Το μοντέλο κατεβαίνει, μερικές φορές χρειάζονται αρκετές ώρες. Παρακαλώ περιμένετε υπομονετικά...",
|
||||||
|
"title": "Λήψη μοντέλου Intel OpenVINO"
|
||||||
|
},
|
||||||
|
"failed": {
|
||||||
|
"install": "Η εγκατάσταση του OVMS απέτυχε:",
|
||||||
|
"install_code_100": "Άγνωστο σφάλμα",
|
||||||
|
"install_code_101": "Υποστηρίζεται μόνο σε Intel(R) Core(TM) Ultra CPU",
|
||||||
|
"install_code_102": "Υποστηρίζεται μόνο στα Windows",
|
||||||
|
"install_code_103": "Η λήψη του OVMS runtime απέτυχε",
|
||||||
|
"install_code_104": "Η αποσυμπίεση του OVMS runtime απέτυχε",
|
||||||
|
"install_code_105": "Ο καθαρισμός του OVMS runtime απέτυχε",
|
||||||
|
"run": "Η εκτέλεση του OVMS απέτυχε:",
|
||||||
|
"stop": "Η διακοπή του OVMS απέτυχε:"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"not_installed": "Το OVMS δεν έχει εγκατασταθεί",
|
||||||
|
"not_running": "Το OVMS δεν εκτελείται",
|
||||||
|
"running": "Το OVMS εκτελείται",
|
||||||
|
"unknown": "Άγνωστη κατάσταση OVMS"
|
||||||
|
},
|
||||||
|
"title": "Intel OVMS"
|
||||||
|
},
|
||||||
"paintings": {
|
"paintings": {
|
||||||
"aspect_ratio": "Λόγος διαστάσεων",
|
"aspect_ratio": "Λόγος διαστάσεων",
|
||||||
"aspect_ratios": {
|
"aspect_ratios": {
|
||||||
@@ -2056,6 +2117,7 @@
|
|||||||
"ollama": "Ollama",
|
"ollama": "Ollama",
|
||||||
"openai": "OpenAI",
|
"openai": "OpenAI",
|
||||||
"openrouter": "OpenRouter",
|
"openrouter": "OpenRouter",
|
||||||
|
"ovms": "Intel OVMS",
|
||||||
"perplexity": "Perplexity",
|
"perplexity": "Perplexity",
|
||||||
"ph8": "Πλατφόρμα Ανοιχτής Μεγάλης Μοντέλου PH8",
|
"ph8": "Πλατφόρμα Ανοιχτής Μεγάλης Μοντέλου PH8",
|
||||||
"poe": "Poe",
|
"poe": "Poe",
|
||||||
@@ -4350,6 +4412,7 @@
|
|||||||
"later": "Μετά",
|
"later": "Μετά",
|
||||||
"message": "Νέα έκδοση {{version}} είναι έτοιμη, θέλετε να την εγκαταστήσετε τώρα;",
|
"message": "Νέα έκδοση {{version}} είναι έτοιμη, θέλετε να την εγκαταστήσετε τώρα;",
|
||||||
"noReleaseNotes": "Χωρίς σημειώσεις",
|
"noReleaseNotes": "Χωρίς σημειώσεις",
|
||||||
|
"saveDataError": "Η αποθήκευση των δεδομένων απέτυχε, δοκιμάστε ξανά",
|
||||||
"title": "Ενημέρωση"
|
"title": "Ενημέρωση"
|
||||||
},
|
},
|
||||||
"warning": {
|
"warning": {
|
||||||
|
|||||||
@@ -251,6 +251,7 @@
|
|||||||
"added": "Agregado",
|
"added": "Agregado",
|
||||||
"case_sensitive": "Distingue mayúsculas y minúsculas",
|
"case_sensitive": "Distingue mayúsculas y minúsculas",
|
||||||
"collapse": "Colapsar",
|
"collapse": "Colapsar",
|
||||||
|
"download": "Descargar",
|
||||||
"includes_user_questions": "Incluye preguntas del usuario",
|
"includes_user_questions": "Incluye preguntas del usuario",
|
||||||
"manage": "Administrar",
|
"manage": "Administrar",
|
||||||
"select_model": "Seleccionar Modelo",
|
"select_model": "Seleccionar Modelo",
|
||||||
@@ -333,6 +334,7 @@
|
|||||||
"new_topic": "Nuevo tema {{Command}}",
|
"new_topic": "Nuevo tema {{Command}}",
|
||||||
"pause": "Pausar",
|
"pause": "Pausar",
|
||||||
"placeholder": "Escribe aquí tu mensaje...",
|
"placeholder": "Escribe aquí tu mensaje...",
|
||||||
|
"placeholder_without_triggers": "Escriba un mensaje aquí y presione {{key}} para enviar",
|
||||||
"send": "Enviar",
|
"send": "Enviar",
|
||||||
"settings": "Configuración",
|
"settings": "Configuración",
|
||||||
"thinking": {
|
"thinking": {
|
||||||
@@ -1581,13 +1583,13 @@
|
|||||||
"nami-ai-search": "Nami AI Search",
|
"nami-ai-search": "Nami AI Search",
|
||||||
"qwen": "Qwen",
|
"qwen": "Qwen",
|
||||||
"sensechat": "SenseChat",
|
"sensechat": "SenseChat",
|
||||||
|
"stepfun": "Stepfun",
|
||||||
"tencent-yuanbao": "Yuanbao",
|
"tencent-yuanbao": "Yuanbao",
|
||||||
"tiangong-ai": "Skywork",
|
"tiangong-ai": "Skywork",
|
||||||
"wanzhi": "Wanzhi",
|
"wanzhi": "Wanzhi",
|
||||||
"wenxin": "ERNIE",
|
"wenxin": "ERNIE",
|
||||||
"wps-copilot": "WPS Copilot",
|
"wps-copilot": "WPS Copilot",
|
||||||
"xiaoyi": "Xiaoyi",
|
"xiaoyi": "Xiaoyi",
|
||||||
"yuewen": "Yuewen",
|
|
||||||
"zhihu": "Zhihu"
|
"zhihu": "Zhihu"
|
||||||
},
|
},
|
||||||
"miniwindow": {
|
"miniwindow": {
|
||||||
@@ -1695,6 +1697,12 @@
|
|||||||
"provider_settings": "Ir a la configuración del proveedor"
|
"provider_settings": "Ir a la configuración del proveedor"
|
||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
|
"auto_rename": {
|
||||||
|
"empty_note": "La nota está vacía, no se puede generar un nombre",
|
||||||
|
"failed": "Error al generar el nombre de la nota",
|
||||||
|
"label": "Generar nombre de nota",
|
||||||
|
"success": "Se ha generado correctamente el nombre de la nota"
|
||||||
|
},
|
||||||
"characters": "carácter",
|
"characters": "carácter",
|
||||||
"collapse": "ocultar",
|
"collapse": "ocultar",
|
||||||
"content_placeholder": "Introduzca el contenido de la nota...",
|
"content_placeholder": "Introduzca el contenido de la nota...",
|
||||||
@@ -1776,6 +1784,8 @@
|
|||||||
"sort_updated_asc": "Fecha de actualización (de más antigua a más reciente)",
|
"sort_updated_asc": "Fecha de actualización (de más antigua a más reciente)",
|
||||||
"sort_updated_desc": "Fecha de actualización (de más nuevo a más antiguo)",
|
"sort_updated_desc": "Fecha de actualización (de más nuevo a más antiguo)",
|
||||||
"sort_z2a": "Nombre de archivo (Z-A)",
|
"sort_z2a": "Nombre de archivo (Z-A)",
|
||||||
|
"spell_check": "comprobación ortográfica",
|
||||||
|
"spell_check_tooltip": "Habilitar/deshabilitar revisión ortográfica",
|
||||||
"star": "Notas guardadas",
|
"star": "Notas guardadas",
|
||||||
"starred_notes": "notas guardadas",
|
"starred_notes": "notas guardadas",
|
||||||
"title": "notas",
|
"title": "notas",
|
||||||
@@ -1825,6 +1835,57 @@
|
|||||||
},
|
},
|
||||||
"title": "Ollama"
|
"title": "Ollama"
|
||||||
},
|
},
|
||||||
|
"ovms": {
|
||||||
|
"action": {
|
||||||
|
"install": "Instalar",
|
||||||
|
"installing": "Instalando",
|
||||||
|
"reinstall": "Reinstalar",
|
||||||
|
"run": "Ejecutar OVMS",
|
||||||
|
"starting": "Iniciando",
|
||||||
|
"stop": "Detener OVMS",
|
||||||
|
"stopping": "Deteniendo"
|
||||||
|
},
|
||||||
|
"description": "<div><p>1. Descargar modelo OV.</p><p>2. Agregar modelo en 'Administrador'.</p><p>¡Solo compatible con Windows!</p><p>Ruta de instalación de OVMS: '%USERPROFILE%\\.cherrystudio\\ovms' .</p><p>Consulte la <a href=https://github.com/openvinotoolkit/model_server/blob/c55551763d02825829337b62c2dcef9339706f79/docs/deploying_server_baremetal.md>Guía de Intel OVMS</a></p></dev>",
|
||||||
|
"download": {
|
||||||
|
"button": "Descargar",
|
||||||
|
"error": "Selección fallida",
|
||||||
|
"model_id": {
|
||||||
|
"label": "ID del modelo:",
|
||||||
|
"model_id_pattern": "El ID del modelo debe comenzar con OpenVINO/",
|
||||||
|
"placeholder": "Requerido, por ejemplo, OpenVINO/Qwen3-8B-int4-ov",
|
||||||
|
"required": "Por favor, ingrese el ID del modelo"
|
||||||
|
},
|
||||||
|
"model_name": {
|
||||||
|
"label": "Nombre del modelo:",
|
||||||
|
"placeholder": "Requerido, por ejemplo, Qwen3-8B-int4-ov",
|
||||||
|
"required": "Por favor, ingrese el nombre del modelo"
|
||||||
|
},
|
||||||
|
"model_source": "Fuente del modelo:",
|
||||||
|
"model_task": "Tarea del modelo:",
|
||||||
|
"success": "Descarga exitosa",
|
||||||
|
"success_desc": "El modelo \"{{modelName}}\"-\"{{modelId}}\" se descargó exitosamente, por favor vaya a la interfaz de administración de OVMS para agregar el modelo",
|
||||||
|
"tip": "El modelo se está descargando, a veces toma varias horas. Por favor espere pacientemente...",
|
||||||
|
"title": "Descargar modelo Intel OpenVINO"
|
||||||
|
},
|
||||||
|
"failed": {
|
||||||
|
"install": "Error al instalar OVMS:",
|
||||||
|
"install_code_100": "Error desconocido",
|
||||||
|
"install_code_101": "Solo compatible con CPU Intel(R) Core(TM) Ultra",
|
||||||
|
"install_code_102": "Solo compatible con Windows",
|
||||||
|
"install_code_103": "Error al descargar el tiempo de ejecución de OVMS",
|
||||||
|
"install_code_104": "Error al descomprimir el tiempo de ejecución de OVMS",
|
||||||
|
"install_code_105": "Error al limpiar el tiempo de ejecución de OVMS",
|
||||||
|
"run": "Error al ejecutar OVMS:",
|
||||||
|
"stop": "Error al detener OVMS:"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"not_installed": "OVMS no instalado",
|
||||||
|
"not_running": "OVMS no está en ejecución",
|
||||||
|
"running": "OVMS en ejecución",
|
||||||
|
"unknown": "Estado de OVMS desconocido"
|
||||||
|
},
|
||||||
|
"title": "Intel OVMS"
|
||||||
|
},
|
||||||
"paintings": {
|
"paintings": {
|
||||||
"aspect_ratio": "Relación de aspecto",
|
"aspect_ratio": "Relación de aspecto",
|
||||||
"aspect_ratios": {
|
"aspect_ratios": {
|
||||||
@@ -2056,6 +2117,7 @@
|
|||||||
"ollama": "Ollama",
|
"ollama": "Ollama",
|
||||||
"openai": "OpenAI",
|
"openai": "OpenAI",
|
||||||
"openrouter": "OpenRouter",
|
"openrouter": "OpenRouter",
|
||||||
|
"ovms": "Intel OVMS",
|
||||||
"perplexity": "Perplejidad",
|
"perplexity": "Perplejidad",
|
||||||
"ph8": "Plataforma Abierta de Grandes Modelos PH8",
|
"ph8": "Plataforma Abierta de Grandes Modelos PH8",
|
||||||
"poe": "Poe",
|
"poe": "Poe",
|
||||||
@@ -4350,6 +4412,7 @@
|
|||||||
"later": "Más tarde",
|
"later": "Más tarde",
|
||||||
"message": "Nueva versión {{version}} disponible, ¿desea instalarla ahora?",
|
"message": "Nueva versión {{version}} disponible, ¿desea instalarla ahora?",
|
||||||
"noReleaseNotes": "Sin notas de la versión",
|
"noReleaseNotes": "Sin notas de la versión",
|
||||||
|
"saveDataError": "Error al guardar los datos, inténtalo de nuevo",
|
||||||
"title": "Actualización"
|
"title": "Actualización"
|
||||||
},
|
},
|
||||||
"warning": {
|
"warning": {
|
||||||
|
|||||||
@@ -251,6 +251,7 @@
|
|||||||
"added": "Ajouté",
|
"added": "Ajouté",
|
||||||
"case_sensitive": "Respecter la casse",
|
"case_sensitive": "Respecter la casse",
|
||||||
"collapse": "Réduire",
|
"collapse": "Réduire",
|
||||||
|
"download": "Télécharger",
|
||||||
"includes_user_questions": "Inclure les questions de l'utilisateur",
|
"includes_user_questions": "Inclure les questions de l'utilisateur",
|
||||||
"manage": "Gérer",
|
"manage": "Gérer",
|
||||||
"select_model": "Sélectionner le Modèle",
|
"select_model": "Sélectionner le Modèle",
|
||||||
@@ -333,6 +334,7 @@
|
|||||||
"new_topic": "Nouveau sujet {{Command}}",
|
"new_topic": "Nouveau sujet {{Command}}",
|
||||||
"pause": "Pause",
|
"pause": "Pause",
|
||||||
"placeholder": "Entrez votre message ici...",
|
"placeholder": "Entrez votre message ici...",
|
||||||
|
"placeholder_without_triggers": "Entrez votre message ici, appuyez sur {{key}} pour envoyer",
|
||||||
"send": "Envoyer",
|
"send": "Envoyer",
|
||||||
"settings": "Paramètres",
|
"settings": "Paramètres",
|
||||||
"thinking": {
|
"thinking": {
|
||||||
@@ -1581,13 +1583,13 @@
|
|||||||
"nami-ai-search": "Nami AI Search",
|
"nami-ai-search": "Nami AI Search",
|
||||||
"qwen": "Qwen",
|
"qwen": "Qwen",
|
||||||
"sensechat": "SenseChat",
|
"sensechat": "SenseChat",
|
||||||
|
"stepfun": "Stepfun",
|
||||||
"tencent-yuanbao": "Yuanbao",
|
"tencent-yuanbao": "Yuanbao",
|
||||||
"tiangong-ai": "Skywork",
|
"tiangong-ai": "Skywork",
|
||||||
"wanzhi": "Wanzhi",
|
"wanzhi": "Wanzhi",
|
||||||
"wenxin": "ERNIE",
|
"wenxin": "ERNIE",
|
||||||
"wps-copilot": "WPS Copilot",
|
"wps-copilot": "WPS Copilot",
|
||||||
"xiaoyi": "Xiaoyi",
|
"xiaoyi": "Xiaoyi",
|
||||||
"yuewen": "Yuewen",
|
|
||||||
"zhihu": "Zhihu"
|
"zhihu": "Zhihu"
|
||||||
},
|
},
|
||||||
"miniwindow": {
|
"miniwindow": {
|
||||||
@@ -1695,6 +1697,12 @@
|
|||||||
"provider_settings": "Aller aux paramètres du fournisseur"
|
"provider_settings": "Aller aux paramètres du fournisseur"
|
||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
|
"auto_rename": {
|
||||||
|
"empty_note": "La note est vide, impossible de générer un nom",
|
||||||
|
"failed": "Échec de la génération du nom de note",
|
||||||
|
"label": "Générer un nom de note",
|
||||||
|
"success": "La génération du nom de note a réussi"
|
||||||
|
},
|
||||||
"characters": "caractère",
|
"characters": "caractère",
|
||||||
"collapse": "réduire",
|
"collapse": "réduire",
|
||||||
"content_placeholder": "Veuillez saisir le contenu de la note...",
|
"content_placeholder": "Veuillez saisir le contenu de la note...",
|
||||||
@@ -1776,6 +1784,8 @@
|
|||||||
"sort_updated_asc": "Heure de mise à jour (du plus ancien au plus récent)",
|
"sort_updated_asc": "Heure de mise à jour (du plus ancien au plus récent)",
|
||||||
"sort_updated_desc": "Date de mise à jour (du plus récent au plus ancien)",
|
"sort_updated_desc": "Date de mise à jour (du plus récent au plus ancien)",
|
||||||
"sort_z2a": "Nom de fichier (Z-A)",
|
"sort_z2a": "Nom de fichier (Z-A)",
|
||||||
|
"spell_check": "Vérification orthographique",
|
||||||
|
"spell_check_tooltip": "Activer/Désactiver la vérification orthographique",
|
||||||
"star": "Notes enregistrées",
|
"star": "Notes enregistrées",
|
||||||
"starred_notes": "notes de collection",
|
"starred_notes": "notes de collection",
|
||||||
"title": "notes",
|
"title": "notes",
|
||||||
@@ -1825,6 +1835,57 @@
|
|||||||
},
|
},
|
||||||
"title": "Ollama"
|
"title": "Ollama"
|
||||||
},
|
},
|
||||||
|
"ovms": {
|
||||||
|
"action": {
|
||||||
|
"install": "Installer",
|
||||||
|
"installing": "Installation en cours",
|
||||||
|
"reinstall": "Réinstaller",
|
||||||
|
"run": "Exécuter OVMS",
|
||||||
|
"starting": "Démarrage en cours",
|
||||||
|
"stop": "Arrêter OVMS",
|
||||||
|
"stopping": "Arrêt en cours"
|
||||||
|
},
|
||||||
|
"description": "<div><p>1. Télécharger le modèle OV.</p><p>2. Ajouter le modèle dans 'Manager'.</p><p>Uniquement compatible avec Windows !</p><p>Chemin d'installation d'OVMS : '%USERPROFILE%\\.cherrystudio\\ovms' .</p><p>Veuillez vous référer au <a href=https://github.com/openvinotoolkit/model_server/blob/c55551763d02825829337b62c2dcef9339706f79/docs/deploying_server_baremetal.md>Guide Intel OVMS</a></p></dev>",
|
||||||
|
"download": {
|
||||||
|
"button": "Télécharger",
|
||||||
|
"error": "Échec de la sélection",
|
||||||
|
"model_id": {
|
||||||
|
"label": "ID du modèle :",
|
||||||
|
"model_id_pattern": "L'ID du modèle doit commencer par OpenVINO/",
|
||||||
|
"placeholder": "Requis, par exemple OpenVINO/Qwen3-8B-int4-ov",
|
||||||
|
"required": "Veuillez saisir l'ID du modèle"
|
||||||
|
},
|
||||||
|
"model_name": {
|
||||||
|
"label": "Nom du modèle :",
|
||||||
|
"placeholder": "Requis, par exemple Qwen3-8B-int4-ov",
|
||||||
|
"required": "Veuillez saisir le nom du modèle"
|
||||||
|
},
|
||||||
|
"model_source": "Source du modèle :",
|
||||||
|
"model_task": "Tâche du modèle :",
|
||||||
|
"success": "Téléchargement réussi",
|
||||||
|
"success_desc": "Le modèle \"{{modelName}}\"-\"{{modelId}}\" a été téléchargé avec succès, veuillez vous rendre à l'interface de gestion OVMS pour ajouter le modèle",
|
||||||
|
"tip": "Le modèle est en cours de téléchargement, cela peut parfois prendre plusieurs heures. Veuillez patienter...",
|
||||||
|
"title": "Télécharger le modèle Intel OpenVINO"
|
||||||
|
},
|
||||||
|
"failed": {
|
||||||
|
"install": "Échec de l'installation d'OVMS :",
|
||||||
|
"install_code_100": "Erreur inconnue",
|
||||||
|
"install_code_101": "Uniquement compatible avec les processeurs Intel(R) Core(TM) Ultra",
|
||||||
|
"install_code_102": "Uniquement compatible avec Windows",
|
||||||
|
"install_code_103": "Échec du téléchargement du runtime OVMS",
|
||||||
|
"install_code_104": "Échec de la décompression du runtime OVMS",
|
||||||
|
"install_code_105": "Échec du nettoyage du runtime OVMS",
|
||||||
|
"run": "Échec de l'exécution d'OVMS :",
|
||||||
|
"stop": "Échec de l'arrêt d'OVMS :"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"not_installed": "OVMS non installé",
|
||||||
|
"not_running": "OVMS n'est pas en cours d'exécution",
|
||||||
|
"running": "OVMS en cours d'exécution",
|
||||||
|
"unknown": "État d'OVMS inconnu"
|
||||||
|
},
|
||||||
|
"title": "Intel OVMS"
|
||||||
|
},
|
||||||
"paintings": {
|
"paintings": {
|
||||||
"aspect_ratio": "Format d'image",
|
"aspect_ratio": "Format d'image",
|
||||||
"aspect_ratios": {
|
"aspect_ratios": {
|
||||||
@@ -2056,6 +2117,7 @@
|
|||||||
"ollama": "Ollama",
|
"ollama": "Ollama",
|
||||||
"openai": "OpenAI",
|
"openai": "OpenAI",
|
||||||
"openrouter": "OpenRouter",
|
"openrouter": "OpenRouter",
|
||||||
|
"ovms": "Intel OVMS",
|
||||||
"perplexity": "Perplexité",
|
"perplexity": "Perplexité",
|
||||||
"ph8": "Plateforme ouverte de grands modèles PH8",
|
"ph8": "Plateforme ouverte de grands modèles PH8",
|
||||||
"poe": "Poe",
|
"poe": "Poe",
|
||||||
@@ -4350,6 +4412,7 @@
|
|||||||
"later": "Plus tard",
|
"later": "Plus tard",
|
||||||
"message": "Nouvelle version {{version}} disponible, voulez-vous l'installer maintenant ?",
|
"message": "Nouvelle version {{version}} disponible, voulez-vous l'installer maintenant ?",
|
||||||
"noReleaseNotes": "Aucune note de version",
|
"noReleaseNotes": "Aucune note de version",
|
||||||
|
"saveDataError": "Échec de la sauvegarde des données, veuillez réessayer",
|
||||||
"title": "Mise à jour"
|
"title": "Mise à jour"
|
||||||
},
|
},
|
||||||
"warning": {
|
"warning": {
|
||||||
|
|||||||
@@ -251,6 +251,7 @@
|
|||||||
"added": "追加済み",
|
"added": "追加済み",
|
||||||
"case_sensitive": "大文字と小文字の区別",
|
"case_sensitive": "大文字と小文字の区別",
|
||||||
"collapse": "折りたたむ",
|
"collapse": "折りたたむ",
|
||||||
|
"download": "ダウンロード",
|
||||||
"includes_user_questions": "ユーザーからの質問を含む",
|
"includes_user_questions": "ユーザーからの質問を含む",
|
||||||
"manage": "管理",
|
"manage": "管理",
|
||||||
"select_model": "モデルを選択",
|
"select_model": "モデルを選択",
|
||||||
@@ -333,6 +334,7 @@
|
|||||||
"new_topic": "新しいトピック {{Command}}",
|
"new_topic": "新しいトピック {{Command}}",
|
||||||
"pause": "一時停止",
|
"pause": "一時停止",
|
||||||
"placeholder": "ここにメッセージを入力し、{{key}} を押して送信...",
|
"placeholder": "ここにメッセージを入力し、{{key}} を押して送信...",
|
||||||
|
"placeholder_without_triggers": "ここにメッセージを入力し、{{key}} を押して送信してください",
|
||||||
"send": "送信",
|
"send": "送信",
|
||||||
"settings": "設定",
|
"settings": "設定",
|
||||||
"thinking": {
|
"thinking": {
|
||||||
@@ -1581,13 +1583,13 @@
|
|||||||
"nami-ai-search": "Nami AI Search",
|
"nami-ai-search": "Nami AI Search",
|
||||||
"qwen": "通義千問",
|
"qwen": "通義千問",
|
||||||
"sensechat": "SenseChat",
|
"sensechat": "SenseChat",
|
||||||
|
"stepfun": "Stepfun",
|
||||||
"tencent-yuanbao": "騰訊元宝",
|
"tencent-yuanbao": "騰訊元宝",
|
||||||
"tiangong-ai": "Skywork",
|
"tiangong-ai": "Skywork",
|
||||||
"wanzhi": "万知",
|
"wanzhi": "万知",
|
||||||
"wenxin": "ERNIE",
|
"wenxin": "ERNIE",
|
||||||
"wps-copilot": "WPS Copilot",
|
"wps-copilot": "WPS Copilot",
|
||||||
"xiaoyi": "小藝",
|
"xiaoyi": "小藝",
|
||||||
"yuewen": "躍問",
|
|
||||||
"zhihu": "知乎直答"
|
"zhihu": "知乎直答"
|
||||||
},
|
},
|
||||||
"miniwindow": {
|
"miniwindow": {
|
||||||
@@ -1695,6 +1697,12 @@
|
|||||||
"provider_settings": "プロバイダー設定に移動"
|
"provider_settings": "プロバイダー設定に移動"
|
||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
|
"auto_rename": {
|
||||||
|
"empty_note": "ノートが空です。名前を生成できません。",
|
||||||
|
"failed": "ノート名の生成に失敗しました",
|
||||||
|
"label": "ノート名の生成",
|
||||||
|
"success": "ノート名の生成に成功しました"
|
||||||
|
},
|
||||||
"characters": "文字",
|
"characters": "文字",
|
||||||
"collapse": "閉じる",
|
"collapse": "閉じる",
|
||||||
"content_placeholder": "メモの内容を入力してください...",
|
"content_placeholder": "メモの内容を入力してください...",
|
||||||
@@ -1776,6 +1784,8 @@
|
|||||||
"sort_updated_asc": "更新日時(古い順)",
|
"sort_updated_asc": "更新日時(古い順)",
|
||||||
"sort_updated_desc": "更新日時(新しい順)",
|
"sort_updated_desc": "更新日時(新しい順)",
|
||||||
"sort_z2a": "ファイル名(Z-A)",
|
"sort_z2a": "ファイル名(Z-A)",
|
||||||
|
"spell_check": "スペルチェック",
|
||||||
|
"spell_check_tooltip": "スペルチェックの有効/無効",
|
||||||
"star": "お気に入りのノート",
|
"star": "お気に入りのノート",
|
||||||
"starred_notes": "収集したノート",
|
"starred_notes": "収集したノート",
|
||||||
"title": "ノート",
|
"title": "ノート",
|
||||||
@@ -1825,6 +1835,57 @@
|
|||||||
},
|
},
|
||||||
"title": "Ollama"
|
"title": "Ollama"
|
||||||
},
|
},
|
||||||
|
"ovms": {
|
||||||
|
"action": {
|
||||||
|
"install": "インストール",
|
||||||
|
"installing": "インストール中",
|
||||||
|
"reinstall": "再インストール",
|
||||||
|
"run": "OVMSを実行",
|
||||||
|
"starting": "起動中",
|
||||||
|
"stop": "OVMSを停止",
|
||||||
|
"stopping": "停止中"
|
||||||
|
},
|
||||||
|
"description": "<div><p>1. OVモデルをダウンロードします。</p><p>2. 'マネージャー'でモデルを追加します。</p><p>Windowsのみサポート!</p><p>OVMSインストールパス: '%USERPROFILE%\\.cherrystudio\\ovms' 。</p><p>詳細は<a href=https://github.com/openvinotoolkit/model_server/blob/c55551763d02825829337b62c2dcef9339706f79/docs/deploying_server_baremetal.md>Intel OVMSガイド</a>をご参照ください。</p></dev>",
|
||||||
|
"download": {
|
||||||
|
"button": "ダウンロード",
|
||||||
|
"error": "ダウンロードエラー",
|
||||||
|
"model_id": {
|
||||||
|
"label": "モデルID",
|
||||||
|
"model_id_pattern": "モデルIDはOpenVINO/で始まる必要があります",
|
||||||
|
"placeholder": "必須 例: OpenVINO/Qwen3-8B-int4-ov",
|
||||||
|
"required": "モデルIDを入力してください"
|
||||||
|
},
|
||||||
|
"model_name": {
|
||||||
|
"label": "モデル名",
|
||||||
|
"placeholder": "必須 例: Qwen3-8B-int4-ov",
|
||||||
|
"required": "モデル名を入力してください"
|
||||||
|
},
|
||||||
|
"model_source": "モデルソース:",
|
||||||
|
"model_task": "モデルタスク:",
|
||||||
|
"success": "ダウンロード成功",
|
||||||
|
"success_desc": "モデル\"{{modelName}}\"-\"{{modelId}}\"ダウンロード成功、OVMS管理インターフェースに移動してモデルを追加してください",
|
||||||
|
"tip": "モデルはダウンロードされていますが、時には数時間かかります。我慢してください...",
|
||||||
|
"title": "Intel OpenVINOモデルをダウンロード"
|
||||||
|
},
|
||||||
|
"failed": {
|
||||||
|
"install": "OVMSのインストールに失敗しました:",
|
||||||
|
"install_code_100": "不明なエラー",
|
||||||
|
"install_code_101": "Intel(R) Core(TM) Ultra CPUのみサポート",
|
||||||
|
"install_code_102": "Windowsのみサポート",
|
||||||
|
"install_code_103": "OVMSランタイムのダウンロードに失敗しました",
|
||||||
|
"install_code_104": "OVMSランタイムの解凍に失敗しました",
|
||||||
|
"install_code_105": "OVMSランタイムのクリーンアップに失敗しました",
|
||||||
|
"run": "OVMSの実行に失敗しました:",
|
||||||
|
"stop": "OVMSの停止に失敗しました:"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"not_installed": "OVMSはインストールされていません",
|
||||||
|
"not_running": "OVMSは実行されていません",
|
||||||
|
"running": "OVMSは実行中です",
|
||||||
|
"unknown": "OVMSのステータスが不明です"
|
||||||
|
},
|
||||||
|
"title": "Intel OVMS"
|
||||||
|
},
|
||||||
"paintings": {
|
"paintings": {
|
||||||
"aspect_ratio": "画幅比例",
|
"aspect_ratio": "画幅比例",
|
||||||
"aspect_ratios": {
|
"aspect_ratios": {
|
||||||
@@ -2056,6 +2117,7 @@
|
|||||||
"ollama": "Ollama",
|
"ollama": "Ollama",
|
||||||
"openai": "OpenAI",
|
"openai": "OpenAI",
|
||||||
"openrouter": "OpenRouter",
|
"openrouter": "OpenRouter",
|
||||||
|
"ovms": "Intel OVMS",
|
||||||
"perplexity": "Perplexity",
|
"perplexity": "Perplexity",
|
||||||
"ph8": "PH8",
|
"ph8": "PH8",
|
||||||
"poe": "Poe",
|
"poe": "Poe",
|
||||||
@@ -4350,6 +4412,7 @@
|
|||||||
"later": "後で",
|
"later": "後で",
|
||||||
"message": "新バージョン {{version}} が利用可能です。今すぐインストールしますか?",
|
"message": "新バージョン {{version}} が利用可能です。今すぐインストールしますか?",
|
||||||
"noReleaseNotes": "暫無更新日誌",
|
"noReleaseNotes": "暫無更新日誌",
|
||||||
|
"saveDataError": "データの保存に失敗しました。もう一度お試しください。",
|
||||||
"title": "更新"
|
"title": "更新"
|
||||||
},
|
},
|
||||||
"warning": {
|
"warning": {
|
||||||
|
|||||||
@@ -251,6 +251,7 @@
|
|||||||
"added": "Adicionado",
|
"added": "Adicionado",
|
||||||
"case_sensitive": "Diferenciar maiúsculas e minúsculas",
|
"case_sensitive": "Diferenciar maiúsculas e minúsculas",
|
||||||
"collapse": "Recolher",
|
"collapse": "Recolher",
|
||||||
|
"download": "Baixar",
|
||||||
"includes_user_questions": "Incluir perguntas do usuário",
|
"includes_user_questions": "Incluir perguntas do usuário",
|
||||||
"manage": "Gerenciar",
|
"manage": "Gerenciar",
|
||||||
"select_model": "Selecionar Modelo",
|
"select_model": "Selecionar Modelo",
|
||||||
@@ -333,6 +334,7 @@
|
|||||||
"new_topic": "Novo tópico {{Command}}",
|
"new_topic": "Novo tópico {{Command}}",
|
||||||
"pause": "Pausar",
|
"pause": "Pausar",
|
||||||
"placeholder": "Digite sua mensagem aqui...",
|
"placeholder": "Digite sua mensagem aqui...",
|
||||||
|
"placeholder_without_triggers": "Digite a mensagem aqui, pressione {{key}} para enviar",
|
||||||
"send": "Enviar",
|
"send": "Enviar",
|
||||||
"settings": "Configurações",
|
"settings": "Configurações",
|
||||||
"thinking": {
|
"thinking": {
|
||||||
@@ -1581,13 +1583,13 @@
|
|||||||
"nami-ai-search": "Nami AI Search",
|
"nami-ai-search": "Nami AI Search",
|
||||||
"qwen": "Qwen",
|
"qwen": "Qwen",
|
||||||
"sensechat": "SenseChat",
|
"sensechat": "SenseChat",
|
||||||
|
"stepfun": "Stepfun",
|
||||||
"tencent-yuanbao": "Yuanbao",
|
"tencent-yuanbao": "Yuanbao",
|
||||||
"tiangong-ai": "Skywork",
|
"tiangong-ai": "Skywork",
|
||||||
"wanzhi": "Wanzhi",
|
"wanzhi": "Wanzhi",
|
||||||
"wenxin": "ERNIE",
|
"wenxin": "ERNIE",
|
||||||
"wps-copilot": "WPS Copilot",
|
"wps-copilot": "WPS Copilot",
|
||||||
"xiaoyi": "Xiaoyi",
|
"xiaoyi": "Xiaoyi",
|
||||||
"yuewen": "Yuewen",
|
|
||||||
"zhihu": "Zhihu"
|
"zhihu": "Zhihu"
|
||||||
},
|
},
|
||||||
"miniwindow": {
|
"miniwindow": {
|
||||||
@@ -1695,6 +1697,12 @@
|
|||||||
"provider_settings": "Ir para as configurações do provedor"
|
"provider_settings": "Ir para as configurações do provedor"
|
||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
|
"auto_rename": {
|
||||||
|
"empty_note": "A nota está vazia, não é possível gerar um nome",
|
||||||
|
"failed": "Falha ao gerar o nome da nota",
|
||||||
|
"label": "Gerar nome da nota",
|
||||||
|
"success": "Nome da nota gerado com sucesso"
|
||||||
|
},
|
||||||
"characters": "caractere",
|
"characters": "caractere",
|
||||||
"collapse": "[minimizar]",
|
"collapse": "[minimizar]",
|
||||||
"content_placeholder": "Introduza o conteúdo da nota...",
|
"content_placeholder": "Introduza o conteúdo da nota...",
|
||||||
@@ -1776,6 +1784,8 @@
|
|||||||
"sort_updated_asc": "Tempo de atualização (do mais antigo para o mais recente)",
|
"sort_updated_asc": "Tempo de atualização (do mais antigo para o mais recente)",
|
||||||
"sort_updated_desc": "atualização de tempo (do mais novo para o mais antigo)",
|
"sort_updated_desc": "atualização de tempo (do mais novo para o mais antigo)",
|
||||||
"sort_z2a": "Nome do arquivo (Z-A)",
|
"sort_z2a": "Nome do arquivo (Z-A)",
|
||||||
|
"spell_check": "verificação ortográfica",
|
||||||
|
"spell_check_tooltip": "Ativar/Desativar verificação ortográfica",
|
||||||
"star": "Notas favoritas",
|
"star": "Notas favoritas",
|
||||||
"starred_notes": "notas salvas",
|
"starred_notes": "notas salvas",
|
||||||
"title": "nota",
|
"title": "nota",
|
||||||
@@ -1825,6 +1835,57 @@
|
|||||||
},
|
},
|
||||||
"title": "Ollama"
|
"title": "Ollama"
|
||||||
},
|
},
|
||||||
|
"ovms": {
|
||||||
|
"action": {
|
||||||
|
"install": "Instalar",
|
||||||
|
"installing": "Instalando",
|
||||||
|
"reinstall": "Reinstalar",
|
||||||
|
"run": "Executar OVMS",
|
||||||
|
"starting": "Iniciando",
|
||||||
|
"stop": "Parar OVMS",
|
||||||
|
"stopping": "Parando"
|
||||||
|
},
|
||||||
|
"description": "<div><p>1. Baixe o modelo OV.</p><p>2. Adicione o modelo no 'Gerenciador'.</p><p>Compatível apenas com Windows!</p><p>Caminho de instalação do OVMS: '%USERPROFILE%\\.cherrystudio\\ovms' .</p><p>Consulte o <a href=https://github.com/openvinotoolkit/model_server/blob/c55551763d02825829337b62c2dcef9339706f79/docs/deploying_server_baremetal.md>Guia do Intel OVMS</a></p></dev>",
|
||||||
|
"download": {
|
||||||
|
"button": "Baixar",
|
||||||
|
"error": "Falha na seleção",
|
||||||
|
"model_id": {
|
||||||
|
"label": "ID do modelo:",
|
||||||
|
"model_id_pattern": "O ID do modelo deve começar com OpenVINO/",
|
||||||
|
"placeholder": "Obrigatório, por exemplo, OpenVINO/Qwen3-8B-int4-ov",
|
||||||
|
"required": "Por favor, insira o ID do modelo"
|
||||||
|
},
|
||||||
|
"model_name": {
|
||||||
|
"label": "Nome do modelo:",
|
||||||
|
"placeholder": "Obrigatório, por exemplo, Qwen3-8B-int4-ov",
|
||||||
|
"required": "Por favor, insira o nome do modelo"
|
||||||
|
},
|
||||||
|
"model_source": "Fonte do modelo:",
|
||||||
|
"model_task": "Tarefa do modelo:",
|
||||||
|
"success": "Download concluído com sucesso",
|
||||||
|
"success_desc": "O modelo \"{{modelName}}\"-\"{{modelId}}\" foi baixado com sucesso, por favor vá para a interface de gerenciamento OVMS para adicionar o modelo",
|
||||||
|
"tip": "O modelo está sendo baixado, às vezes leva várias horas. Por favor aguarde pacientemente...",
|
||||||
|
"title": "Baixar modelo Intel OpenVINO"
|
||||||
|
},
|
||||||
|
"failed": {
|
||||||
|
"install": "Falha na instalação do OVMS:",
|
||||||
|
"install_code_100": "Erro desconhecido",
|
||||||
|
"install_code_101": "Compatível apenas com CPU Intel(R) Core(TM) Ultra",
|
||||||
|
"install_code_102": "Compatível apenas com Windows",
|
||||||
|
"install_code_103": "Falha ao baixar o tempo de execução do OVMS",
|
||||||
|
"install_code_104": "Falha ao descompactar o tempo de execução do OVMS",
|
||||||
|
"install_code_105": "Falha ao limpar o tempo de execução do OVMS",
|
||||||
|
"run": "Falha ao executar o OVMS:",
|
||||||
|
"stop": "Falha ao parar o OVMS:"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"not_installed": "OVMS não instalado",
|
||||||
|
"not_running": "OVMS não está em execução",
|
||||||
|
"running": "OVMS em execução",
|
||||||
|
"unknown": "Status do OVMS desconhecido"
|
||||||
|
},
|
||||||
|
"title": "Intel OVMS"
|
||||||
|
},
|
||||||
"paintings": {
|
"paintings": {
|
||||||
"aspect_ratio": "Proporção da Imagem",
|
"aspect_ratio": "Proporção da Imagem",
|
||||||
"aspect_ratios": {
|
"aspect_ratios": {
|
||||||
@@ -2056,6 +2117,7 @@
|
|||||||
"ollama": "Ollama",
|
"ollama": "Ollama",
|
||||||
"openai": "OpenAI",
|
"openai": "OpenAI",
|
||||||
"openrouter": "OpenRouter",
|
"openrouter": "OpenRouter",
|
||||||
|
"ovms": "Intel OVMS",
|
||||||
"perplexity": "Perplexidade",
|
"perplexity": "Perplexidade",
|
||||||
"ph8": "Plataforma Aberta de Grandes Modelos PH8",
|
"ph8": "Plataforma Aberta de Grandes Modelos PH8",
|
||||||
"poe": "Poe",
|
"poe": "Poe",
|
||||||
@@ -4350,6 +4412,7 @@
|
|||||||
"later": "Mais tarde",
|
"later": "Mais tarde",
|
||||||
"message": "Nova versão {{version}} disponível, deseja instalar agora?",
|
"message": "Nova versão {{version}} disponível, deseja instalar agora?",
|
||||||
"noReleaseNotes": "Sem notas de versão",
|
"noReleaseNotes": "Sem notas de versão",
|
||||||
|
"saveDataError": "Falha ao salvar os dados, tente novamente",
|
||||||
"title": "Atualização"
|
"title": "Atualização"
|
||||||
},
|
},
|
||||||
"warning": {
|
"warning": {
|
||||||
|
|||||||
@@ -251,6 +251,7 @@
|
|||||||
"added": "Добавлено",
|
"added": "Добавлено",
|
||||||
"case_sensitive": "Чувствительность к регистру",
|
"case_sensitive": "Чувствительность к регистру",
|
||||||
"collapse": "Свернуть",
|
"collapse": "Свернуть",
|
||||||
|
"download": "Скачать",
|
||||||
"includes_user_questions": "Включает вопросы пользователей",
|
"includes_user_questions": "Включает вопросы пользователей",
|
||||||
"manage": "Редактировать",
|
"manage": "Редактировать",
|
||||||
"select_model": "Выбрать модель",
|
"select_model": "Выбрать модель",
|
||||||
@@ -333,6 +334,7 @@
|
|||||||
"new_topic": "Новый топик {{Command}}",
|
"new_topic": "Новый топик {{Command}}",
|
||||||
"pause": "Остановить",
|
"pause": "Остановить",
|
||||||
"placeholder": "Введите ваше сообщение здесь, нажмите {{key}} для отправки...",
|
"placeholder": "Введите ваше сообщение здесь, нажмите {{key}} для отправки...",
|
||||||
|
"placeholder_without_triggers": "Введите сообщение здесь, нажмите {{key}}, чтобы отправить",
|
||||||
"send": "Отправить",
|
"send": "Отправить",
|
||||||
"settings": "Настройки",
|
"settings": "Настройки",
|
||||||
"thinking": {
|
"thinking": {
|
||||||
@@ -1581,13 +1583,13 @@
|
|||||||
"nami-ai-search": "Nami AI Search",
|
"nami-ai-search": "Nami AI Search",
|
||||||
"qwen": "Qwen",
|
"qwen": "Qwen",
|
||||||
"sensechat": "SenseChat",
|
"sensechat": "SenseChat",
|
||||||
|
"stepfun": "Stepfun",
|
||||||
"tencent-yuanbao": "Tencent Yuanbao",
|
"tencent-yuanbao": "Tencent Yuanbao",
|
||||||
"tiangong-ai": "Skywork",
|
"tiangong-ai": "Skywork",
|
||||||
"wanzhi": "Wanzhi",
|
"wanzhi": "Wanzhi",
|
||||||
"wenxin": "ERNIE",
|
"wenxin": "ERNIE",
|
||||||
"wps-copilot": "WPS Copilot",
|
"wps-copilot": "WPS Copilot",
|
||||||
"xiaoyi": "Xiaoyi",
|
"xiaoyi": "Xiaoyi",
|
||||||
"yuewen": "Yuewen",
|
|
||||||
"zhihu": "Zhihu"
|
"zhihu": "Zhihu"
|
||||||
},
|
},
|
||||||
"miniwindow": {
|
"miniwindow": {
|
||||||
@@ -1695,6 +1697,12 @@
|
|||||||
"provider_settings": "Перейти к настройкам поставщика"
|
"provider_settings": "Перейти к настройкам поставщика"
|
||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
|
"auto_rename": {
|
||||||
|
"empty_note": "Заметки пусты, имя невозможно сгенерировать",
|
||||||
|
"failed": "Создание названия заметки не удалось",
|
||||||
|
"label": "Создать название заметки",
|
||||||
|
"success": "Имя заметки успешно создано"
|
||||||
|
},
|
||||||
"characters": "Символы",
|
"characters": "Символы",
|
||||||
"collapse": "Свернуть",
|
"collapse": "Свернуть",
|
||||||
"content_placeholder": "Введите содержимое заметки...",
|
"content_placeholder": "Введите содержимое заметки...",
|
||||||
@@ -1776,6 +1784,8 @@
|
|||||||
"sort_updated_asc": "Время обновления (от старого к новому)",
|
"sort_updated_asc": "Время обновления (от старого к новому)",
|
||||||
"sort_updated_desc": "Время обновления (от нового к старому)",
|
"sort_updated_desc": "Время обновления (от нового к старому)",
|
||||||
"sort_z2a": "Имя файла (Я-А)",
|
"sort_z2a": "Имя файла (Я-А)",
|
||||||
|
"spell_check": "Проверка орфографии",
|
||||||
|
"spell_check_tooltip": "Включить/отключить проверку орфографии",
|
||||||
"star": "Избранные заметки",
|
"star": "Избранные заметки",
|
||||||
"starred_notes": "Сохраненные заметки",
|
"starred_notes": "Сохраненные заметки",
|
||||||
"title": "заметки",
|
"title": "заметки",
|
||||||
@@ -1825,6 +1835,57 @@
|
|||||||
},
|
},
|
||||||
"title": "Ollama"
|
"title": "Ollama"
|
||||||
},
|
},
|
||||||
|
"ovms": {
|
||||||
|
"action": {
|
||||||
|
"install": "Установить",
|
||||||
|
"installing": "Установка",
|
||||||
|
"reinstall": "Переустановить",
|
||||||
|
"run": "Запустить OVMS",
|
||||||
|
"starting": "Запуск",
|
||||||
|
"stop": "Остановить OVMS",
|
||||||
|
"stopping": "Остановка"
|
||||||
|
},
|
||||||
|
"description": "<div><p>1. Загрузите модели OV.</p><p>2. Добавьте модели в 'Менеджер'.</p><p>Поддерживается только Windows!</p><p>Путь установки OVMS: '%USERPROFILE%\\.cherrystudio\\ovms'.</p><p>Пожалуйста, ознакомьтесь с <a href=https://github.com/openvinotoolkit/model_server/blob/c55551763d02825829337b62c2dcef9339706f79/docs/deploying_server_baremetal.md>руководством Intel OVMS</a></p></dev>",
|
||||||
|
"download": {
|
||||||
|
"button": "Скачать",
|
||||||
|
"error": "Ошибка загрузки",
|
||||||
|
"model_id": {
|
||||||
|
"label": "ID модели",
|
||||||
|
"model_id_pattern": "ID модели должен начинаться с OpenVINO/",
|
||||||
|
"placeholder": "Обязательно, например: OpenVINO/Qwen3-8B-int4-ov",
|
||||||
|
"required": "Пожалуйста, введите ID модели"
|
||||||
|
},
|
||||||
|
"model_name": {
|
||||||
|
"label": "Название модели:",
|
||||||
|
"placeholder": "Обязательно, например: Qwen3-8B-int4-ov",
|
||||||
|
"required": "Пожалуйста, введите название модели"
|
||||||
|
},
|
||||||
|
"model_source": "Источник модели:",
|
||||||
|
"model_task": "Задача модели:",
|
||||||
|
"success": "Скачивание успешно",
|
||||||
|
"success_desc": "Модель \"{{modelName}}\"-\"{{modelId}}\" успешно скачана, пожалуйста, перейдите в интерфейс управления OVMS, чтобы добавить модель",
|
||||||
|
"tip": "Модель загружается, иногда это занимает часы. Пожалуйста, будьте терпеливы...",
|
||||||
|
"title": "Скачать модель Intel OpenVINO"
|
||||||
|
},
|
||||||
|
"failed": {
|
||||||
|
"install": "Ошибка установки OVMS:",
|
||||||
|
"install_code_100": "Неизвестная ошибка",
|
||||||
|
"install_code_101": "Поддерживаются только процессоры Intel(R) Core(TM) Ultra CPU",
|
||||||
|
"install_code_102": "Поддерживается только Windows",
|
||||||
|
"install_code_103": "Ошибка загрузки среды выполнения OVMS",
|
||||||
|
"install_code_104": "Ошибка распаковки среды выполнения OVMS",
|
||||||
|
"install_code_105": "Ошибка очистки среды выполнения OVMS",
|
||||||
|
"run": "Ошибка запуска OVMS:",
|
||||||
|
"stop": "Ошибка остановки OVMS:"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"not_installed": "OVMS не установлен",
|
||||||
|
"not_running": "OVMS не запущен",
|
||||||
|
"running": "OVMS запущен",
|
||||||
|
"unknown": "Статус OVMS неизвестен"
|
||||||
|
},
|
||||||
|
"title": "Intel OVMS"
|
||||||
|
},
|
||||||
"paintings": {
|
"paintings": {
|
||||||
"aspect_ratio": "Пропорции изображения",
|
"aspect_ratio": "Пропорции изображения",
|
||||||
"aspect_ratios": {
|
"aspect_ratios": {
|
||||||
@@ -2056,6 +2117,7 @@
|
|||||||
"ollama": "Ollama",
|
"ollama": "Ollama",
|
||||||
"openai": "OpenAI",
|
"openai": "OpenAI",
|
||||||
"openrouter": "OpenRouter",
|
"openrouter": "OpenRouter",
|
||||||
|
"ovms": "Intel OVMS",
|
||||||
"perplexity": "Perplexity",
|
"perplexity": "Perplexity",
|
||||||
"ph8": "PH8",
|
"ph8": "PH8",
|
||||||
"poe": "Poe",
|
"poe": "Poe",
|
||||||
@@ -4350,6 +4412,7 @@
|
|||||||
"later": "Позже",
|
"later": "Позже",
|
||||||
"message": "Новая версия {{version}} готова, установить сейчас?",
|
"message": "Новая версия {{version}} готова, установить сейчас?",
|
||||||
"noReleaseNotes": "Нет заметок об обновлении",
|
"noReleaseNotes": "Нет заметок об обновлении",
|
||||||
|
"saveDataError": "Ошибка сохранения данных, повторите попытку",
|
||||||
"title": "Обновление"
|
"title": "Обновление"
|
||||||
},
|
},
|
||||||
"warning": {
|
"warning": {
|
||||||
|
|||||||
@@ -98,6 +98,10 @@ const CodeToolsPage: FC = () => {
|
|||||||
return m.id.includes('openai') || OPENAI_CODEX_SUPPORTED_PROVIDERS.includes(m.provider)
|
return m.id.includes('openai') || OPENAI_CODEX_SUPPORTED_PROVIDERS.includes(m.provider)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (selectedCliTool === codeTools.githubCopilotCli) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if (selectedCliTool === codeTools.qwenCode || selectedCliTool === codeTools.iFlowCli) {
|
if (selectedCliTool === codeTools.qwenCode || selectedCliTool === codeTools.iFlowCli) {
|
||||||
if (m.supported_endpoint_types) {
|
if (m.supported_endpoint_types) {
|
||||||
return ['openai', 'openai-response'].some((type) =>
|
return ['openai', 'openai-response'].some((type) =>
|
||||||
@@ -196,7 +200,7 @@ const CodeToolsPage: FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!selectedModel) {
|
if (!selectedModel && selectedCliTool !== codeTools.githubCopilotCli) {
|
||||||
return { isValid: false, message: t('code.model_required') }
|
return { isValid: false, message: t('code.model_required') }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,6 +209,11 @@ const CodeToolsPage: FC = () => {
|
|||||||
|
|
||||||
// 准备启动环境
|
// 准备启动环境
|
||||||
const prepareLaunchEnvironment = async (): Promise<Record<string, string> | null> => {
|
const prepareLaunchEnvironment = async (): Promise<Record<string, string> | null> => {
|
||||||
|
if (selectedCliTool === codeTools.githubCopilotCli) {
|
||||||
|
const userEnv = parseEnvironmentVariables(environmentVariables)
|
||||||
|
return userEnv
|
||||||
|
}
|
||||||
|
|
||||||
if (!selectedModel) return null
|
if (!selectedModel) return null
|
||||||
|
|
||||||
const modelProvider = getProviderByModel(selectedModel)
|
const modelProvider = getProviderByModel(selectedModel)
|
||||||
@@ -229,7 +238,9 @@ const CodeToolsPage: FC = () => {
|
|||||||
|
|
||||||
// 执行启动操作
|
// 执行启动操作
|
||||||
const executeLaunch = async (env: Record<string, string>) => {
|
const executeLaunch = async (env: Record<string, string>) => {
|
||||||
window.api.codeTools.run(selectedCliTool, selectedModel?.id!, currentDirectory, env, {
|
const modelId = selectedCliTool === codeTools.githubCopilotCli ? '' : selectedModel?.id!
|
||||||
|
|
||||||
|
window.api.codeTools.run(selectedCliTool, modelId, currentDirectory, env, {
|
||||||
autoUpdateToLatest,
|
autoUpdateToLatest,
|
||||||
terminal: selectedTerminal
|
terminal: selectedTerminal
|
||||||
})
|
})
|
||||||
@@ -316,7 +327,12 @@ const CodeToolsPage: FC = () => {
|
|||||||
banner
|
banner
|
||||||
style={{ borderRadius: 'var(--list-item-border-radius)' }}
|
style={{ borderRadius: 'var(--list-item-border-radius)' }}
|
||||||
message={
|
message={
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center'
|
||||||
|
}}>
|
||||||
<span>{t('code.bun_required_message')}</span>
|
<span>{t('code.bun_required_message')}</span>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
@@ -345,46 +361,64 @@ const CodeToolsPage: FC = () => {
|
|||||||
/>
|
/>
|
||||||
</SettingsItem>
|
</SettingsItem>
|
||||||
|
|
||||||
<SettingsItem>
|
{selectedCliTool !== codeTools.githubCopilotCli && (
|
||||||
<div className="settings-label">
|
<SettingsItem>
|
||||||
{t('code.model')}
|
<div className="settings-label">
|
||||||
{selectedCliTool === 'claude-code' && (
|
{t('code.model')}
|
||||||
<Popover
|
{selectedCliTool === 'claude-code' && (
|
||||||
content={
|
<Popover
|
||||||
<div style={{ width: 200 }}>
|
content={
|
||||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>{t('code.supported_providers')}</div>
|
<div style={{ width: 200 }}>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<div style={{ marginBottom: 8, fontWeight: 500 }}>{t('code.supported_providers')}</div>
|
||||||
{getClaudeSupportedProviders(allProviders).map((provider) => {
|
<div
|
||||||
return (
|
style={{
|
||||||
<Link
|
display: 'flex',
|
||||||
key={provider.id}
|
flexDirection: 'column',
|
||||||
style={{ color: 'var(--color-text)', display: 'flex', alignItems: 'center', gap: 4 }}
|
gap: 8
|
||||||
to={`/settings/provider?id=${provider.id}`}>
|
}}>
|
||||||
<ProviderLogo shape="square" src={getProviderLogo(provider.id)} size={20} />
|
{getClaudeSupportedProviders(allProviders).map((provider) => {
|
||||||
{getProviderLabel(provider.id)}
|
return (
|
||||||
<ArrowUpRight size={14} />
|
<Link
|
||||||
</Link>
|
key={provider.id}
|
||||||
)
|
style={{
|
||||||
})}
|
color: 'var(--color-text)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4
|
||||||
|
}}
|
||||||
|
to={`/settings/provider?id=${provider.id}`}>
|
||||||
|
<ProviderLogo shape="square" src={getProviderLogo(provider.id)} size={20} />
|
||||||
|
{getProviderLabel(provider.id)}
|
||||||
|
<ArrowUpRight size={14} />
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
}
|
trigger="hover"
|
||||||
trigger="hover"
|
placement="right">
|
||||||
placement="right">
|
<HelpCircle
|
||||||
<HelpCircle size={14} style={{ color: 'var(--color-text-3)', cursor: 'pointer' }} />
|
size={14}
|
||||||
</Popover>
|
style={{
|
||||||
)}
|
color: 'var(--color-text-3)',
|
||||||
</div>
|
cursor: 'pointer'
|
||||||
<ModelSelector
|
}}
|
||||||
providers={availableProviders}
|
/>
|
||||||
predicate={modelPredicate}
|
</Popover>
|
||||||
style={{ width: '100%' }}
|
)}
|
||||||
placeholder={t('code.model_placeholder')}
|
</div>
|
||||||
value={selectedModel ? getModelUniqId(selectedModel) : undefined}
|
<ModelSelector
|
||||||
onChange={handleModelChange}
|
providers={availableProviders}
|
||||||
allowClear
|
predicate={modelPredicate}
|
||||||
/>
|
style={{ width: '100%' }}
|
||||||
</SettingsItem>
|
placeholder={t('code.model_placeholder')}
|
||||||
|
value={selectedModel ? getModelUniqId(selectedModel) : undefined}
|
||||||
|
onChange={handleModelChange}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
)}
|
||||||
|
|
||||||
<SettingsItem>
|
<SettingsItem>
|
||||||
<div className="settings-label">{t('code.working_directory')}</div>
|
<div className="settings-label">{t('code.working_directory')}</div>
|
||||||
@@ -403,11 +437,27 @@ const CodeToolsPage: FC = () => {
|
|||||||
options={directories.map((dir) => ({
|
options={directories.map((dir) => ({
|
||||||
value: dir,
|
value: dir,
|
||||||
label: (
|
label: (
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div
|
||||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>{dir}</span>
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center'
|
||||||
|
}}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis'
|
||||||
|
}}>
|
||||||
|
{dir}
|
||||||
|
</span>
|
||||||
<X
|
<X
|
||||||
size={14}
|
size={14}
|
||||||
style={{ marginLeft: 8, cursor: 'pointer', color: '#999' }}
|
style={{
|
||||||
|
marginLeft: 8,
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: '#999'
|
||||||
|
}}
|
||||||
onClick={(e) => handleRemoveDirectory(dir, e)}
|
onClick={(e) => handleRemoveDirectory(dir, e)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -429,7 +479,14 @@ const CodeToolsPage: FC = () => {
|
|||||||
rows={2}
|
rows={2}
|
||||||
style={{ fontFamily: 'monospace' }}
|
style={{ fontFamily: 'monospace' }}
|
||||||
/>
|
/>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-3)', marginTop: 4 }}>{t('code.env_vars_help')}</div>
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
marginTop: 4
|
||||||
|
}}>
|
||||||
|
{t('code.env_vars_help')}
|
||||||
|
</div>
|
||||||
</SettingsItem>
|
</SettingsItem>
|
||||||
|
|
||||||
{/* 终端选择 (macOS 和 Windows) */}
|
{/* 终端选择 (macOS 和 Windows) */}
|
||||||
@@ -464,7 +521,12 @@ const CodeToolsPage: FC = () => {
|
|||||||
selectedTerminal !== terminalApps.cmd &&
|
selectedTerminal !== terminalApps.cmd &&
|
||||||
selectedTerminal !== terminalApps.powershell &&
|
selectedTerminal !== terminalApps.powershell &&
|
||||||
selectedTerminal !== terminalApps.windowsTerminal && (
|
selectedTerminal !== terminalApps.windowsTerminal && (
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-3)', marginTop: 4 }}>
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
marginTop: 4
|
||||||
|
}}>
|
||||||
{terminalCustomPaths[selectedTerminal]
|
{terminalCustomPaths[selectedTerminal]
|
||||||
? `${t('code.custom_path')}: ${terminalCustomPaths[selectedTerminal]}`
|
? `${t('code.custom_path')}: ${terminalCustomPaths[selectedTerminal]}`
|
||||||
: t('code.custom_path_required')}
|
: t('code.custom_path_required')}
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ export const CLI_TOOLS = [
|
|||||||
{ value: codeTools.qwenCode, label: 'Qwen Code' },
|
{ value: codeTools.qwenCode, label: 'Qwen Code' },
|
||||||
{ value: codeTools.geminiCli, label: 'Gemini CLI' },
|
{ value: codeTools.geminiCli, label: 'Gemini CLI' },
|
||||||
{ value: codeTools.openaiCodex, label: 'OpenAI Codex' },
|
{ value: codeTools.openaiCodex, label: 'OpenAI Codex' },
|
||||||
{ value: codeTools.iFlowCli, label: 'iFlow CLI' }
|
{ value: codeTools.iFlowCli, label: 'iFlow CLI' },
|
||||||
|
{ value: codeTools.githubCopilotCli, label: 'GitHub Copilot CLI' }
|
||||||
]
|
]
|
||||||
|
|
||||||
export const GEMINI_SUPPORTED_PROVIDERS = ['aihubmix', 'dmxapi', 'new-api', 'cherryin']
|
export const GEMINI_SUPPORTED_PROVIDERS = ['aihubmix', 'dmxapi', 'new-api', 'cherryin']
|
||||||
@@ -43,7 +44,8 @@ export const CLI_TOOL_PROVIDER_MAP: Record<string, (providers: Provider[]) => Pr
|
|||||||
[codeTools.qwenCode]: (providers) => providers.filter((p) => p.type.includes('openai')),
|
[codeTools.qwenCode]: (providers) => providers.filter((p) => p.type.includes('openai')),
|
||||||
[codeTools.openaiCodex]: (providers) =>
|
[codeTools.openaiCodex]: (providers) =>
|
||||||
providers.filter((p) => p.id === 'openai' || OPENAI_CODEX_SUPPORTED_PROVIDERS.includes(p.id)),
|
providers.filter((p) => p.id === 'openai' || OPENAI_CODEX_SUPPORTED_PROVIDERS.includes(p.id)),
|
||||||
[codeTools.iFlowCli]: (providers) => providers.filter((p) => p.type.includes('openai'))
|
[codeTools.iFlowCli]: (providers) => providers.filter((p) => p.type.includes('openai')),
|
||||||
|
[codeTools.githubCopilotCli]: () => []
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getCodeToolsApiBaseUrl = (model: Model, type: EndpointType) => {
|
export const getCodeToolsApiBaseUrl = (model: Model, type: EndpointType) => {
|
||||||
@@ -158,6 +160,10 @@ export const generateToolEnvironment = ({
|
|||||||
env.IFLOW_BASE_URL = baseUrl
|
env.IFLOW_BASE_URL = baseUrl
|
||||||
env.IFLOW_MODEL_NAME = model.id
|
env.IFLOW_MODEL_NAME = model.id
|
||||||
break
|
break
|
||||||
|
|
||||||
|
case codeTools.githubCopilotCli:
|
||||||
|
env.GITHUB_TOKEN = apiKey || ''
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
return env
|
return env
|
||||||
|
|||||||
@@ -250,21 +250,19 @@ const MentionModelsButton: FC<Props> = ({
|
|||||||
// ESC关闭时的处理:删除 @ 和搜索文本
|
// ESC关闭时的处理:删除 @ 和搜索文本
|
||||||
if (action === 'esc') {
|
if (action === 'esc') {
|
||||||
// 只有在输入触发且有模型选择动作时才删除@字符和搜索文本
|
// 只有在输入触发且有模型选择动作时才删除@字符和搜索文本
|
||||||
if (
|
const triggerInfo = ctx?.triggerInfo ?? triggerInfoRef.current
|
||||||
hasModelActionRef.current &&
|
if (hasModelActionRef.current && triggerInfo?.type === 'input' && triggerInfo?.position !== undefined) {
|
||||||
ctx.triggerInfo?.type === 'input' &&
|
|
||||||
ctx.triggerInfo?.position !== undefined
|
|
||||||
) {
|
|
||||||
// 基于当前光标 + 搜索词精确定位并删除,position 仅作兜底
|
// 基于当前光标 + 搜索词精确定位并删除,position 仅作兜底
|
||||||
setText((currentText) => {
|
setText((currentText) => {
|
||||||
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement | null
|
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement | null
|
||||||
const caret = textArea ? (textArea.selectionStart ?? currentText.length) : currentText.length
|
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删除@的情况(delete-symbol):
|
||||||
// @ 已经被Backspace自然删除,面板关闭,不需要额外操作
|
// @ 已经被Backspace自然删除,面板关闭,不需要额外操作
|
||||||
|
triggerInfoRef.current = undefined
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -103,7 +103,8 @@ const MessageErrorInfo: React.FC<{ block: ErrorMessageBlock; message: Message }>
|
|||||||
const [showDetailModal, setShowDetailModal] = useState(false)
|
const [showDetailModal, setShowDetailModal] = useState(false)
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const onRemoveBlock = () => {
|
const onRemoveBlock = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
setTimeoutTimer('onRemoveBlock', () => dispatch(removeBlocksThunk(message.topicId, message.id, [block.id])), 350)
|
setTimeoutTimer('onRemoveBlock', () => dispatch(removeBlocksThunk(message.topicId, message.id, [block.id])), 350)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useSettings } from '@renderer/hooks/useSettings'
|
|||||||
import { useTemporaryValue } from '@renderer/hooks/useTemporaryValue'
|
import { useTemporaryValue } from '@renderer/hooks/useTemporaryValue'
|
||||||
import { MessageBlockStatus, type ThinkingMessageBlock } from '@renderer/types/newMessage'
|
import { MessageBlockStatus, type ThinkingMessageBlock } from '@renderer/types/newMessage'
|
||||||
import { Collapse, message as antdMessage, Tooltip } from 'antd'
|
import { Collapse, message as antdMessage, 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 { useTranslation } from 'react-i18next'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
@@ -105,30 +105,37 @@ const ThinkingBlock: React.FC<Props> = ({ block }) => {
|
|||||||
const ThinkingTimeSeconds = memo(
|
const ThinkingTimeSeconds = memo(
|
||||||
({ blockThinkingTime, isThinking }: { blockThinkingTime: number; isThinking: boolean }) => {
|
({ blockThinkingTime, isThinking }: { blockThinkingTime: number; isThinking: boolean }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
// const [thinkingTime, setThinkingTime] = useState(blockThinkingTime || 0)
|
const [displayTime, setDisplayTime] = useState(blockThinkingTime)
|
||||||
|
|
||||||
// FIXME: 这里统计的和请求处统计的有一定误差
|
const timer = useRef<NodeJS.Timeout | null>(null)
|
||||||
// useEffect(() => {
|
|
||||||
// let timer: NodeJS.Timeout | null = null
|
|
||||||
// if (isThinking) {
|
|
||||||
// timer = setInterval(() => {
|
|
||||||
// setThinkingTime((prev) => prev + 100)
|
|
||||||
// }, 100)
|
|
||||||
// } else if (timer) {
|
|
||||||
// // 立即清除计时器
|
|
||||||
// clearInterval(timer)
|
|
||||||
// timer = null
|
|
||||||
// }
|
|
||||||
|
|
||||||
// return () => {
|
useEffect(() => {
|
||||||
// if (timer) {
|
if (isThinking) {
|
||||||
// clearInterval(timer)
|
if (!timer.current) {
|
||||||
// timer = null
|
timer.current = setInterval(() => {
|
||||||
// }
|
setDisplayTime((prev) => prev + 100)
|
||||||
// }
|
}, 100)
|
||||||
// }, [isThinking])
|
}
|
||||||
|
} 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
|
return isThinking
|
||||||
? t('chat.thinking', {
|
? t('chat.thinking', {
|
||||||
|
|||||||
@@ -235,13 +235,12 @@ describe('ThinkingBlock', () => {
|
|||||||
renderThinkingBlock(thinkingBlock)
|
renderThinkingBlock(thinkingBlock)
|
||||||
|
|
||||||
const activeTimeText = getThinkingTimeText()
|
const activeTimeText = getThinkingTimeText()
|
||||||
expect(activeTimeText).toHaveTextContent('1.0s')
|
|
||||||
expect(activeTimeText).toHaveTextContent('Thinking...')
|
expect(activeTimeText).toHaveTextContent('Thinking...')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle extreme thinking times correctly', () => {
|
it('should handle extreme thinking times correctly', () => {
|
||||||
const testCases = [
|
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: 86400000, expectedTime: '86400.0s' }, // 1 day
|
||||||
{ thinking_millsec: 259200000, expectedTime: '259200.0s' } // 3 days
|
{ thinking_millsec: 259200000, expectedTime: '259200.0s' } // 3 days
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { cn } from '@heroui/react'
|
||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
|
import HorizontalScrollContainer from '@renderer/components/HorizontalScrollContainer'
|
||||||
import Scrollbar from '@renderer/components/Scrollbar'
|
import Scrollbar from '@renderer/components/Scrollbar'
|
||||||
import { useMessageEditing } from '@renderer/context/MessageEditingContext'
|
import { useMessageEditing } from '@renderer/context/MessageEditingContext'
|
||||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||||
@@ -225,20 +227,28 @@ const MessageItem: FC<Props> = ({
|
|||||||
</MessageErrorBoundary>
|
</MessageErrorBoundary>
|
||||||
</MessageContentContainer>
|
</MessageContentContainer>
|
||||||
{showMenubar && (
|
{showMenubar && (
|
||||||
<MessageFooter className="MessageFooter" $isLastMessage={isLastMessage} $messageStyle={messageStyle}>
|
<MessageFooter className="MessageFooter">
|
||||||
<MessageMenubar
|
<HorizontalScrollContainer
|
||||||
message={message}
|
classNames={{
|
||||||
assistant={assistant}
|
content: cn(
|
||||||
model={model}
|
'flex-1 items-center justify-between',
|
||||||
index={index}
|
isLastMessage && messageStyle === 'plain' ? 'flex-row-reverse' : 'flex-row'
|
||||||
topic={topic}
|
)
|
||||||
isLastMessage={isLastMessage}
|
}}>
|
||||||
isAssistantMessage={isAssistantMessage}
|
<MessageMenubar
|
||||||
isGrouped={isGrouped}
|
message={message}
|
||||||
messageContainerRef={messageContainerRef as React.RefObject<HTMLDivElement>}
|
assistant={assistant}
|
||||||
setModel={setModel}
|
model={model}
|
||||||
onUpdateUseful={onUpdateUseful}
|
index={index}
|
||||||
/>
|
topic={topic}
|
||||||
|
isLastMessage={isLastMessage}
|
||||||
|
isAssistantMessage={isAssistantMessage}
|
||||||
|
isGrouped={isGrouped}
|
||||||
|
messageContainerRef={messageContainerRef as React.RefObject<HTMLDivElement>}
|
||||||
|
setModel={setModel}
|
||||||
|
onUpdateUseful={onUpdateUseful}
|
||||||
|
/>
|
||||||
|
</HorizontalScrollContainer>
|
||||||
</MessageFooter>
|
</MessageFooter>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -282,10 +292,8 @@ const MessageContentContainer = styled(Scrollbar)`
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
`
|
`
|
||||||
|
|
||||||
const MessageFooter = styled.div<{ $isLastMessage: boolean; $messageStyle: 'plain' | 'bubble' }>`
|
const MessageFooter = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: ${({ $isLastMessage, $messageStyle }) =>
|
|
||||||
$isLastMessage && $messageStyle === 'plain' ? 'row-reverse' : 'row'};
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|||||||
@@ -337,17 +337,29 @@ const GroupContainer = styled.div`
|
|||||||
const GridContainer = styled(Scrollbar)<{ $count: number; $gridColumns: number }>`
|
const GridContainer = styled(Scrollbar)<{ $count: number; $gridColumns: number }>`
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: grid;
|
display: grid;
|
||||||
overflow-y: visible;
|
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
|
||||||
&.horizontal {
|
&.horizontal {
|
||||||
padding-bottom: 4px;
|
padding-bottom: 4px;
|
||||||
grid-template-columns: repeat(${({ $count }) => $count}, minmax(420px, 1fr));
|
grid-template-columns: repeat(${({ $count }) => $count}, minmax(420px, 1fr));
|
||||||
|
overflow-y: hidden;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-scrollbar-thumb);
|
||||||
|
border-radius: var(--scrollbar-thumb-radius);
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--color-scrollbar-thumb-hover);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
&.fold,
|
&.fold,
|
||||||
&.vertical {
|
&.vertical {
|
||||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
&.grid {
|
&.grid {
|
||||||
grid-template-columns: repeat(
|
grid-template-columns: repeat(
|
||||||
@@ -355,11 +367,15 @@ const GridContainer = styled(Scrollbar)<{ $count: number; $gridColumns: number }
|
|||||||
minmax(0, 1fr)
|
minmax(0, 1fr)
|
||||||
);
|
);
|
||||||
grid-template-rows: auto;
|
grid-template-rows: auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.multi-select-mode {
|
&.multi-select-mode {
|
||||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
.grid {
|
.grid {
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
@@ -385,7 +401,7 @@ interface MessageWrapperProps {
|
|||||||
const MessageWrapper = styled.div<MessageWrapperProps>`
|
const MessageWrapper = styled.div<MessageWrapperProps>`
|
||||||
&.horizontal {
|
&.horizontal {
|
||||||
padding: 1px;
|
padding: 1px;
|
||||||
overflow-y: auto;
|
/* overflow-y: auto; */
|
||||||
.message {
|
.message {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border: 0.5px solid var(--color-border);
|
border: 0.5px solid var(--color-border);
|
||||||
@@ -405,8 +421,9 @@ const MessageWrapper = styled.div<MessageWrapperProps>`
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
&.grid {
|
&.grid {
|
||||||
|
display: block;
|
||||||
height: 300px;
|
height: 300px;
|
||||||
overflow-y: hidden;
|
overflow: hidden;
|
||||||
border: 0.5px solid var(--color-border);
|
border: 0.5px solid var(--color-border);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { KnowledgeSearchToolInput, KnowledgeSearchToolOutput } from '@renderer/aiCore/tools/KnowledgeSearchTool'
|
import { KnowledgeSearchToolInput, KnowledgeSearchToolOutput } from '@renderer/aiCore/tools/KnowledgeSearchTool'
|
||||||
import Spinner from '@renderer/components/Spinner'
|
import Spinner from '@renderer/components/Spinner'
|
||||||
import i18n from '@renderer/i18n'
|
import i18n from '@renderer/i18n'
|
||||||
import { MCPToolResponse } from '@renderer/types'
|
import { NormalToolResponse } from '@renderer/types'
|
||||||
import { Typography } from 'antd'
|
import { Typography } from 'antd'
|
||||||
import { FileSearch } from 'lucide-react'
|
import { FileSearch } from 'lucide-react'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
const { Text } = Typography
|
const { Text } = Typography
|
||||||
export function MessageKnowledgeSearchToolTitle({ toolResponse }: { toolResponse: MCPToolResponse }) {
|
export function MessageKnowledgeSearchToolTitle({ toolResponse }: { toolResponse: NormalToolResponse }) {
|
||||||
const toolInput = toolResponse.arguments as KnowledgeSearchToolInput
|
const toolInput = toolResponse.arguments as KnowledgeSearchToolInput
|
||||||
const toolOutput = toolResponse.response as KnowledgeSearchToolOutput
|
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
|
const toolOutput = toolResponse.response as KnowledgeSearchToolOutput
|
||||||
|
|
||||||
return toolResponse.status === 'done' ? (
|
return toolResponse.status === 'done' ? (
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
|||||||
import { useMCPServers } from '@renderer/hooks/useMCPServers'
|
import { useMCPServers } from '@renderer/hooks/useMCPServers'
|
||||||
import { useSettings } from '@renderer/hooks/useSettings'
|
import { useSettings } from '@renderer/hooks/useSettings'
|
||||||
import { useTimer } from '@renderer/hooks/useTimer'
|
import { useTimer } from '@renderer/hooks/useTimer'
|
||||||
|
import { MCPToolResponse } from '@renderer/types'
|
||||||
import type { ToolMessageBlock } from '@renderer/types/newMessage'
|
import type { ToolMessageBlock } from '@renderer/types/newMessage'
|
||||||
import { isToolAutoApproved } from '@renderer/utils/mcp-tools'
|
import { isToolAutoApproved } from '@renderer/utils/mcp-tools'
|
||||||
import { cancelToolAction, confirmToolAction } from '@renderer/utils/userConfirmation'
|
import { cancelToolAction, confirmToolAction } from '@renderer/utils/userConfirmation'
|
||||||
@@ -57,7 +58,7 @@ const MessageMcpTool: FC<Props> = ({ block }) => {
|
|||||||
const [progress, setProgress] = useState<number>(0)
|
const [progress, setProgress] = useState<number>(0)
|
||||||
const { setTimeoutTimer } = useTimer()
|
const { setTimeoutTimer } = useTimer()
|
||||||
|
|
||||||
const toolResponse = block.metadata?.rawMcpToolResponse
|
const toolResponse = block.metadata?.rawMcpToolResponse as MCPToolResponse
|
||||||
|
|
||||||
const { id, tool, status, response } = toolResponse!
|
const { id, tool, status, response } = toolResponse!
|
||||||
const isPending = status === 'pending'
|
const isPending = status === 'pending'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { MemorySearchToolInput, MemorySearchToolOutput } from '@renderer/aiCore/tools/MemorySearchTool'
|
import { MemorySearchToolInput, MemorySearchToolOutput } from '@renderer/aiCore/tools/MemorySearchTool'
|
||||||
import Spinner from '@renderer/components/Spinner'
|
import Spinner from '@renderer/components/Spinner'
|
||||||
import { MCPToolResponse } from '@renderer/types'
|
import { NormalToolResponse } from '@renderer/types'
|
||||||
import { Typography } from 'antd'
|
import { Typography } from 'antd'
|
||||||
import { ChevronRight } from 'lucide-react'
|
import { ChevronRight } from 'lucide-react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
@@ -8,7 +8,7 @@ import styled from 'styled-components'
|
|||||||
|
|
||||||
const { Text } = Typography
|
const { Text } = Typography
|
||||||
|
|
||||||
export const MessageMemorySearchToolTitle = ({ toolResponse }: { toolResponse: MCPToolResponse }) => {
|
export const MessageMemorySearchToolTitle = ({ toolResponse }: { toolResponse: NormalToolResponse }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const toolInput = toolResponse.arguments as MemorySearchToolInput
|
const toolInput = toolResponse.arguments as MemorySearchToolInput
|
||||||
const toolOutput = toolResponse.response as MemorySearchToolOutput
|
const toolOutput = toolResponse.response as MemorySearchToolOutput
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { MCPToolResponse } from '@renderer/types'
|
import { NormalToolResponse } from '@renderer/types'
|
||||||
import type { ToolMessageBlock } from '@renderer/types/newMessage'
|
import type { ToolMessageBlock } from '@renderer/types/newMessage'
|
||||||
import { Collapse } from 'antd'
|
import { Collapse } from 'antd'
|
||||||
|
|
||||||
@@ -11,8 +11,9 @@ interface Props {
|
|||||||
}
|
}
|
||||||
const prefix = 'builtin_'
|
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
|
let toolName = toolResponse.tool.name
|
||||||
|
const toolType = toolResponse.tool.type
|
||||||
if (toolName.startsWith(prefix)) {
|
if (toolName.startsWith(prefix)) {
|
||||||
toolName = toolName.slice(prefix.length)
|
toolName = toolName.slice(prefix.length)
|
||||||
}
|
}
|
||||||
@@ -20,10 +21,12 @@ const ChooseTool = (toolResponse: MCPToolResponse): { label: React.ReactNode; bo
|
|||||||
switch (toolName) {
|
switch (toolName) {
|
||||||
case 'web_search':
|
case 'web_search':
|
||||||
case 'web_search_preview':
|
case 'web_search_preview':
|
||||||
return {
|
return toolType === 'provider'
|
||||||
label: <MessageWebSearchToolTitle toolResponse={toolResponse} />,
|
? null
|
||||||
body: null
|
: {
|
||||||
}
|
label: <MessageWebSearchToolTitle toolResponse={toolResponse} />,
|
||||||
|
body: null
|
||||||
|
}
|
||||||
case 'knowledge_search':
|
case 'knowledge_search':
|
||||||
return {
|
return {
|
||||||
label: <MessageKnowledgeSearchToolTitle toolResponse={toolResponse} />,
|
label: <MessageKnowledgeSearchToolTitle toolResponse={toolResponse} />,
|
||||||
@@ -41,7 +44,7 @@ const ChooseTool = (toolResponse: MCPToolResponse): { label: React.ReactNode; bo
|
|||||||
|
|
||||||
export default function MessageTool({ block }: Props) {
|
export default function MessageTool({ block }: Props) {
|
||||||
// FIXME: 语义错误,这里已经不是 MCP tool 了,更改rawMcpToolResponse需要改用户数据, 所以暂时保留
|
// FIXME: 语义错误,这里已经不是 MCP tool 了,更改rawMcpToolResponse需要改用户数据, 所以暂时保留
|
||||||
const toolResponse = block.metadata?.rawMcpToolResponse
|
const toolResponse = block.metadata?.rawMcpToolResponse as NormalToolResponse
|
||||||
|
|
||||||
if (!toolResponse) return null
|
if (!toolResponse) return null
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { WebSearchToolInput, WebSearchToolOutput } from '@renderer/aiCore/tools/WebSearchTool'
|
import { WebSearchToolInput, WebSearchToolOutput } from '@renderer/aiCore/tools/WebSearchTool'
|
||||||
import Spinner from '@renderer/components/Spinner'
|
import Spinner from '@renderer/components/Spinner'
|
||||||
import { MCPToolResponse } from '@renderer/types'
|
import { NormalToolResponse } from '@renderer/types'
|
||||||
import { Typography } from 'antd'
|
import { Typography } from 'antd'
|
||||||
import { Search } from 'lucide-react'
|
import { Search } from 'lucide-react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
@@ -8,7 +8,7 @@ import styled from 'styled-components'
|
|||||||
|
|
||||||
const { Text } = Typography
|
const { Text } = Typography
|
||||||
|
|
||||||
export const MessageWebSearchToolTitle = ({ toolResponse }: { toolResponse: MCPToolResponse }) => {
|
export const MessageWebSearchToolTitle = ({ toolResponse }: { toolResponse: NormalToolResponse }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const toolInput = toolResponse.arguments as WebSearchToolInput
|
const toolInput = toolResponse.arguments as WebSearchToolInput
|
||||||
const toolOutput = toolResponse.response as WebSearchToolOutput
|
const toolOutput = toolResponse.response as WebSearchToolOutput
|
||||||
|
|||||||
@@ -525,6 +525,11 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic,
|
|||||||
onContextMenu={() => setTargetTopic(topic)}
|
onContextMenu={() => setTargetTopic(topic)}
|
||||||
className={classNames(isActive ? 'active' : '', singlealone ? 'singlealone' : '')}
|
className={classNames(isActive ? 'active' : '', singlealone ? 'singlealone' : '')}
|
||||||
onClick={editingTopicId === topic.id && topicEdit.isEditing ? undefined : () => onSwitchTopic(topic)}
|
onClick={editingTopicId === topic.id && topicEdit.isEditing ? undefined : () => onSwitchTopic(topic)}
|
||||||
|
onDoubleClick={() => {
|
||||||
|
if (editingTopicId === topic.id && topicEdit.isEditing) return
|
||||||
|
setEditingTopicId(topic.id)
|
||||||
|
topicEdit.startEdit(topic.name)
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
borderRadius,
|
borderRadius,
|
||||||
cursor: editingTopicId === topic.id && topicEdit.isEditing ? 'default' : 'pointer'
|
cursor: editingTopicId === topic.id && topicEdit.isEditing ? 'default' : 'pointer'
|
||||||
@@ -541,13 +546,7 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic,
|
|||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<TopicName
|
<TopicName className={getTopicNameClassName()} title={topicName}>
|
||||||
className={getTopicNameClassName()}
|
|
||||||
title={topicName}
|
|
||||||
onDoubleClick={() => {
|
|
||||||
setEditingTopicId(topic.id)
|
|
||||||
topicEdit.startEdit(topic.name)
|
|
||||||
}}>
|
|
||||||
{topicName}
|
{topicName}
|
||||||
</TopicName>
|
</TopicName>
|
||||||
)}
|
)}
|
||||||
@@ -571,7 +570,8 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic,
|
|||||||
} else {
|
} else {
|
||||||
handleDeleteClick(topic.id, e)
|
handleDeleteClick(topic.id, e)
|
||||||
}
|
}
|
||||||
}}>
|
}}
|
||||||
|
onDoubleClick={(e) => e.stopPropagation()}>
|
||||||
{deletingTopicId === topic.id ? (
|
{deletingTopicId === topic.id ? (
|
||||||
<DeleteIcon size={14} color="var(--color-error)" style={{ pointerEvents: 'none' }} />
|
<DeleteIcon size={14} color="var(--color-error)" style={{ pointerEvents: 'none' }} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -386,6 +386,7 @@ const Container = styled.div`
|
|||||||
border-radius: var(--list-item-border-radius);
|
border-radius: var(--list-item-border-radius);
|
||||||
border: 0.5px solid transparent;
|
border: 0.5px solid transparent;
|
||||||
width: calc(var(--assistants-width) - 20px);
|
width: calc(var(--assistants-width) - 20px);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--color-list-item-hover);
|
background-color: var(--color-list-item-hover);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { SyncOutlined } from '@ant-design/icons'
|
import { SyncOutlined } from '@ant-design/icons'
|
||||||
|
import { useDisclosure } from '@heroui/react'
|
||||||
|
import UpdateDialog from '@renderer/components/UpdateDialog'
|
||||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||||
import { useSettings } from '@renderer/hooks/useSettings'
|
import { useSettings } from '@renderer/hooks/useSettings'
|
||||||
import { Button } from 'antd'
|
import { Button } from 'antd'
|
||||||
@@ -10,6 +12,7 @@ const UpdateAppButton: FC = () => {
|
|||||||
const { update } = useRuntime()
|
const { update } = useRuntime()
|
||||||
const { autoCheckUpdate } = useSettings()
|
const { autoCheckUpdate } = useSettings()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||||
|
|
||||||
if (!update) {
|
if (!update) {
|
||||||
return null
|
return null
|
||||||
@@ -23,13 +26,15 @@ const UpdateAppButton: FC = () => {
|
|||||||
<Container>
|
<Container>
|
||||||
<UpdateButton
|
<UpdateButton
|
||||||
className="nodrag"
|
className="nodrag"
|
||||||
onClick={() => window.api.showUpdateDialog()}
|
onClick={onOpen}
|
||||||
icon={<SyncOutlined />}
|
icon={<SyncOutlined />}
|
||||||
color="orange"
|
color="orange"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
size="small">
|
size="small">
|
||||||
{t('button.update_available')}
|
{t('button.update_available')}
|
||||||
</UpdateButton>
|
</UpdateButton>
|
||||||
|
|
||||||
|
<UpdateDialog isOpen={isOpen} onClose={onClose} releaseInfo={update.info || null} />
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import styled from 'styled-components'
|
|||||||
|
|
||||||
// Tab 模式下新的页面壳,不再直接创建 WebView,而是依赖全局 MinAppTabsPool
|
// Tab 模式下新的页面壳,不再直接创建 WebView,而是依赖全局 MinAppTabsPool
|
||||||
import MinimalToolbar from './components/MinimalToolbar'
|
import MinimalToolbar from './components/MinimalToolbar'
|
||||||
|
import WebviewSearch from './components/WebviewSearch'
|
||||||
|
|
||||||
const logger = loggerService.withContext('MinAppPage')
|
const logger = loggerService.withContext('MinAppPage')
|
||||||
|
|
||||||
@@ -184,6 +185,7 @@ const MinAppPage: FC = () => {
|
|||||||
onOpenDevTools={handleOpenDevTools}
|
onOpenDevTools={handleOpenDevTools}
|
||||||
/>
|
/>
|
||||||
</ToolbarWrapper>
|
</ToolbarWrapper>
|
||||||
|
<WebviewSearch webviewRef={webviewRef} isWebviewReady={isReady} appId={app.id} />
|
||||||
{!isReady && (
|
{!isReady && (
|
||||||
<LoadingMask>
|
<LoadingMask>
|
||||||
<Avatar src={app.logo} size={60} style={{ border: '1px solid var(--color-border)' }} />
|
<Avatar src={app.logo} size={60} style={{ border: '1px solid var(--color-border)' }} />
|
||||||
|
|||||||
298
src/renderer/src/pages/minapps/components/WebviewSearch.tsx
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
import { Button, Input } from '@heroui/react'
|
||||||
|
import { loggerService } from '@logger'
|
||||||
|
import type { WebviewTag } from 'electron'
|
||||||
|
import { ChevronDown, ChevronUp, X } from 'lucide-react'
|
||||||
|
import { FC, useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
type FoundInPageResult = Electron.FoundInPageResult
|
||||||
|
|
||||||
|
interface WebviewSearchProps {
|
||||||
|
webviewRef: React.RefObject<WebviewTag | null>
|
||||||
|
isWebviewReady: boolean
|
||||||
|
appId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('WebviewSearch')
|
||||||
|
|
||||||
|
const WebviewSearch: FC<WebviewSearchProps> = ({ webviewRef, isWebviewReady, appId }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [isVisible, setIsVisible] = useState(false)
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [matchCount, setMatchCount] = useState(0)
|
||||||
|
const [activeIndex, setActiveIndex] = useState(0)
|
||||||
|
const [currentWebview, setCurrentWebview] = useState<WebviewTag | null>(null)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const focusFrameRef = useRef<number | null>(null)
|
||||||
|
const lastAppIdRef = useRef<string>(appId)
|
||||||
|
const attachedWebviewRef = useRef<WebviewTag | null>(null)
|
||||||
|
|
||||||
|
const focusInput = useCallback(() => {
|
||||||
|
if (focusFrameRef.current !== null) {
|
||||||
|
window.cancelAnimationFrame(focusFrameRef.current)
|
||||||
|
focusFrameRef.current = null
|
||||||
|
}
|
||||||
|
focusFrameRef.current = window.requestAnimationFrame(() => {
|
||||||
|
inputRef.current?.focus()
|
||||||
|
inputRef.current?.select()
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const resetSearchState = useCallback((options?: { keepQuery?: boolean }) => {
|
||||||
|
if (!options?.keepQuery) {
|
||||||
|
setQuery('')
|
||||||
|
}
|
||||||
|
setMatchCount(0)
|
||||||
|
setActiveIndex(0)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const stopSearch = useCallback(() => {
|
||||||
|
const target = webviewRef.current ?? attachedWebviewRef.current
|
||||||
|
if (!target) return
|
||||||
|
try {
|
||||||
|
target.stopFindInPage('clearSelection')
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('stopFindInPage failed', { error })
|
||||||
|
}
|
||||||
|
}, [webviewRef])
|
||||||
|
|
||||||
|
const closeSearch = useCallback(() => {
|
||||||
|
setIsVisible(false)
|
||||||
|
stopSearch()
|
||||||
|
resetSearchState({ keepQuery: true })
|
||||||
|
}, [resetSearchState, stopSearch])
|
||||||
|
|
||||||
|
const performSearch = useCallback(
|
||||||
|
(text: string, options?: Electron.FindInPageOptions) => {
|
||||||
|
const target = webviewRef.current ?? attachedWebviewRef.current
|
||||||
|
if (!target) {
|
||||||
|
logger.debug('Skip performSearch: webview not attached')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!text) {
|
||||||
|
stopSearch()
|
||||||
|
resetSearchState({ keepQuery: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
target.findInPage(text, options)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('findInPage failed', { error })
|
||||||
|
window.toast?.error(t('common.error'))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[resetSearchState, stopSearch, t, webviewRef]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleFoundInPage = useCallback((event: Event & { result?: FoundInPageResult }) => {
|
||||||
|
if (!event.result) return
|
||||||
|
|
||||||
|
const { activeMatchOrdinal, matches } = event.result
|
||||||
|
|
||||||
|
if (matches !== undefined) {
|
||||||
|
setMatchCount(matches)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeMatchOrdinal !== undefined) {
|
||||||
|
setActiveIndex(activeMatchOrdinal)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const openSearch = useCallback(() => {
|
||||||
|
if (!isWebviewReady) {
|
||||||
|
logger.debug('Skip openSearch: webview not ready')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setIsVisible(true)
|
||||||
|
focusInput()
|
||||||
|
}, [focusInput, isWebviewReady])
|
||||||
|
|
||||||
|
const goToNext = useCallback(() => {
|
||||||
|
if (!query) return
|
||||||
|
performSearch(query, { forward: true, findNext: true })
|
||||||
|
}, [performSearch, query])
|
||||||
|
|
||||||
|
const goToPrevious = useCallback(() => {
|
||||||
|
if (!query) return
|
||||||
|
performSearch(query, { forward: false, findNext: true })
|
||||||
|
}, [performSearch, query])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const nextWebview = webviewRef.current ?? null
|
||||||
|
if (currentWebview === nextWebview) return
|
||||||
|
setCurrentWebview(nextWebview)
|
||||||
|
}, [currentWebview, webviewRef])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const target = currentWebview
|
||||||
|
if (!target) {
|
||||||
|
attachedWebviewRef.current = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const handle = handleFoundInPage
|
||||||
|
attachedWebviewRef.current = target
|
||||||
|
target.addEventListener('found-in-page', handle)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
target.removeEventListener('found-in-page', handle)
|
||||||
|
if (attachedWebviewRef.current === target) {
|
||||||
|
try {
|
||||||
|
target.stopFindInPage('clearSelection')
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('stopFindInPage failed', { error })
|
||||||
|
}
|
||||||
|
attachedWebviewRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [currentWebview, handleFoundInPage])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isVisible) return
|
||||||
|
focusInput()
|
||||||
|
}, [focusInput, isVisible])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isVisible) return
|
||||||
|
if (!query) {
|
||||||
|
performSearch('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
performSearch(query)
|
||||||
|
}, [currentWebview, isVisible, performSearch, query])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeydown = (event: KeyboardEvent) => {
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'f') {
|
||||||
|
event.preventDefault()
|
||||||
|
openSearch()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isVisible) return
|
||||||
|
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault()
|
||||||
|
closeSearch()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault()
|
||||||
|
if (event.shiftKey) {
|
||||||
|
goToPrevious()
|
||||||
|
} else {
|
||||||
|
goToNext()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeydown, true)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleKeydown, true)
|
||||||
|
}
|
||||||
|
}, [closeSearch, goToNext, goToPrevious, isVisible, openSearch])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isWebviewReady) {
|
||||||
|
setIsVisible(false)
|
||||||
|
resetSearchState()
|
||||||
|
stopSearch()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}, [isWebviewReady, resetSearchState, stopSearch])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!appId) return
|
||||||
|
if (lastAppIdRef.current === appId) return
|
||||||
|
lastAppIdRef.current = appId
|
||||||
|
setIsVisible(false)
|
||||||
|
resetSearchState()
|
||||||
|
stopSearch()
|
||||||
|
}, [appId, resetSearchState, stopSearch])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
stopSearch()
|
||||||
|
if (focusFrameRef.current !== null) {
|
||||||
|
window.cancelAnimationFrame(focusFrameRef.current)
|
||||||
|
focusFrameRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [stopSearch])
|
||||||
|
|
||||||
|
if (!isVisible) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchLabel = `${matchCount > 0 ? Math.max(activeIndex, 1) : 0}/${matchCount}`
|
||||||
|
const noResultTitle = matchCount === 0 && query ? t('common.no_results') : undefined
|
||||||
|
const disableNavigation = !query || matchCount === 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-auto absolute top-3 right-3 z-50 flex items-center gap-2 rounded-xl border border-default-200 bg-background px-2 py-1 shadow-lg">
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
autoFocus
|
||||||
|
value={query}
|
||||||
|
onValueChange={setQuery}
|
||||||
|
spellCheck={'false'}
|
||||||
|
placeholder={t('common.search')}
|
||||||
|
size="sm"
|
||||||
|
radius="sm"
|
||||||
|
variant="flat"
|
||||||
|
classNames={{
|
||||||
|
base: 'w-[240px]',
|
||||||
|
inputWrapper:
|
||||||
|
'h-8 bg-transparent border border-transparent shadow-none hover:border-transparent hover:bg-transparent focus:border-transparent data-[hover=true]:border-transparent data-[focus=true]:border-transparent data-[focus-visible=true]:outline-none data-[focus-visible=true]:ring-0',
|
||||||
|
input: 'text-small focus:outline-none focus-visible:outline-none',
|
||||||
|
innerWrapper: 'gap-0'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="min-w-[44px] text-center text-default-500 text-small tabular-nums"
|
||||||
|
title={noResultTitle}
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="true">
|
||||||
|
{matchLabel}
|
||||||
|
</span>
|
||||||
|
<div className="h-4 w-px bg-default-200" />
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
radius="full"
|
||||||
|
isIconOnly
|
||||||
|
onPress={goToPrevious}
|
||||||
|
isDisabled={disableNavigation}
|
||||||
|
aria-label="Previous match"
|
||||||
|
className="text-default-500 hover:text-default-900">
|
||||||
|
<ChevronUp size={16} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
radius="full"
|
||||||
|
isIconOnly
|
||||||
|
onPress={goToNext}
|
||||||
|
isDisabled={disableNavigation}
|
||||||
|
aria-label="Next match"
|
||||||
|
className="text-default-500 hover:text-default-900">
|
||||||
|
<ChevronDown size={16} />
|
||||||
|
</Button>
|
||||||
|
<div className="h-4 w-px bg-default-200" />
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
radius="full"
|
||||||
|
isIconOnly
|
||||||
|
onPress={closeSearch}
|
||||||
|
aria-label={t('common.close')}
|
||||||
|
className="text-default-500 hover:text-default-900">
|
||||||
|
<X size={16} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WebviewSearch
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import type { WebviewTag } from 'electron'
|
||||||
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import WebviewSearch from '../WebviewSearch'
|
||||||
|
|
||||||
|
const translations: Record<string, string> = {
|
||||||
|
'common.close': 'Close',
|
||||||
|
'common.error': 'Error',
|
||||||
|
'common.no_results': 'No results',
|
||||||
|
'common.search': 'Search'
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string) => translations[key] ?? key
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
const createWebviewMock = () => {
|
||||||
|
const listeners = new Map<string, Set<(event: Event & { result?: Electron.FoundInPageResult }) => void>>()
|
||||||
|
const findInPageMock = vi.fn()
|
||||||
|
const stopFindInPageMock = vi.fn()
|
||||||
|
const webview = {
|
||||||
|
addEventListener: vi.fn(
|
||||||
|
(type: string, listener: (event: Event & { result?: Electron.FoundInPageResult }) => void) => {
|
||||||
|
if (!listeners.has(type)) {
|
||||||
|
listeners.set(type, new Set())
|
||||||
|
}
|
||||||
|
listeners.get(type)!.add(listener)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
removeEventListener: vi.fn(
|
||||||
|
(type: string, listener: (event: Event & { result?: Electron.FoundInPageResult }) => void) => {
|
||||||
|
listeners.get(type)?.delete(listener)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
findInPage: findInPageMock as unknown as WebviewTag['findInPage'],
|
||||||
|
stopFindInPage: stopFindInPageMock as unknown as WebviewTag['stopFindInPage']
|
||||||
|
} as unknown as WebviewTag
|
||||||
|
|
||||||
|
const emit = (type: string, result?: Electron.FoundInPageResult) => {
|
||||||
|
listeners.get(type)?.forEach((listener) => {
|
||||||
|
const event = new CustomEvent(type) as Event & { result?: Electron.FoundInPageResult }
|
||||||
|
event.result = result
|
||||||
|
listener(event)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
emit,
|
||||||
|
findInPageMock,
|
||||||
|
stopFindInPageMock,
|
||||||
|
webview
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openSearchOverlay = async () => {
|
||||||
|
await act(async () => {
|
||||||
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true }))
|
||||||
|
})
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalRAF = window.requestAnimationFrame
|
||||||
|
const originalCAF = window.cancelAnimationFrame
|
||||||
|
|
||||||
|
const requestAnimationFrameMock = vi.fn((callback: FrameRequestCallback) => {
|
||||||
|
callback(0)
|
||||||
|
return 1
|
||||||
|
})
|
||||||
|
const cancelAnimationFrameMock = vi.fn()
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(window, 'requestAnimationFrame', {
|
||||||
|
value: requestAnimationFrameMock,
|
||||||
|
writable: true
|
||||||
|
})
|
||||||
|
Object.defineProperty(window, 'cancelAnimationFrame', {
|
||||||
|
value: cancelAnimationFrameMock,
|
||||||
|
writable: true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
Object.defineProperty(window, 'requestAnimationFrame', {
|
||||||
|
value: originalRAF
|
||||||
|
})
|
||||||
|
Object.defineProperty(window, 'cancelAnimationFrame', {
|
||||||
|
value: originalCAF
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('WebviewSearch', () => {
|
||||||
|
const toastMock = {
|
||||||
|
error: vi.fn(),
|
||||||
|
success: vi.fn(),
|
||||||
|
warning: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
addToast: vi.fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
Object.assign(window, { toast: toastMock })
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens the search overlay with keyboard shortcut', async () => {
|
||||||
|
const { webview } = createWebviewMock()
|
||||||
|
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
|
||||||
|
|
||||||
|
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
|
||||||
|
|
||||||
|
expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
await openSearchOverlay()
|
||||||
|
|
||||||
|
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('performs searches and navigates between results', async () => {
|
||||||
|
const { emit, findInPageMock, webview } = createWebviewMock()
|
||||||
|
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
|
||||||
|
await openSearchOverlay()
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
await user.type(input, 'Cherry')
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(findInPageMock).toHaveBeenCalledWith('Cherry', undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
emit('found-in-page', {
|
||||||
|
requestId: 1,
|
||||||
|
matches: 3,
|
||||||
|
activeMatchOrdinal: 1,
|
||||||
|
selectionArea: undefined as unknown as Electron.Rectangle,
|
||||||
|
finalUpdate: false
|
||||||
|
} as Electron.FoundInPageResult)
|
||||||
|
})
|
||||||
|
|
||||||
|
const nextButton = screen.getByRole('button', { name: 'Next match' })
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(nextButton).not.toBeDisabled()
|
||||||
|
})
|
||||||
|
await user.click(nextButton)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(findInPageMock).toHaveBeenLastCalledWith('Cherry', { forward: true, findNext: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
const previousButton = screen.getByRole('button', { name: 'Previous match' })
|
||||||
|
await user.click(previousButton)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(findInPageMock).toHaveBeenLastCalledWith('Cherry', { forward: false, findNext: true })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears search state when appId changes', async () => {
|
||||||
|
const { findInPageMock, stopFindInPageMock, webview } = createWebviewMock()
|
||||||
|
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
const { rerender } = render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
|
||||||
|
await openSearchOverlay()
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
await user.type(input, 'Cherry')
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(findInPageMock).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
rerender(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-2" />)
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
expect(stopFindInPageMock).toHaveBeenCalledWith('clearSelection')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows toast error when search fails', async () => {
|
||||||
|
const { findInPageMock, webview } = createWebviewMock()
|
||||||
|
findInPageMock.mockImplementation(() => {
|
||||||
|
throw new Error('findInPage failed')
|
||||||
|
})
|
||||||
|
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
|
||||||
|
await openSearchOverlay()
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
await user.type(input, 'Cherry')
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(toastMock.error).toHaveBeenCalledWith('Error')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stops search when component unmounts', async () => {
|
||||||
|
const { stopFindInPageMock, webview } = createWebviewMock()
|
||||||
|
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
|
||||||
|
|
||||||
|
const { unmount } = render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
|
||||||
|
await openSearchOverlay()
|
||||||
|
|
||||||
|
stopFindInPageMock.mockClear()
|
||||||
|
unmount()
|
||||||
|
|
||||||
|
expect(stopFindInPageMock).toHaveBeenCalledWith('clearSelection')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores keyboard shortcut when webview is not ready', async () => {
|
||||||
|
const { findInPageMock, webview } = createWebviewMock()
|
||||||
|
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
|
||||||
|
|
||||||
|
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady={false} appId="app-1" />)
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.keyDown(window, { key: 'f', ctrlKey: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument()
|
||||||
|
expect(findInPageMock).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -5,24 +5,25 @@ import { HStack } from '@renderer/components/Layout'
|
|||||||
import { useActiveNode } from '@renderer/hooks/useNotesQuery'
|
import { useActiveNode } from '@renderer/hooks/useNotesQuery'
|
||||||
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
|
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
|
||||||
import { useShowWorkspace } from '@renderer/hooks/useShowWorkspace'
|
import { useShowWorkspace } from '@renderer/hooks/useShowWorkspace'
|
||||||
import { findNodeByPath, findNodeInTree, updateNodeInTree } from '@renderer/services/NotesTreeService'
|
import { findNode } from '@renderer/services/NotesTreeService'
|
||||||
import { NotesTreeNode } from '@types'
|
import { Dropdown, Input, Tooltip } from 'antd'
|
||||||
import { Dropdown, Tooltip } from 'antd'
|
|
||||||
import { t } from 'i18next'
|
import { t } from 'i18next'
|
||||||
import { MoreHorizontal, PanelLeftClose, PanelRightClose, Star } from 'lucide-react'
|
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 styled from 'styled-components'
|
||||||
|
|
||||||
import { menuItems } from './MenuConfig'
|
import { menuItems } from './MenuConfig'
|
||||||
|
|
||||||
const logger = loggerService.withContext('HeaderNavbar')
|
const logger = loggerService.withContext('HeaderNavbar')
|
||||||
|
|
||||||
const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar }) => {
|
const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar, onExpandPath, onRenameNode }) => {
|
||||||
const { showWorkspace, toggleShowWorkspace } = useShowWorkspace()
|
const { showWorkspace, toggleShowWorkspace } = useShowWorkspace()
|
||||||
const { activeNode } = useActiveNode(notesTree)
|
const { activeNode } = useActiveNode(notesTree)
|
||||||
const [breadcrumbItems, setBreadcrumbItems] = useState<
|
const [breadcrumbItems, setBreadcrumbItems] = useState<
|
||||||
Array<{ key: string; title: string; treePath: string; isFolder: boolean }>
|
Array<{ key: string; title: string; treePath: string; isFolder: boolean }>
|
||||||
>([])
|
>([])
|
||||||
|
const [titleValue, setTitleValue] = useState('')
|
||||||
|
const titleInputRef = useRef<any>(null)
|
||||||
const { settings, updateSettings } = useNotesSettings()
|
const { settings, updateSettings } = useNotesSettings()
|
||||||
const canShowStarButton = activeNode?.type === 'file' && onToggleStar
|
const canShowStarButton = activeNode?.type === 'file' && onToggleStar
|
||||||
|
|
||||||
@@ -52,37 +53,41 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar }) => {
|
|||||||
}, [getCurrentNoteContent])
|
}, [getCurrentNoteContent])
|
||||||
|
|
||||||
const handleBreadcrumbClick = useCallback(
|
const handleBreadcrumbClick = useCallback(
|
||||||
async (item: { treePath: string; isFolder: boolean }) => {
|
(item: { treePath: string; isFolder: boolean }) => {
|
||||||
if (item.isFolder && notesTree) {
|
if (item.isFolder && onExpandPath) {
|
||||||
try {
|
onExpandPath(item.treePath)
|
||||||
// 获取从根目录到点击目录的所有路径片段
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[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) => {
|
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(() => {
|
useEffect(() => {
|
||||||
if (!activeNode || !notesTree) {
|
if (!activeNode || !notesTree) {
|
||||||
setBreadcrumbItems([])
|
setBreadcrumbItems([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const node = findNodeInTree(notesTree, activeNode.id)
|
const node = findNode(notesTree, activeNode.id)
|
||||||
if (!node) return
|
if (!node) return
|
||||||
|
|
||||||
const pathParts = node.treePath.split('/').filter(Boolean)
|
const pathParts = node.treePath.split('/').filter(Boolean)
|
||||||
@@ -179,16 +191,41 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar }) => {
|
|||||||
</HStack>
|
</HStack>
|
||||||
<NavbarCenter style={{ flex: 1, minWidth: 0 }}>
|
<NavbarCenter style={{ flex: 1, minWidth: 0 }}>
|
||||||
<BreadcrumbsContainer>
|
<BreadcrumbsContainer>
|
||||||
<Breadcrumbs>
|
<Breadcrumbs style={{ borderRadius: 0 }}>
|
||||||
{breadcrumbItems.map((item, index) => (
|
{breadcrumbItems.map((item, index) => {
|
||||||
<BreadcrumbItem key={item.key} isCurrent={index === breadcrumbItems.length - 1}>
|
const isLastItem = index === breadcrumbItems.length - 1
|
||||||
<BreadcrumbTitle
|
const isCurrentNote = isLastItem && !item.isFolder
|
||||||
onClick={() => handleBreadcrumbClick(item)}
|
|
||||||
$clickable={item.isFolder && index < breadcrumbItems.length - 1}>
|
return (
|
||||||
{item.title}
|
<BreadcrumbItem key={item.key} isCurrent={isLastItem}>
|
||||||
</BreadcrumbTitle>
|
{isCurrentNote ? (
|
||||||
</BreadcrumbItem>
|
<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>
|
</Breadcrumbs>
|
||||||
</BreadcrumbsContainer>
|
</BreadcrumbsContainer>
|
||||||
</NavbarCenter>
|
</NavbarCenter>
|
||||||
@@ -303,6 +340,30 @@ export const BreadcrumbsContainer = styled.div`
|
|||||||
align-items: center;
|
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 {
|
& li:not(:last-child)::after {
|
||||||
flex-shrink: 0;
|
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
|
export default HeaderNavbar
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
|
import ActionIconButton from '@renderer/components/Buttons/ActionIconButton'
|
||||||
import CodeEditor from '@renderer/components/CodeEditor'
|
import CodeEditor from '@renderer/components/CodeEditor'
|
||||||
import { HSpaceBetweenStack } from '@renderer/components/Layout'
|
import { HSpaceBetweenStack } from '@renderer/components/Layout'
|
||||||
import RichEditor from '@renderer/components/RichEditor'
|
import RichEditor from '@renderer/components/RichEditor'
|
||||||
import { RichEditorRef } from '@renderer/components/RichEditor/types'
|
import { RichEditorRef } from '@renderer/components/RichEditor/types'
|
||||||
import Selector from '@renderer/components/Selector'
|
import Selector from '@renderer/components/Selector'
|
||||||
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
|
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
|
||||||
|
import { useSettings } from '@renderer/hooks/useSettings'
|
||||||
|
import { useAppDispatch } from '@renderer/store'
|
||||||
|
import { setEnableSpellCheck } from '@renderer/store/settings'
|
||||||
import { EditorView } from '@renderer/types'
|
import { EditorView } from '@renderer/types'
|
||||||
import { Empty, Spin } from 'antd'
|
import { Empty, Tooltip } from 'antd'
|
||||||
|
import { SpellCheck } from 'lucide-react'
|
||||||
import { FC, memo, RefObject, useCallback, useMemo, useState } from 'react'
|
import { FC, memo, RefObject, useCallback, useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
@@ -14,15 +19,16 @@ interface NotesEditorProps {
|
|||||||
activeNodeId?: string
|
activeNodeId?: string
|
||||||
currentContent: string
|
currentContent: string
|
||||||
tokenCount: number
|
tokenCount: number
|
||||||
isLoading: boolean
|
|
||||||
editorRef: RefObject<RichEditorRef | null>
|
editorRef: RefObject<RichEditorRef | null>
|
||||||
onMarkdownChange: (content: string) => void
|
onMarkdownChange: (content: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const NotesEditor: FC<NotesEditorProps> = memo(
|
const NotesEditor: FC<NotesEditorProps> = memo(
|
||||||
({ activeNodeId, currentContent, tokenCount, isLoading, onMarkdownChange, editorRef }) => {
|
({ activeNodeId, currentContent, tokenCount, onMarkdownChange, editorRef }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
const { settings } = useNotesSettings()
|
const { settings } = useNotesSettings()
|
||||||
|
const { enableSpellCheck } = useSettings()
|
||||||
const currentViewMode = useMemo(() => {
|
const currentViewMode = useMemo(() => {
|
||||||
if (settings.defaultViewMode === 'edit') {
|
if (settings.defaultViewMode === 'edit') {
|
||||||
return settings.defaultEditMode
|
return settings.defaultEditMode
|
||||||
@@ -47,14 +53,6 @@ const NotesEditor: FC<NotesEditorProps> = memo(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<LoadingContainer>
|
|
||||||
<Spin tip={t('common.loading')} />
|
|
||||||
</LoadingContainer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<RichEditorContainer>
|
<RichEditorContainer>
|
||||||
@@ -87,6 +85,7 @@ const NotesEditor: FC<NotesEditorProps> = memo(
|
|||||||
isFullWidth={settings.isFullWidth}
|
isFullWidth={settings.isFullWidth}
|
||||||
fontFamily={settings.fontFamily}
|
fontFamily={settings.fontFamily}
|
||||||
fontSize={settings.fontSize}
|
fontSize={settings.fontSize}
|
||||||
|
enableSpellCheck={enableSpellCheck}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</RichEditorContainer>
|
</RichEditorContainer>
|
||||||
@@ -101,8 +100,21 @@ const NotesEditor: FC<NotesEditorProps> = memo(
|
|||||||
color: 'var(--color-text-3)',
|
color: 'var(--color-text-3)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 8
|
gap: 12
|
||||||
}}>
|
}}>
|
||||||
|
{tmpViewMode === 'preview' && (
|
||||||
|
<Tooltip placement="top" title={t('notes.spell_check_tooltip')} mouseLeaveDelay={0} arrow>
|
||||||
|
<ActionIconButton
|
||||||
|
active={enableSpellCheck}
|
||||||
|
onClick={() => {
|
||||||
|
const newValue = !enableSpellCheck
|
||||||
|
dispatch(setEnableSpellCheck(newValue))
|
||||||
|
window.api.setEnableSpellCheck(newValue)
|
||||||
|
}}>
|
||||||
|
<SpellCheck size={18} />
|
||||||
|
</ActionIconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
<Selector
|
<Selector
|
||||||
value={tmpViewMode as EditorView}
|
value={tmpViewMode as EditorView}
|
||||||
onChange={(value: EditorView) => setTmpViewMode(value)}
|
onChange={(value: EditorView) => setTmpViewMode(value)}
|
||||||
@@ -122,14 +134,6 @@ const NotesEditor: FC<NotesEditorProps> = memo(
|
|||||||
|
|
||||||
NotesEditor.displayName = 'NotesEditor'
|
NotesEditor.displayName = 'NotesEditor'
|
||||||
|
|
||||||
const LoadingContainer = styled.div`
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
`
|
|
||||||
|
|
||||||
const EmptyContainer = styled.div`
|
const EmptyContainer = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
@@ -5,21 +5,38 @@ import { useActiveNode, useFileContent, useFileContentSync } from '@renderer/hoo
|
|||||||
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
|
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
|
||||||
import { useShowWorkspace } from '@renderer/hooks/useShowWorkspace'
|
import { useShowWorkspace } from '@renderer/hooks/useShowWorkspace'
|
||||||
import {
|
import {
|
||||||
createFolder,
|
addDir,
|
||||||
createNote,
|
addNote,
|
||||||
deleteNode,
|
delNode,
|
||||||
initWorkSpace,
|
loadTree,
|
||||||
moveNode,
|
renameNode as renameEntry,
|
||||||
renameNode,
|
sortTree,
|
||||||
sortAllLevels,
|
uploadNotes
|
||||||
uploadFiles
|
|
||||||
} from '@renderer/services/NotesService'
|
} from '@renderer/services/NotesService'
|
||||||
import { getNotesTree, isParentNode, updateNodeInTree } from '@renderer/services/NotesTreeService'
|
import {
|
||||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
addUniquePath,
|
||||||
import { selectActiveFilePath, selectSortType, setActiveFilePath, setSortType } from '@renderer/store/note'
|
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 { NotesSortType, NotesTreeNode } from '@renderer/types/note'
|
import { NotesSortType, NotesTreeNode } from '@renderer/types/note'
|
||||||
import { FileChangeEvent } from '@shared/config/types'
|
import { FileChangeEvent } from '@shared/config/types'
|
||||||
import { useLiveQuery } from 'dexie-react-hooks'
|
|
||||||
import { debounce } from 'lodash'
|
import { debounce } from 'lodash'
|
||||||
import { AnimatePresence, motion } from 'motion/react'
|
import { AnimatePresence, motion } from 'motion/react'
|
||||||
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
@@ -37,27 +54,98 @@ const NotesPage: FC = () => {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { showWorkspace } = useShowWorkspace()
|
const { showWorkspace } = useShowWorkspace()
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
|
const store = useAppStore()
|
||||||
const activeFilePath = useAppSelector(selectActiveFilePath)
|
const activeFilePath = useAppSelector(selectActiveFilePath)
|
||||||
const sortType = useAppSelector(selectSortType)
|
const sortType = useAppSelector(selectSortType)
|
||||||
|
const starredPaths = useAppSelector(selectStarredPaths)
|
||||||
|
const expandedPaths = useAppSelector(selectExpandedPaths)
|
||||||
const { settings, notesPath, updateNotesPath } = useNotesSettings()
|
const { settings, notesPath, updateNotesPath } = useNotesSettings()
|
||||||
|
|
||||||
// 混合策略:useLiveQuery用于笔记树,React Query用于文件内容
|
// 混合策略:useLiveQuery用于笔记树,React Query用于文件内容
|
||||||
const notesTreeQuery = useLiveQuery(() => getNotesTree(), [])
|
const [notesTree, setNotesTree] = useState<NotesTreeNode[]>([])
|
||||||
const notesTree = useMemo(() => notesTreeQuery || [], [notesTreeQuery])
|
const starredSet = useMemo(() => new Set(starredPaths), [starredPaths])
|
||||||
|
const expandedSet = useMemo(() => new Set(expandedPaths), [expandedPaths])
|
||||||
const { activeNode } = useActiveNode(notesTree)
|
const { activeNode } = useActiveNode(notesTree)
|
||||||
const { invalidateFileContent } = useFileContentSync()
|
const { invalidateFileContent } = useFileContentSync()
|
||||||
const { data: currentContent = '', isLoading: isContentLoading } = useFileContent(activeFilePath)
|
const { data: currentContent = '' } = useFileContent(activeFilePath)
|
||||||
|
|
||||||
const [tokenCount, setTokenCount] = useState(0)
|
const [tokenCount, setTokenCount] = useState(0)
|
||||||
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null)
|
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null)
|
||||||
const watcherRef = useRef<(() => void) | null>(null)
|
const watcherRef = useRef<(() => void) | null>(null)
|
||||||
const isSyncingTreeRef = useRef(false)
|
|
||||||
const lastContentRef = useRef<string>('')
|
const lastContentRef = useRef<string>('')
|
||||||
const lastFilePathRef = useRef<string | undefined>(undefined)
|
const lastFilePathRef = useRef<string | undefined>(undefined)
|
||||||
const isInitialSortApplied = useRef(false)
|
|
||||||
const isRenamingRef = useRef(false)
|
const isRenamingRef = useRef(false)
|
||||||
const isCreatingNoteRef = 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(() => {
|
useEffect(() => {
|
||||||
const updateCharCount = () => {
|
const updateCharCount = () => {
|
||||||
const textContent = editorRef.current?.getContent() || currentContent
|
const textContent = editorRef.current?.getContent() || currentContent
|
||||||
@@ -67,19 +155,16 @@ const NotesPage: FC = () => {
|
|||||||
updateCharCount()
|
updateCharCount()
|
||||||
}, [currentContent])
|
}, [currentContent])
|
||||||
|
|
||||||
// 查找树节点 by ID
|
useEffect(() => {
|
||||||
const findNodeById = useCallback((tree: NotesTreeNode[], nodeId: string): NotesTreeNode | null => {
|
refreshTree()
|
||||||
for (const node of tree) {
|
}, [refreshTree])
|
||||||
if (node.id === nodeId) {
|
|
||||||
return node
|
// Re-merge tree state when starred or expanded paths change
|
||||||
}
|
useEffect(() => {
|
||||||
if (node.children) {
|
if (notesTree.length > 0) {
|
||||||
const found = findNodeById(node.children, nodeId)
|
setNotesTree((prev) => mergeTreeState(prev))
|
||||||
if (found) return found
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return null
|
}, [starredPaths, expandedPaths, mergeTreeState, notesTree.length])
|
||||||
}, [])
|
|
||||||
|
|
||||||
// 保存当前笔记内容
|
// 保存当前笔记内容
|
||||||
const saveCurrentNote = useCallback(
|
const saveCurrentNote = useCallback(
|
||||||
@@ -107,6 +192,11 @@ const NotesPage: FC = () => {
|
|||||||
[saveCurrentNote]
|
[saveCurrentNote]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const saveCurrentNoteRef = useRef(saveCurrentNote)
|
||||||
|
const debouncedSaveRef = useRef(debouncedSave)
|
||||||
|
const invalidateFileContentRef = useRef(invalidateFileContent)
|
||||||
|
const refreshTreeRef = useRef(refreshTree)
|
||||||
|
|
||||||
const handleMarkdownChange = useCallback(
|
const handleMarkdownChange = useCallback(
|
||||||
(newMarkdown: string) => {
|
(newMarkdown: string) => {
|
||||||
// 记录最新内容和文件路径,用于兜底保存
|
// 记录最新内容和文件路径,用于兜底保存
|
||||||
@@ -118,6 +208,30 @@ const NotesPage: FC = () => {
|
|||||||
[debouncedSave, activeFilePath]
|
[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(() => {
|
useEffect(() => {
|
||||||
async function initialize() {
|
async function initialize() {
|
||||||
if (!notesPath) {
|
if (!notesPath) {
|
||||||
@@ -133,29 +247,12 @@ const NotesPage: FC = () => {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [notesPath])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (notesTree.length === 0) return
|
if (notesTree.length === 0) return
|
||||||
// 如果有activeFilePath但找不到对应节点,清空选择
|
// 如果有activeFilePath但找不到对应节点,清空选择
|
||||||
// 但要排除正在同步树结构、重命名或创建笔记的情况,避免在这些操作中误清空
|
// 但要排除正在同步树结构、重命名或创建笔记的情况,避免在这些操作中误清空
|
||||||
const shouldClearPath =
|
const shouldClearPath = activeFilePath && !activeNode && !isRenamingRef.current && !isCreatingNoteRef.current
|
||||||
activeFilePath && !activeNode && !isSyncingTreeRef.current && !isRenamingRef.current && !isCreatingNoteRef.current
|
|
||||||
|
|
||||||
if (shouldClearPath) {
|
if (shouldClearPath) {
|
||||||
logger.warn('Clearing activeFilePath - node not found in tree', {
|
logger.warn('Clearing activeFilePath - node not found in tree', {
|
||||||
@@ -167,7 +264,7 @@ const NotesPage: FC = () => {
|
|||||||
}, [notesTree, activeFilePath, activeNode, dispatch])
|
}, [notesTree, activeFilePath, activeNode, dispatch])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!notesPath || notesTree.length === 0) return
|
if (!notesPath) return
|
||||||
|
|
||||||
async function startFileWatcher() {
|
async function startFileWatcher() {
|
||||||
// 清理之前的监控
|
// 清理之前的监控
|
||||||
@@ -181,31 +278,14 @@ const NotesPage: FC = () => {
|
|||||||
try {
|
try {
|
||||||
if (!notesPath) return
|
if (!notesPath) return
|
||||||
const { eventType, filePath } = data
|
const { eventType, filePath } = data
|
||||||
|
const normalizedEventPath = normalizePathValue(filePath)
|
||||||
|
|
||||||
switch (eventType) {
|
switch (eventType) {
|
||||||
case 'change': {
|
case 'change': {
|
||||||
// 处理文件内容变化 - 只有内容真正改变时才触发更新
|
// 处理文件内容变化 - 只有内容真正改变时才触发更新
|
||||||
if (activeFilePath === filePath) {
|
const activePath = activeFilePathRef.current
|
||||||
try {
|
if (activePath && normalizePathValue(activePath) === normalizedEventPath) {
|
||||||
// 读取文件最新内容
|
invalidateFileContentRef.current?.(normalizedEventPath)
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -215,20 +295,18 @@ const NotesPage: FC = () => {
|
|||||||
case 'unlink':
|
case 'unlink':
|
||||||
case 'unlinkDir': {
|
case 'unlinkDir': {
|
||||||
// 如果删除的是当前活动文件,清空选择
|
// 如果删除的是当前活动文件,清空选择
|
||||||
if ((eventType === 'unlink' || eventType === 'unlinkDir') && activeFilePath === filePath) {
|
if (
|
||||||
|
(eventType === 'unlink' || eventType === 'unlinkDir') &&
|
||||||
|
activeFilePathRef.current &&
|
||||||
|
normalizePathValue(activeFilePathRef.current) === normalizedEventPath
|
||||||
|
) {
|
||||||
dispatch(setActiveFilePath(undefined))
|
dispatch(setActiveFilePath(undefined))
|
||||||
|
editorRef.current?.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置同步标志,避免竞态条件
|
const refresh = refreshTreeRef.current
|
||||||
isSyncingTreeRef.current = true
|
if (refresh) {
|
||||||
|
await refresh()
|
||||||
// 重新同步数据库,useLiveQuery会自动响应数据库变化
|
|
||||||
try {
|
|
||||||
await initWorkSpace(notesPath, sortType)
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to sync database:', error as Error)
|
|
||||||
} finally {
|
|
||||||
isSyncingTreeRef.current = false
|
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -261,26 +339,19 @@ const NotesPage: FC = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 如果有未保存的内容,立即保存
|
// 如果有未保存的内容,立即保存
|
||||||
if (lastContentRef.current && lastContentRef.current !== currentContent && lastFilePathRef.current) {
|
if (lastContentRef.current && lastFilePathRef.current && lastContentRef.current !== currentContentRef.current) {
|
||||||
saveCurrentNote(lastContentRef.current, lastFilePathRef.current).catch((error) => {
|
const saveFn = saveCurrentNoteRef.current
|
||||||
logger.error('Emergency save failed:', error as Error)
|
if (saveFn) {
|
||||||
})
|
saveFn(lastContentRef.current, lastFilePathRef.current).catch((error) => {
|
||||||
|
logger.error('Emergency save failed:', error as Error)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清理防抖函数
|
// 清理防抖函数
|
||||||
debouncedSave.cancel()
|
debouncedSaveRef.current?.cancel()
|
||||||
}
|
}
|
||||||
}, [
|
}, [dispatch, notesPath])
|
||||||
notesPath,
|
|
||||||
notesTree.length,
|
|
||||||
activeFilePath,
|
|
||||||
invalidateFileContent,
|
|
||||||
dispatch,
|
|
||||||
currentContent,
|
|
||||||
debouncedSave,
|
|
||||||
saveCurrentNote,
|
|
||||||
sortType
|
|
||||||
])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const editor = editorRef.current
|
const editor = editorRef.current
|
||||||
@@ -314,47 +385,55 @@ const NotesPage: FC = () => {
|
|||||||
}, [activeFilePath])
|
}, [activeFilePath])
|
||||||
|
|
||||||
// 获取目标文件夹路径(选中文件夹或根目录)
|
// 获取目标文件夹路径(选中文件夹或根目录)
|
||||||
const getTargetFolderPath = useCallback(() => {
|
const getTargetFolderPath = useCallback(
|
||||||
if (selectedFolderId) {
|
(targetFolderId?: string) => {
|
||||||
const selectedNode = findNodeById(notesTree, selectedFolderId)
|
const folderId = targetFolderId || selectedFolderId
|
||||||
if (selectedNode && selectedNode.type === 'folder') {
|
if (folderId) {
|
||||||
return selectedNode.externalPath
|
const selectedNode = findNode(notesTree, folderId)
|
||||||
|
if (selectedNode && selectedNode.type === 'folder') {
|
||||||
|
return selectedNode.externalPath
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
return notesPath // 默认返回根目录
|
||||||
return notesPath // 默认返回根目录
|
},
|
||||||
}, [selectedFolderId, notesTree, notesPath, findNodeById])
|
[selectedFolderId, notesTree, notesPath]
|
||||||
|
)
|
||||||
|
|
||||||
// 创建文件夹
|
// 创建文件夹
|
||||||
const handleCreateFolder = useCallback(
|
const handleCreateFolder = useCallback(
|
||||||
async (name: string) => {
|
async (name: string, targetFolderId?: string) => {
|
||||||
try {
|
try {
|
||||||
const targetPath = getTargetFolderPath()
|
const targetPath = getTargetFolderPath(targetFolderId)
|
||||||
if (!targetPath) {
|
if (!targetPath) {
|
||||||
throw new Error('No folder path selected')
|
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) {
|
} catch (error) {
|
||||||
logger.error('Failed to create folder:', error as Error)
|
logger.error('Failed to create folder:', error as Error)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[getTargetFolderPath]
|
[getTargetFolderPath, refreshTree, updateExpandedPaths]
|
||||||
)
|
)
|
||||||
|
|
||||||
// 创建笔记
|
// 创建笔记
|
||||||
const handleCreateNote = useCallback(
|
const handleCreateNote = useCallback(
|
||||||
async (name: string) => {
|
async (name: string, targetFolderId?: string) => {
|
||||||
try {
|
try {
|
||||||
isCreatingNoteRef.current = true
|
isCreatingNoteRef.current = true
|
||||||
|
|
||||||
const targetPath = getTargetFolderPath()
|
const targetPath = getTargetFolderPath(targetFolderId)
|
||||||
if (!targetPath) {
|
if (!targetPath) {
|
||||||
throw new Error('No folder path selected')
|
throw new Error('No folder path selected')
|
||||||
}
|
}
|
||||||
const newNote = await createNote(name, '', targetPath)
|
const { path: notePath } = await addNote(name, '', targetPath)
|
||||||
dispatch(setActiveFilePath(newNote.externalPath))
|
const normalizedParent = normalizePathValue(targetPath)
|
||||||
|
updateExpandedPaths((prev) => addUniquePath(prev, normalizedParent))
|
||||||
|
dispatch(setActiveFilePath(notePath))
|
||||||
setSelectedFolderId(null)
|
setSelectedFolderId(null)
|
||||||
|
|
||||||
await sortAllLevels(sortType)
|
await refreshTree()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to create note:', error as Error)
|
logger.error('Failed to create note:', error as Error)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -364,73 +443,41 @@ const NotesPage: FC = () => {
|
|||||||
}, 500)
|
}, 500)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[dispatch, getTargetFolderPath, sortType]
|
[dispatch, getTargetFolderPath, refreshTree, updateExpandedPaths]
|
||||||
)
|
|
||||||
|
|
||||||
// 切换展开状态
|
|
||||||
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]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleToggleExpanded = useCallback(
|
const handleToggleExpanded = useCallback(
|
||||||
async (nodeId: string) => {
|
(nodeId: string) => {
|
||||||
try {
|
const targetNode = findNode(notesTree, nodeId)
|
||||||
await toggleNodeExpanded(nodeId)
|
if (!targetNode || targetNode.type !== 'folder') {
|
||||||
} catch (error) {
|
return
|
||||||
logger.error('Failed to toggle expanded:', error as Error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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]
|
[notesTree, updateExpandedPaths]
|
||||||
)
|
|
||||||
|
|
||||||
// 切换收藏状态
|
|
||||||
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]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleToggleStar = useCallback(
|
const handleToggleStar = useCallback(
|
||||||
async (nodeId: string) => {
|
(nodeId: string) => {
|
||||||
try {
|
const node = findNode(notesTree, nodeId)
|
||||||
await toggleStarred(nodeId)
|
if (!node) {
|
||||||
} catch (error) {
|
return
|
||||||
logger.error('Failed to toggle star:', error as Error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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]
|
||||||
)
|
)
|
||||||
|
|
||||||
// 选择节点
|
// 选择节点
|
||||||
@@ -447,7 +494,7 @@ const NotesPage: FC = () => {
|
|||||||
}
|
}
|
||||||
} else if (node.type === 'folder') {
|
} else if (node.type === 'folder') {
|
||||||
setSelectedFolderId(node.id)
|
setSelectedFolderId(node.id)
|
||||||
await handleToggleExpanded(node.id)
|
handleToggleExpanded(node.id)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[dispatch, handleToggleExpanded, invalidateFileContent]
|
[dispatch, handleToggleExpanded, invalidateFileContent]
|
||||||
@@ -457,28 +504,35 @@ const NotesPage: FC = () => {
|
|||||||
const handleDeleteNode = useCallback(
|
const handleDeleteNode = useCallback(
|
||||||
async (nodeId: string) => {
|
async (nodeId: string) => {
|
||||||
try {
|
try {
|
||||||
const nodeToDelete = findNodeById(notesTree, nodeId)
|
const nodeToDelete = findNode(notesTree, nodeId)
|
||||||
if (!nodeToDelete) return
|
if (!nodeToDelete) return
|
||||||
|
|
||||||
const isActiveNodeOrParent =
|
await delNode(nodeToDelete)
|
||||||
activeFilePath &&
|
|
||||||
(nodeToDelete.externalPath === activeFilePath || isParentNode(notesTree, nodeId, activeNode?.id || ''))
|
|
||||||
|
|
||||||
await deleteNode(nodeId)
|
updateStarredPaths((prev) => removePathEntries(prev, nodeToDelete.externalPath, nodeToDelete.type === 'folder'))
|
||||||
await sortAllLevels(sortType)
|
updateExpandedPaths((prev) =>
|
||||||
|
removePathEntries(prev, nodeToDelete.externalPath, nodeToDelete.type === 'folder')
|
||||||
|
)
|
||||||
|
|
||||||
// 如果删除的是当前活动节点或其父节点,清空编辑器
|
const normalizedActivePath = activeFilePath ? normalizePathValue(activeFilePath) : undefined
|
||||||
if (isActiveNodeOrParent) {
|
const normalizedDeletePath = normalizePathValue(nodeToDelete.externalPath)
|
||||||
|
const isActiveNode = normalizedActivePath === normalizedDeletePath
|
||||||
|
const isActiveDescendant =
|
||||||
|
nodeToDelete.type === 'folder' &&
|
||||||
|
normalizedActivePath &&
|
||||||
|
normalizedActivePath.startsWith(`${normalizedDeletePath}/`)
|
||||||
|
|
||||||
|
if (isActiveNode || isActiveDescendant) {
|
||||||
dispatch(setActiveFilePath(undefined))
|
dispatch(setActiveFilePath(undefined))
|
||||||
if (editorRef.current) {
|
editorRef.current?.clear()
|
||||||
editorRef.current.clear()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await refreshTree()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to delete node:', error as Error)
|
logger.error('Failed to delete node:', error as Error)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[findNodeById, notesTree, activeFilePath, activeNode?.id, sortType, dispatch]
|
[notesTree, activeFilePath, dispatch, refreshTree, updateStarredPaths, updateExpandedPaths]
|
||||||
)
|
)
|
||||||
|
|
||||||
// 重命名节点
|
// 重命名节点
|
||||||
@@ -487,29 +541,30 @@ const NotesPage: FC = () => {
|
|||||||
try {
|
try {
|
||||||
isRenamingRef.current = true
|
isRenamingRef.current = true
|
||||||
|
|
||||||
const tree = await getNotesTree()
|
const node = findNode(notesTree, nodeId)
|
||||||
const node = findNodeById(tree, nodeId)
|
if (!node || node.name === newName) {
|
||||||
|
return
|
||||||
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 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) {
|
} catch (error) {
|
||||||
logger.error('Failed to rename node:', error as Error)
|
logger.error('Failed to rename node:', error as Error)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -518,7 +573,7 @@ const NotesPage: FC = () => {
|
|||||||
}, 500)
|
}, 500)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[activeFilePath, dispatch, findNodeById, sortType, t]
|
[activeFilePath, dispatch, notesTree, refreshTree, updateStarredPaths, updateExpandedPaths]
|
||||||
)
|
)
|
||||||
|
|
||||||
// 处理文件上传
|
// 处理文件上传
|
||||||
@@ -535,7 +590,7 @@ const NotesPage: FC = () => {
|
|||||||
throw new Error('No folder path selected')
|
throw new Error('No folder path selected')
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await uploadFiles(files, targetFolderPath)
|
const result = await uploadNotes(files, targetFolderPath)
|
||||||
|
|
||||||
// 检查上传结果
|
// 检查上传结果
|
||||||
if (result.fileCount === 0) {
|
if (result.fileCount === 0) {
|
||||||
@@ -544,7 +599,8 @@ const NotesPage: FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 排序并显示成功信息
|
// 排序并显示成功信息
|
||||||
await sortAllLevels(sortType)
|
updateExpandedPaths((prev) => addUniquePath(prev, normalizePathValue(targetFolderPath)))
|
||||||
|
await refreshTree()
|
||||||
|
|
||||||
const successMessage = t('notes.upload_success')
|
const successMessage = t('notes.upload_success')
|
||||||
|
|
||||||
@@ -554,37 +610,141 @@ const NotesPage: FC = () => {
|
|||||||
window.toast.error(t('notes.upload_failed'))
|
window.toast.error(t('notes.upload_failed'))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[getTargetFolderPath, sortType, t]
|
[getTargetFolderPath, refreshTree, t, updateExpandedPaths]
|
||||||
)
|
)
|
||||||
|
|
||||||
// 处理节点移动
|
// 处理节点移动
|
||||||
const handleMoveNode = useCallback(
|
const handleMoveNode = useCallback(
|
||||||
async (sourceNodeId: string, targetNodeId: string, position: 'before' | 'after' | 'inside') => {
|
async (sourceNodeId: string, targetNodeId: string, position: 'before' | 'after' | 'inside') => {
|
||||||
|
if (!notesPath) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await moveNode(sourceNodeId, targetNodeId, position)
|
const sourceNode = findNode(notesTree, sourceNodeId)
|
||||||
if (result.success && result.type !== 'manual_reorder') {
|
const targetNode = findNode(notesTree, targetNodeId)
|
||||||
await sortAllLevels(sortType)
|
|
||||||
|
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) {
|
} catch (error) {
|
||||||
logger.error('Failed to move nodes:', error as Error)
|
logger.error('Failed to move nodes:', error as Error)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[sortType]
|
[activeFilePath, dispatch, notesPath, notesTree, refreshTree, updateStarredPaths, updateExpandedPaths]
|
||||||
)
|
)
|
||||||
|
|
||||||
// 处理节点排序
|
// 处理节点排序
|
||||||
const handleSortNodes = useCallback(
|
const handleSortNodes = useCallback(
|
||||||
async (newSortType: NotesSortType) => {
|
async (newSortType: NotesSortType) => {
|
||||||
try {
|
dispatch(setSortType(newSortType))
|
||||||
// 更新Redux中的排序类型
|
setNotesTree((prev) => mergeTreeState(sortTree(prev, newSortType)))
|
||||||
dispatch(setSortType(newSortType))
|
},
|
||||||
await sortAllLevels(newSortType)
|
[dispatch, mergeTreeState]
|
||||||
} catch (error) {
|
)
|
||||||
logger.error('Failed to sort notes:', error as Error)
|
|
||||||
throw error
|
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(() => {
|
const getCurrentNoteContent = useCallback(() => {
|
||||||
@@ -631,12 +791,13 @@ const NotesPage: FC = () => {
|
|||||||
notesTree={notesTree}
|
notesTree={notesTree}
|
||||||
getCurrentNoteContent={getCurrentNoteContent}
|
getCurrentNoteContent={getCurrentNoteContent}
|
||||||
onToggleStar={handleToggleStar}
|
onToggleStar={handleToggleStar}
|
||||||
|
onExpandPath={handleExpandPath}
|
||||||
|
onRenameNode={handleRenameNode}
|
||||||
/>
|
/>
|
||||||
<NotesEditor
|
<NotesEditor
|
||||||
activeNodeId={activeNode?.id}
|
activeNodeId={activeNode?.id}
|
||||||
currentContent={currentContent}
|
currentContent={currentContent}
|
||||||
tokenCount={tokenCount}
|
tokenCount={tokenCount}
|
||||||
isLoading={isContentLoading}
|
|
||||||
onMarkdownChange={handleMarkdownChange}
|
onMarkdownChange={handleMarkdownChange}
|
||||||
editorRef={editorRef}
|
editorRef={editorRef}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,10 +6,14 @@ import { useInPlaceEdit } from '@renderer/hooks/useInPlaceEdit'
|
|||||||
import { useKnowledgeBases } from '@renderer/hooks/useKnowledge'
|
import { useKnowledgeBases } from '@renderer/hooks/useKnowledge'
|
||||||
import { useActiveNode } from '@renderer/hooks/useNotesQuery'
|
import { useActiveNode } from '@renderer/hooks/useNotesQuery'
|
||||||
import NotesSidebarHeader from '@renderer/pages/notes/NotesSidebarHeader'
|
import NotesSidebarHeader from '@renderer/pages/notes/NotesSidebarHeader'
|
||||||
import { useAppSelector } from '@renderer/store'
|
import { fetchNoteSummary } from '@renderer/services/ApiService'
|
||||||
|
import { RootState, useAppSelector } from '@renderer/store'
|
||||||
import { selectSortType } from '@renderer/store/note'
|
import { selectSortType } from '@renderer/store/note'
|
||||||
import { NotesSortType, NotesTreeNode } from '@renderer/types/note'
|
import { NotesSortType, NotesTreeNode } from '@renderer/types/note'
|
||||||
|
import { exportNote } from '@renderer/utils/export'
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||||
import { Dropdown, Input, InputRef, MenuProps } from 'antd'
|
import { Dropdown, Input, InputRef, MenuProps } from 'antd'
|
||||||
|
import { ItemType, MenuItemType } from 'antd/es/menu/interface'
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
@@ -19,16 +23,19 @@ import {
|
|||||||
FileSearch,
|
FileSearch,
|
||||||
Folder,
|
Folder,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
|
Sparkles,
|
||||||
Star,
|
Star,
|
||||||
StarOff
|
StarOff,
|
||||||
|
UploadIcon
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { FC, Ref, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { FC, memo, Ref, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useSelector } from 'react-redux'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
interface NotesSidebarProps {
|
interface NotesSidebarProps {
|
||||||
onCreateFolder: (name: string, parentId?: string) => void
|
onCreateFolder: (name: string, targetFolderId?: string) => void
|
||||||
onCreateNote: (name: string, parentId?: string) => void
|
onCreateNote: (name: string, targetFolderId?: string) => void
|
||||||
onSelectNode: (node: NotesTreeNode) => void
|
onSelectNode: (node: NotesTreeNode) => void
|
||||||
onDeleteNode: (nodeId: string) => void
|
onDeleteNode: (nodeId: string) => void
|
||||||
onRenameNode: (nodeId: string, newName: string) => void
|
onRenameNode: (nodeId: string, newName: string) => void
|
||||||
@@ -43,6 +50,181 @@ interface NotesSidebarProps {
|
|||||||
|
|
||||||
const logger = loggerService.withContext('NotesSidebar')
|
const logger = loggerService.withContext('NotesSidebar')
|
||||||
|
|
||||||
|
interface TreeNodeProps {
|
||||||
|
node: NotesTreeNode
|
||||||
|
depth: number
|
||||||
|
selectedFolderId?: string | null
|
||||||
|
activeNodeId?: string
|
||||||
|
editingNodeId: string | null
|
||||||
|
renamingNodeIds: Set<string>
|
||||||
|
newlyRenamedNodeIds: Set<string>
|
||||||
|
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 // 控制是否渲染子节点
|
||||||
|
openDropdownKey: string | null
|
||||||
|
onDropdownOpenChange: (key: string | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const TreeNode = memo<TreeNodeProps>(
|
||||||
|
({
|
||||||
|
node,
|
||||||
|
depth,
|
||||||
|
selectedFolderId,
|
||||||
|
activeNodeId,
|
||||||
|
editingNodeId,
|
||||||
|
renamingNodeIds,
|
||||||
|
newlyRenamedNodeIds,
|
||||||
|
draggedNodeId,
|
||||||
|
dragOverNodeId,
|
||||||
|
dragPosition,
|
||||||
|
inPlaceEdit,
|
||||||
|
getMenuItems,
|
||||||
|
onSelectNode,
|
||||||
|
onToggleExpanded,
|
||||||
|
onDragStart,
|
||||||
|
onDragOver,
|
||||||
|
onDragLeave,
|
||||||
|
onDrop,
|
||||||
|
onDragEnd,
|
||||||
|
renderChildren = true,
|
||||||
|
openDropdownKey,
|
||||||
|
onDropdownOpenChange
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const isActive = selectedFolderId
|
||||||
|
? node.type === 'folder' && node.id === selectedFolderId
|
||||||
|
: node.id === activeNodeId
|
||||||
|
const isEditing = editingNodeId === node.id && inPlaceEdit.isEditing
|
||||||
|
const isRenaming = renamingNodeIds.has(node.id)
|
||||||
|
const isNewlyRenamed = newlyRenamedNodeIds.has(node.id)
|
||||||
|
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'
|
||||||
|
|
||||||
|
const getNodeNameClassName = () => {
|
||||||
|
if (isRenaming) return 'shimmer'
|
||||||
|
if (isNewlyRenamed) return 'typing'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={node.id}>
|
||||||
|
<Dropdown
|
||||||
|
menu={{ items: getMenuItems(node) }}
|
||||||
|
trigger={['contextMenu']}
|
||||||
|
open={openDropdownKey === node.id}
|
||||||
|
onOpenChange={(open) => onDropdownOpenChange(open ? node.id : null)}>
|
||||||
|
<div onContextMenu={(e) => e.stopPropagation()}>
|
||||||
|
<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 className={getNodeNameClassName()}>{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}
|
||||||
|
renamingNodeIds={renamingNodeIds}
|
||||||
|
newlyRenamedNodeIds={newlyRenamedNodeIds}
|
||||||
|
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}
|
||||||
|
openDropdownKey={openDropdownKey}
|
||||||
|
onDropdownOpenChange={onDropdownOpenChange}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const NotesSidebar: FC<NotesSidebarProps> = ({
|
const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||||
onCreateFolder,
|
onCreateFolder,
|
||||||
onCreateNote,
|
onCreateNote,
|
||||||
@@ -61,7 +243,10 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
|||||||
const { bases } = useKnowledgeBases()
|
const { bases } = useKnowledgeBases()
|
||||||
const { activeNode } = useActiveNode(notesTree)
|
const { activeNode } = useActiveNode(notesTree)
|
||||||
const sortType = useAppSelector(selectSortType)
|
const sortType = useAppSelector(selectSortType)
|
||||||
|
const exportMenuOptions = useSelector((state: RootState) => state.settings.exportMenuOptions)
|
||||||
const [editingNodeId, setEditingNodeId] = useState<string | null>(null)
|
const [editingNodeId, setEditingNodeId] = useState<string | null>(null)
|
||||||
|
const [renamingNodeIds, setRenamingNodeIds] = useState<Set<string>>(new Set())
|
||||||
|
const [newlyRenamedNodeIds, setNewlyRenamedNodeIds] = useState<Set<string>>(new Set())
|
||||||
const [draggedNodeId, setDraggedNodeId] = useState<string | null>(null)
|
const [draggedNodeId, setDraggedNodeId] = useState<string | null>(null)
|
||||||
const [dragOverNodeId, setDragOverNodeId] = useState<string | null>(null)
|
const [dragOverNodeId, setDragOverNodeId] = useState<string | null>(null)
|
||||||
const [dragPosition, setDragPosition] = useState<'before' | 'inside' | 'after'>('inside')
|
const [dragPosition, setDragPosition] = useState<'before' | 'inside' | 'after'>('inside')
|
||||||
@@ -69,6 +254,7 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
|||||||
const [isShowSearch, setIsShowSearch] = useState(false)
|
const [isShowSearch, setIsShowSearch] = useState(false)
|
||||||
const [searchKeyword, setSearchKeyword] = useState('')
|
const [searchKeyword, setSearchKeyword] = useState('')
|
||||||
const [isDragOverSidebar, setIsDragOverSidebar] = useState(false)
|
const [isDragOverSidebar, setIsDragOverSidebar] = useState(false)
|
||||||
|
const [openDropdownKey, setOpenDropdownKey] = useState<string | null>(null)
|
||||||
const dragNodeRef = useRef<HTMLDivElement | null>(null)
|
const dragNodeRef = useRef<HTMLDivElement | null>(null)
|
||||||
const scrollbarRef = useRef<any>(null)
|
const scrollbarRef = useRef<any>(null)
|
||||||
|
|
||||||
@@ -184,6 +370,49 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
|||||||
[bases.length, t]
|
[bases.length, t]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const handleAutoRename = useCallback(
|
||||||
|
async (note: NotesTreeNode) => {
|
||||||
|
if (note.type !== 'file') return
|
||||||
|
|
||||||
|
setRenamingNodeIds((prev) => new Set(prev).add(note.id))
|
||||||
|
try {
|
||||||
|
const content = await window.api.file.readExternal(note.externalPath)
|
||||||
|
if (!content || content.trim().length === 0) {
|
||||||
|
window.toast.warning(t('notes.auto_rename.empty_note'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryText = await fetchNoteSummary({ content })
|
||||||
|
if (summaryText) {
|
||||||
|
onRenameNode(note.id, summaryText)
|
||||||
|
window.toast.success(t('notes.auto_rename.success'))
|
||||||
|
} else {
|
||||||
|
window.toast.error(t('notes.auto_rename.failed'))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
window.toast.error(t('notes.auto_rename.failed'))
|
||||||
|
logger.error(`Failed to auto-rename note: ${error}`)
|
||||||
|
} finally {
|
||||||
|
setRenamingNodeIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.delete(note.id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
setNewlyRenamedNodeIds((prev) => new Set(prev).add(note.id))
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setNewlyRenamedNodeIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.delete(note.id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, 700)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onRenameNode, t]
|
||||||
|
)
|
||||||
|
|
||||||
const handleDragStart = useCallback((e: React.DragEvent, node: NotesTreeNode) => {
|
const handleDragStart = useCallback((e: React.DragEvent, node: NotesTreeNode) => {
|
||||||
setDraggedNodeId(node.id)
|
setDraggedNodeId(node.id)
|
||||||
e.dataTransfer.effectAllowed = 'move'
|
e.dataTransfer.effectAllowed = 'move'
|
||||||
@@ -268,9 +497,26 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
|||||||
setIsShowSearch(!isShowSearch)
|
setIsShowSearch(!isShowSearch)
|
||||||
}, [isShowSearch])
|
}, [isShowSearch])
|
||||||
|
|
||||||
const filteredTree = useMemo(() => {
|
// Flatten tree nodes for virtualization and filtering
|
||||||
if (!isShowStarred && !isShowSearch) return notesTree
|
const flattenedNodes = useMemo(() => {
|
||||||
const flattenNodes = (nodes: NotesTreeNode[]): NotesTreeNode[] => {
|
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[] = []
|
let result: NotesTreeNode[] = []
|
||||||
|
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
@@ -284,18 +530,81 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (node.children && node.children.length > 0) {
|
if (node.children && node.children.length > 0) {
|
||||||
result = [...result, ...flattenNodes(node.children)]
|
result = [...result, ...flattenForFiltering(node.children)]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result
|
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])
|
}, [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(
|
const getMenuItems = useCallback(
|
||||||
(node: NotesTreeNode) => {
|
(node: NotesTreeNode) => {
|
||||||
const baseMenuItems: MenuProps['items'] = [
|
const baseMenuItems: MenuProps['items'] = []
|
||||||
|
|
||||||
|
// only show auto rename for file for now
|
||||||
|
if (node.type !== 'folder') {
|
||||||
|
baseMenuItems.push({
|
||||||
|
label: t('notes.auto_rename.label'),
|
||||||
|
key: 'auto-rename',
|
||||||
|
icon: <Sparkles size={14} />,
|
||||||
|
disabled: renamingNodeIds.has(node.id),
|
||||||
|
onClick: () => {
|
||||||
|
handleAutoRename(node)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'folder') {
|
||||||
|
baseMenuItems.push(
|
||||||
|
{
|
||||||
|
label: t('notes.new_note'),
|
||||||
|
key: 'new_note',
|
||||||
|
icon: <FilePlus size={14} />,
|
||||||
|
onClick: () => {
|
||||||
|
onCreateNote(t('notes.untitled_note'), node.id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('notes.new_folder'),
|
||||||
|
key: 'new_folder',
|
||||||
|
icon: <Folder size={14} />,
|
||||||
|
onClick: () => {
|
||||||
|
onCreateFolder(t('notes.untitled_folder'), node.id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ type: 'divider' }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
baseMenuItems.push(
|
||||||
{
|
{
|
||||||
label: t('notes.rename'),
|
label: t('notes.rename'),
|
||||||
key: 'rename',
|
key: 'rename',
|
||||||
@@ -312,7 +621,7 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
|||||||
window.api.openPath(node.externalPath)
|
window.api.openPath(node.externalPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
)
|
||||||
if (node.type !== 'folder') {
|
if (node.type !== 'folder') {
|
||||||
baseMenuItems.push(
|
baseMenuItems.push(
|
||||||
{
|
{
|
||||||
@@ -330,6 +639,48 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
|||||||
onClick: () => {
|
onClick: () => {
|
||||||
handleExportKnowledge(node)
|
handleExportKnowledge(node)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('chat.topics.export.title'),
|
||||||
|
key: 'export',
|
||||||
|
icon: <UploadIcon size={14} />,
|
||||||
|
children: [
|
||||||
|
exportMenuOptions.markdown && {
|
||||||
|
label: t('chat.topics.export.md.label'),
|
||||||
|
key: 'markdown',
|
||||||
|
onClick: () => exportNote({ node, platform: 'markdown' })
|
||||||
|
},
|
||||||
|
exportMenuOptions.docx && {
|
||||||
|
label: t('chat.topics.export.word'),
|
||||||
|
key: 'word',
|
||||||
|
onClick: () => exportNote({ node, platform: 'docx' })
|
||||||
|
},
|
||||||
|
exportMenuOptions.notion && {
|
||||||
|
label: t('chat.topics.export.notion'),
|
||||||
|
key: 'notion',
|
||||||
|
onClick: () => exportNote({ node, platform: 'notion' })
|
||||||
|
},
|
||||||
|
exportMenuOptions.yuque && {
|
||||||
|
label: t('chat.topics.export.yuque'),
|
||||||
|
key: 'yuque',
|
||||||
|
onClick: () => exportNote({ node, platform: 'yuque' })
|
||||||
|
},
|
||||||
|
exportMenuOptions.obsidian && {
|
||||||
|
label: t('chat.topics.export.obsidian'),
|
||||||
|
key: 'obsidian',
|
||||||
|
onClick: () => exportNote({ node, platform: 'obsidian' })
|
||||||
|
},
|
||||||
|
exportMenuOptions.joplin && {
|
||||||
|
label: t('chat.topics.export.joplin'),
|
||||||
|
key: 'joplin',
|
||||||
|
onClick: () => exportNote({ node, platform: 'joplin' })
|
||||||
|
},
|
||||||
|
exportMenuOptions.siyuan && {
|
||||||
|
label: t('chat.topics.export.siyuan'),
|
||||||
|
key: 'siyuan',
|
||||||
|
onClick: () => exportNote({ node, platform: 'siyuan' })
|
||||||
|
}
|
||||||
|
].filter(Boolean) as ItemType<MenuItemType>[]
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -348,115 +699,17 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
|||||||
|
|
||||||
return baseMenuItems
|
return baseMenuItems
|
||||||
},
|
},
|
||||||
[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,
|
t,
|
||||||
handleDragStart,
|
handleStartEdit,
|
||||||
handleDragOver,
|
onToggleStar,
|
||||||
handleDrop,
|
handleExportKnowledge,
|
||||||
onSelectNode,
|
handleDeleteNode,
|
||||||
onToggleExpanded
|
renamingNodeIds,
|
||||||
|
handleAutoRename,
|
||||||
|
exportMenuOptions,
|
||||||
|
onCreateNote,
|
||||||
|
onCreateFolder
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -537,6 +790,23 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
|||||||
fileInput.click()
|
fileInput.click()
|
||||||
}, [onUploadFiles])
|
}, [onUploadFiles])
|
||||||
|
|
||||||
|
const getEmptyAreaMenuItems = useCallback((): MenuProps['items'] => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: t('notes.new_note'),
|
||||||
|
key: 'new_note',
|
||||||
|
icon: <FilePlus size={14} />,
|
||||||
|
onClick: handleCreateNote
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('notes.new_folder'),
|
||||||
|
key: 'new_folder',
|
||||||
|
icon: <Folder size={14} />,
|
||||||
|
onClick: handleCreateFolder
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}, [t, handleCreateNote, handleCreateFolder])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarContainer
|
<SidebarContainer
|
||||||
onDragOver={(e) => {
|
onDragOver={(e) => {
|
||||||
@@ -565,23 +835,154 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<NotesTreeContainer>
|
<NotesTreeContainer>
|
||||||
<StyledScrollbar ref={scrollbarRef}>
|
{shouldUseVirtualization ? (
|
||||||
<TreeContent>
|
<Dropdown
|
||||||
{filteredTree.map((node) => renderTreeNode(node))}
|
menu={{ items: getEmptyAreaMenuItems() }}
|
||||||
{!isShowStarred && !isShowSearch && (
|
trigger={['contextMenu']}
|
||||||
<DropHintNode>
|
open={openDropdownKey === 'empty-area'}
|
||||||
<TreeNodeContainer active={false} depth={0}>
|
onOpenChange={(open) => setOpenDropdownKey(open ? 'empty-area' : null)}>
|
||||||
<TreeNodeContent>
|
<VirtualizedTreeContainer ref={parentRef}>
|
||||||
<NodeIcon>
|
<div
|
||||||
<FilePlus size={16} />
|
style={{
|
||||||
</NodeIcon>
|
height: `${virtualizer.getTotalSize()}px`,
|
||||||
<DropHintText onClick={handleClickToSelectFiles}>{t('notes.drop_markdown_hint')}</DropHintText>
|
width: '100%',
|
||||||
</TreeNodeContent>
|
position: 'relative'
|
||||||
</TreeNodeContainer>
|
}}>
|
||||||
</DropHintNode>
|
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||||
)}
|
const { node, depth } = flattenedNodes[virtualItem.index]
|
||||||
</TreeContent>
|
return (
|
||||||
</StyledScrollbar>
|
<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}
|
||||||
|
renamingNodeIds={renamingNodeIds}
|
||||||
|
newlyRenamedNodeIds={newlyRenamedNodeIds}
|
||||||
|
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}
|
||||||
|
openDropdownKey={openDropdownKey}
|
||||||
|
onDropdownOpenChange={setOpenDropdownKey}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{!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>
|
||||||
|
)}
|
||||||
|
</VirtualizedTreeContainer>
|
||||||
|
</Dropdown>
|
||||||
|
) : (
|
||||||
|
<Dropdown
|
||||||
|
menu={{ items: getEmptyAreaMenuItems() }}
|
||||||
|
trigger={['contextMenu']}
|
||||||
|
open={openDropdownKey === 'empty-area'}
|
||||||
|
onOpenChange={(open) => setOpenDropdownKey(open ? 'empty-area' : null)}>
|
||||||
|
<StyledScrollbar ref={scrollbarRef}>
|
||||||
|
<TreeContent>
|
||||||
|
{isShowStarred || isShowSearch
|
||||||
|
? filteredTree.map((node) => (
|
||||||
|
<TreeNode
|
||||||
|
key={node.id}
|
||||||
|
node={node}
|
||||||
|
depth={0}
|
||||||
|
selectedFolderId={selectedFolderId}
|
||||||
|
activeNodeId={activeNode?.id}
|
||||||
|
editingNodeId={editingNodeId}
|
||||||
|
renamingNodeIds={renamingNodeIds}
|
||||||
|
newlyRenamedNodeIds={newlyRenamedNodeIds}
|
||||||
|
draggedNodeId={draggedNodeId}
|
||||||
|
dragOverNodeId={dragOverNodeId}
|
||||||
|
dragPosition={dragPosition}
|
||||||
|
inPlaceEdit={inPlaceEdit}
|
||||||
|
getMenuItems={getMenuItems}
|
||||||
|
onSelectNode={onSelectNode}
|
||||||
|
onToggleExpanded={onToggleExpanded}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
openDropdownKey={openDropdownKey}
|
||||||
|
onDropdownOpenChange={setOpenDropdownKey}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
: notesTree.map((node) => (
|
||||||
|
<TreeNode
|
||||||
|
key={node.id}
|
||||||
|
node={node}
|
||||||
|
depth={0}
|
||||||
|
selectedFolderId={selectedFolderId}
|
||||||
|
activeNodeId={activeNode?.id}
|
||||||
|
editingNodeId={editingNodeId}
|
||||||
|
renamingNodeIds={renamingNodeIds}
|
||||||
|
newlyRenamedNodeIds={newlyRenamedNodeIds}
|
||||||
|
draggedNodeId={draggedNodeId}
|
||||||
|
dragOverNodeId={dragOverNodeId}
|
||||||
|
dragPosition={dragPosition}
|
||||||
|
inPlaceEdit={inPlaceEdit}
|
||||||
|
getMenuItems={getMenuItems}
|
||||||
|
onSelectNode={onSelectNode}
|
||||||
|
onToggleExpanded={onToggleExpanded}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
openDropdownKey={openDropdownKey}
|
||||||
|
onDropdownOpenChange={setOpenDropdownKey}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{!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>
|
||||||
|
</Dropdown>
|
||||||
|
)}
|
||||||
</NotesTreeContainer>
|
</NotesTreeContainer>
|
||||||
|
|
||||||
{isDragOverSidebar && <DragOverIndicator />}
|
{isDragOverSidebar && <DragOverIndicator />}
|
||||||
@@ -592,7 +993,7 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
|||||||
const SidebarContainer = styled.div`
|
const SidebarContainer = styled.div`
|
||||||
width: 250px;
|
width: 250px;
|
||||||
min-width: 250px;
|
min-width: 250px;
|
||||||
height: 100vh;
|
height: calc(100vh - var(--navbar-height));
|
||||||
background-color: var(--color-background);
|
background-color: var(--color-background);
|
||||||
border-right: 0.5px solid var(--color-border);
|
border-right: 0.5px solid var(--color-border);
|
||||||
border-top-left-radius: 10px;
|
border-top-left-radius: 10px;
|
||||||
@@ -606,7 +1007,15 @@ const NotesTreeContainer = styled.div`
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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)`
|
const StyledScrollbar = styled(Scrollbar)`
|
||||||
@@ -732,6 +1141,44 @@ const NodeName = styled.div`
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
|
position: relative;
|
||||||
|
will-change: background-position, width;
|
||||||
|
|
||||||
|
--color-shimmer-mid: var(--color-text-1);
|
||||||
|
--color-shimmer-end: color-mix(in srgb, var(--color-text-1) 25%, transparent);
|
||||||
|
|
||||||
|
&.shimmer {
|
||||||
|
background: linear-gradient(to left, var(--color-shimmer-end), var(--color-shimmer-mid), var(--color-shimmer-end));
|
||||||
|
background-size: 200% 100%;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
animation: shimmer 3s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.typing {
|
||||||
|
display: block;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: typewriter 0.5s steps(40, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes typewriter {
|
||||||
|
from {
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
const EditInput = styled(Input)`
|
const EditInput = styled(Input)`
|
||||||
@@ -752,7 +1199,8 @@ const DragOverIndicator = styled.div`
|
|||||||
`
|
`
|
||||||
|
|
||||||
const DropHintNode = styled.div`
|
const DropHintNode = styled.div`
|
||||||
margin-top: 8px;
|
margin: 6px 0;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
${TreeNodeContainer} {
|
${TreeNodeContainer} {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
@@ -773,4 +1221,4 @@ const DropHintText = styled.div`
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
`
|
`
|
||||||
|
|
||||||
export default NotesSidebar
|
export default memo(NotesSidebar)
|
||||||
|
|||||||