Compare commits

..

1 Commits

Author SHA1 Message Date
kangfenmao
ebf61b1ce9 feat: plugins 2024-12-30 23:45:47 +08:00
488 changed files with 11538 additions and 57288 deletions

5
.eslintignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules
dist
out
.gitignore
scripts/cloudflare-worker.js

21
.eslintrc.cjs Normal file
View File

@@ -0,0 +1,21 @@
module.exports = {
plugins: ['unused-imports', 'simple-import-sort'],
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:react/jsx-runtime',
'plugin:react-hooks/recommended',
'@electron-toolkit/eslint-config-ts/recommended',
'@electron-toolkit/eslint-config-prettier'
],
rules: {
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'unused-imports/no-unused-imports': 'error',
'@typescript-eslint/no-non-null-asserted-optional-chain': 'off',
'react/prop-types': 'off',
'simple-import-sort/imports': 'error',
'simple-import-sort/exports': 'error',
'react/no-is-mounted': 'off'
}
}

2
.gitattributes vendored
View File

@@ -1,2 +0,0 @@
/.yarn/** linguist-vendored
/.yarn/releases/* binary

View File

@@ -1,4 +1,4 @@
name: 🐛 错误报告 (中文)
name: 🐛 错误报告
description: 创建一个报告以帮助我们改进
title: '[错误]: '
labels: ['bug']
@@ -7,21 +7,6 @@ body:
attributes:
value: |
感谢您花时间填写此错误报告!
在提交此问题之前,请确保您已经了解了[常见问题](https://docs.cherry-ai.com/question-contact/questions)和[知识科普](https://docs.cherry-ai.com/question-contact/knowledge)
- type: checkboxes
id: checklist
attributes:
label: 提交前检查
description: |
在提交 Issue 前请确保您已经完成了以下所有步骤
options:
- label: 我理解 Issue 是用于反馈和解决问题的,而非吐槽评论区,将尽可能提供更多信息帮助问题解决。
required: true
- label: 我已经查看了置顶 Issue 并搜索了现有的 [开放Issue](https://github.com/CherryHQ/cherry-studio/issues)和[已关闭Issue](https://github.com/CherryHQ/cherry-studio/issues?q=is%3Aissue%20state%3Aclosed%20),没有找到类似的问题。
required: true
- label: 我填写了简短且清晰明确的标题,以便开发者在翻阅 Issue 列表时能快速确定大致问题。而不是“一个建议”、“卡住了”等。
required: true
- type: dropdown
id: platform
@@ -48,7 +33,7 @@ body:
id: description
attributes:
label: 错误描述
description: 描述问题时请尽可能详细
description: 清晰简洁地描述错误是什么
placeholder: 告诉我们发生了什么...
validations:
required: true
@@ -57,7 +42,7 @@ body:
id: reproduction
attributes:
label: 重现步骤
description: 提供详细的重现步骤,以便于我们可以准确地重现问题
description: 重现行为的步骤
placeholder: |
1. 转到 '...'
2. 点击 '....'
@@ -85,4 +70,4 @@ body:
id: additional
attributes:
label: 附加信息
description: 任何能让我们对你所遇到的问题有更多了解的东西
description: 在此添加有关问题的任何其他上下文

View File

@@ -1,4 +1,4 @@
name: 💡 功能建议 (中文)
name: 💡 功能建议
description: 为项目提出新的想法
title: '[功能]: '
labels: ['enhancement']
@@ -7,49 +7,11 @@ body:
attributes:
value: |
感谢您花时间提出新的功能建议!
在提交此问题之前,请确保您已经了解了[项目规划](https://docs.cherry-ai.com/cherrystudio/planning)和[功能介绍](https://docs.cherry-ai.com/cherrystudio/preview)
- type: checkboxes
id: checklist
attributes:
label: 提交前检查
description: |
在提交 Issue 前请确保您已经完成了以下所有步骤
options:
- label: 我理解 Issue 是用于反馈和解决问题的,而非吐槽评论区,将尽可能提供更多信息帮助问题解决。
required: true
- label: 我已经查看了置顶 Issue 并搜索了现有的 [开放Issue](https://github.com/CherryHQ/cherry-studio/issues)和[已关闭Issue](https://github.com/CherryHQ/cherry-studio/issues?q=is%3Aissue%20state%3Aclosed%20),没有找到类似的建议。
required: true
- label: 我填写了简短且清晰明确的标题,以便开发者在翻阅 Issue 列表时能快速确定大致问题。而不是“一个建议”、“卡住了”等。
required: true
- label: 最新的 Cherry Studio 版本没有实现我所提出的功能。
required: true
- type: dropdown
id: platform
attributes:
label: 平台
description: 您正在使用哪个平台?
options:
- Windows
- macOS
- Linux
validations:
required: true
- type: input
id: version
attributes:
label: 版本
description: 您正在运行的 Cherry Studio 版本是什么?
placeholder: 例如 v1.0.0
validations:
required: true
- type: textarea
id: problem
attributes:
label: 您的功能建议是否与某个问题/issue相关?
label: 您的功能建议是否与某个问题相关?
description: 请简明扼要地描述您遇到的问题
placeholder: 我总是感到沮丧,因为...
validations:

View File

@@ -1,6 +1,6 @@
name: 讨论 & 提问 (中文)
description: 寻求帮助、讨论问题、提出疑问等...
title: '[讨论]: '
name: 提问
description: 提出一个问题或寻求帮助
title: '[问题]: '
labels: ['question']
body:
- type: markdown
@@ -8,39 +8,6 @@ body:
value: |
感谢您的提问!请尽可能详细地描述您的问题,这样我们才能更好地帮助您。
- type: checkboxes
id: checklist
attributes:
label: Issue 检查清单
description: |
在提交 Issue 前请确保您已经完成了以下所有步骤
options:
- label: 我理解 Issue 是用于反馈和解决问题的,而非吐槽评论区,将尽可能提供更多信息帮助问题解决。
required: true
- label: 我确认自己需要的是提出问题并且讨论问题,而不是 Bug 反馈或需求建议。
required: true
- type: dropdown
id: platform
attributes:
label: 平台
description: 您正在使用哪个平台?
options:
- Windows
- macOS
- Linux
validations:
required: true
- type: input
id: version
attributes:
label: 版本
description: 您正在运行的 Cherry Studio 版本是什么?
placeholder: 例如 v1.0.0
validations:
required: true
- type: textarea
id: question
attributes:

View File

@@ -1,4 +1,4 @@
name: 🐛 Bug Report (English)
name: 🐛 Bug Report
description: Create a report to help us improve
title: '[Bug]: '
labels: ['bug']
@@ -6,22 +6,7 @@ body:
- type: markdown
attributes:
value: |
Thank you for taking the time to fill out this bug report!
Before submitting this issue, please make sure that you have understood the [FAQ](https://docs.cherry-ai.com/question-contact/questions) and [Knowledge Science](https://docs.cherry-ai.com/question-contact/knowledge)
- type: checkboxes
id: checklist
attributes:
label: Issue Checklist
description: |
Before submitting an issue, please make sure you have completed the following steps
options:
- label: I understand that issues are for feedback and problem solving, not for complaining in the comment section, and will provide as much information as possible to help solve the problem.
required: true
- label: I've looked at pinned issues and searched for existing [Open Issues](https://github.com/CherryHQ/cherry-studio/issues), [Closed Issues](https://github.com/CherryHQ/cherry-studio/issues?q=is%3Aissue%20state%3Aclosed), and [Discussions](https://github.com/CherryHQ/cherry-studio/discussions), no similar issue or discussion was found.
required: true
- label: I've filled in short, clear headings so that developers can quickly identify a rough idea of what to expect when flipping through the list of issues. And not "a suggestion", "stuck", etc.
required: true
Thanks for taking the time to fill out this bug report!
- type: dropdown
id: platform
@@ -48,8 +33,8 @@ body:
id: description
attributes:
label: Bug Description
description: Please be as detailed as possible when describing the problem. Please provide screenshots or screen recordings whenever possible to help us better understand the issue.
placeholder: Tell us what happened... (Remember to attach screenshots/recordings if applicable)
description: A clear and concise description of what the bug is
placeholder: Tell us what happened...
validations:
required: true
@@ -57,14 +42,12 @@ body:
id: reproduction
attributes:
label: Steps To Reproduce
description: Provide detailed steps to reproduce the issue so that our developers can reproduce the issue accurately. Please include screenshots or screen recordings for each step when possible.
description: Steps to reproduce the behavior
placeholder: |
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
Remember to attach screenshots/recordings for each step when possible!
validations:
required: true
@@ -87,4 +70,4 @@ body:
id: additional
attributes:
label: Additional Context
description: Anything that gives us a better understanding of the problem you're experiencing
description: Add any other context about the problem here

View File

@@ -1,4 +1,4 @@
name: 💡 Feature Request (English)
name: 💡 Feature Request
description: Suggest an idea for this project
title: '[Feature]: '
labels: ['enhancement']
@@ -6,71 +6,33 @@ body:
- type: markdown
attributes:
value: |
Thank you for taking the time to submit a feature request!
Before submitting this issue, please make sure you have reviewed the [Project Roadmap](https://docs.cherry-ai.com/cherrystudio/planning) and the [Feature Overview](https://docs.cherry-ai.com/cherrystudio/preview).
- type: checkboxes
id: checklist
attributes:
label: Issue Checklist
description: |
Before submitting an issue, please make sure you have completed the following steps
options:
- label: I understand that issues are for reporting problems and requesting features, not for off-topic comments, and I will provide as much detail as possible to help resolve the issue.
required: true
- label: I have checked the pinned issues and searched through the existing [open issues](https://github.com/CherryHQ/cherry-studio/issues), [closed issues](https://github.com/CherryHQ/cherry-studio/issues?q=is%3Aissue%20state%3Aclosed), and [discussions](https://github.com/CherryHQ/cherry-studio/discussions) and did not find a similar suggestion.
required: true
- label: I have provided a short and descriptive title so that developers can quickly understand the issue when browsing the issue list, rather than vague titles like "A suggestion" or "Stuck."
required: true
- label: The latest version of Cherry Studio does not include the feature I am suggesting.
required: true
- type: dropdown
id: platform
attributes:
label: Platform
description: What platform are you using?
options:
- Windows
- macOS
- Linux
validations:
required: true
- type: input
id: version
attributes:
label: Version
description: What version of Cherry Studio are you running?
placeholder: e.g. v1.0.0
validations:
required: true
Thanks for taking the time to suggest a new feature!
- type: textarea
id: problem
attributes:
label: Is your feature request related to an existing issue?
description: Please briefly describe the problem you are experiencing. If possible, include screenshots or recordings to help illustrate the current situation or pain points.
placeholder: I often feel frustrated because... (Remember to attach screenshots/recordings if applicable)
label: Is your feature request related to a problem?
description: A clear and concise description of what the problem is
placeholder: I'm always frustrated when...
validations:
required: true
- type: textarea
id: solution
attributes:
label: Desired Solution
description: Please briefly describe what you would like to happen. You can include mockups, screenshots, or screen recordings to better illustrate your proposed solution.
label: Describe the solution you'd like
description: A clear and concise description of what you want to happen
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternative Solutions
description: Please briefly describe any alternative solutions or features you have considered. Feel free to include screenshots or mockups of alternative approaches.
label: Describe alternatives you've considered
description: A clear and concise description of any alternative solutions or features you've considered
- type: textarea
id: additional
attributes:
label: Additional Information
description: Add any other context, screenshots, mockups or recordings that can help us better understand your feature request.
label: Additional Context
description: Add any other context or screenshots about the feature request here

View File

@@ -1,54 +1,19 @@
name: Discussion & Questions
description: Seeking help, discussing issues, asking questions, etc...
title: '[Discussion]: '
name: ❓ Question
description: Ask a question or seek help
title: '[Question]: '
labels: ['question']
body:
- type: markdown
attributes:
value: |
Thank you for your question! Please describe your issue in as much detail as possible so that we can better assist you.
- type: checkboxes
id: checklist
attributes:
label: Issue Checklist
description: |
Before submitting an issue, please make sure you have completed the following steps
options:
- label: I understand that issues are meant for feedback and problem-solving, not for venting, and I will provide as much detail as possible to help resolve the issue.
required: true
- label: I have checked the pinned issues and searched through the existing [open issues](https://github.com/CherryHQ/cherry-studio/issues), [closed issues](https://github.com/CherryHQ/cherry-studio/issues?q=is%3Aissue%20state%3Aclosed), and [discussions](https://github.com/CherryHQ/cherry-studio/discussions) and did not find a similar suggestion.
required: true
- label: I confirm that I am here to ask questions and discuss issues, not to report bugs or request features.
required: true
- type: dropdown
id: platform
attributes:
label: Platform
description: What platform are you using?
options:
- Windows
- macOS
- Linux
validations:
required: true
- type: input
id: version
attributes:
label: Version
description: What version of Cherry Studio are you running?
placeholder: e.g. v1.0.0
validations:
required: true
Thanks for asking a question! Please provide as much detail as possible so we can better assist you.
- type: textarea
id: question
attributes:
label: Your Question
description: Please describe your issue in detail. Include screenshots or screen recordings whenever possible to help us better understand your question.
placeholder: Please explain your issue as clearly as possible...(Remember to attach screenshots/recordings if applicable)
description: Please describe your question in detail
placeholder: Please explain your question as clearly as possible...
validations:
required: true
@@ -56,23 +21,23 @@ body:
id: context
attributes:
label: Context
description: Please provide some background information to help us better understand your question. Screenshots or recordings of your current setup or situation can be very helpful.
placeholder: "For example: use case, solutions you've tried, etc. Don't forget to include relevant screenshots/recordings!"
description: Please provide some background information to help us better understand your question
placeholder: "For example: use case, solutions you've tried, etc."
- type: textarea
id: additional
attributes:
label: Additional Information
description: Any other relevant information, screenshots, recordings, or code examples that can help us better assist you
description: Any other relevant information, screenshots, or code examples
render: shell
- type: dropdown
id: priority
attributes:
label: Priority
description: How urgent is this issue for you?
description: How urgent is this question for you?
options:
- Low (Review when available)
- Low (Can wait)
- Medium (Would like a response soon)
- High (Blocking progress)
validations:

View File

@@ -1,261 +0,0 @@
name: Nightly Build
on:
workflow_dispatch:
schedule:
- cron: '0 17 * * *' # 1:00 BJ Time
permissions:
contents: write
jobs:
nightly-build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [macos-latest, windows-latest, ubuntu-latest]
fail-fast: false
steps:
- name: Check out Git repository
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install corepack
run: corepack enable && corepack prepare yarn@4.6.0 --activate
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT
- name: Cache yarn dependencies
uses: actions/cache@v4
with:
path: |
${{ steps.yarn-cache-dir-path.outputs.dir }}
node_modules
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install Dependencies
run: yarn install
- name: Generate date tag
id: date
run: echo "date=$(date +'%Y%m%d')" >> $GITHUB_OUTPUT
shell: bash
- name: Build Linux
if: matrix.os == 'ubuntu-latest'
run: |
yarn build:npm linux
yarn build:linux
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
- name: Build Mac
if: matrix.os == 'macos-latest'
run: |
yarn build:npm mac
yarn build:mac
env:
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_ID: ${{ vars.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ vars.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build Windows
if: matrix.os == 'windows-latest'
run: yarn build:win
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
- name: Replace spaces in filenames
run: node scripts/replace-spaces.js
- name: Rename artifacts with nightly format
shell: bash
run: |
mkdir -p renamed-artifacts
DATE=${{ steps.date.outputs.date }}
# Windows artifacts - based on actual file naming pattern
if [ "${{ matrix.os }}" == "windows-latest" ]; then
# Setup installer
find dist -name "*setup.exe" -exec cp {} renamed-artifacts/cherry-studio-nightly-${DATE}-setup.exe \;
# Portable exe
find dist -name "*portable.exe" -exec cp {} renamed-artifacts/cherry-studio-nightly-${DATE}-portable.exe \;
# Rename blockmap files to match the new exe names
if [ -f "dist/*setup.exe.blockmap" ]; then
cp dist/*setup.exe.blockmap renamed-artifacts/cherry-studio-nightly-${DATE}-setup.exe.blockmap || true
fi
fi
# macOS artifacts
if [ "${{ matrix.os }}" == "macos-latest" ]; then
# 处理arm64架构文件
find dist -name "*-arm64.dmg" -exec cp {} renamed-artifacts/cherry-studio-nightly-${DATE}-arm64.dmg \;
find dist -name "*-arm64.dmg.blockmap" -exec cp {} renamed-artifacts/cherry-studio-nightly-${DATE}-arm64.dmg.blockmap \;
find dist -name "*-arm64.zip" -exec cp {} renamed-artifacts/cherry-studio-nightly-${DATE}-arm64.zip \;
find dist -name "*-arm64.zip.blockmap" -exec cp {} renamed-artifacts/cherry-studio-nightly-${DATE}-arm64.zip.blockmap \;
# 处理x64架构文件
find dist -name "*-x64.dmg" -exec cp {} renamed-artifacts/cherry-studio-nightly-${DATE}-x64.dmg \;
find dist -name "*-x64.dmg.blockmap" -exec cp {} renamed-artifacts/cherry-studio-nightly-${DATE}-x64.dmg.blockmap \;
find dist -name "*-x64.zip" -exec cp {} renamed-artifacts/cherry-studio-nightly-${DATE}-x64.zip \;
find dist -name "*-x64.zip.blockmap" -exec cp {} renamed-artifacts/cherry-studio-nightly-${DATE}-x64.zip.blockmap \;
fi
# Linux artifacts
if [ "${{ matrix.os }}" == "ubuntu-latest" ]; then
find dist -name "*.AppImage" -exec cp {} renamed-artifacts/cherry-studio-nightly-${DATE}.AppImage \;
find dist -name "*.snap" -exec cp {} renamed-artifacts/cherry-studio-nightly-${DATE}.snap \;
find dist -name "*.deb" -exec cp {} renamed-artifacts/cherry-studio-nightly-${DATE}.deb \;
find dist -name "*.rpm" -exec cp {} renamed-artifacts/cherry-studio-nightly-${DATE}.rpm \;
find dist -name "*.tar.gz" -exec cp {} renamed-artifacts/cherry-studio-nightly-${DATE}.tar.gz \;
fi
# Copy update files
cp dist/latest*.yml renamed-artifacts/ || true
# Generate SHA256 checksums (Windows)
- name: Generate SHA256 checksums (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
cd renamed-artifacts
echo "# SHA256 checksums for Windows - $(Get-Date -Format 'yyyy-MM-dd')" > SHA256SUMS.txt
Get-ChildItem -File | Where-Object { $_.Name -ne 'SHA256SUMS.txt' } | ForEach-Object {
$file = $_.Name
$hash = (Get-FileHash -Algorithm SHA256 $file).Hash.ToLower()
Add-Content -Path SHA256SUMS.txt -Value "$hash $file"
}
cat SHA256SUMS.txt
# Generate SHA256 checksums (macOS/Linux)
- name: Generate SHA256 checksums (macOS/Linux)
if: runner.os != 'Windows'
shell: bash
run: |
cd renamed-artifacts
echo "# SHA256 checksums for ${{ runner.os }} - $(date +'%Y-%m-%d')" > SHA256SUMS.txt
if command -v shasum &>/dev/null; then
# macOS
shasum -a 256 * 2>/dev/null | grep -v SHA256SUMS.txt >> SHA256SUMS.txt || echo "No files to hash" >> SHA256SUMS.txt
else
# Linux
sha256sum * 2>/dev/null | grep -v SHA256SUMS.txt >> SHA256SUMS.txt || echo "No files to hash" >> SHA256SUMS.txt
fi
cat SHA256SUMS.txt
- name: List files to be uploaded
shell: bash
run: |
echo "准备上传的文件:"
if [ -x "$(command -v tree)" ]; then
tree renamed-artifacts
elif [ "$RUNNER_OS" == "Windows" ]; then
dir renamed-artifacts
else
ls -la renamed-artifacts
fi
echo "总计: $(find renamed-artifacts -type f | wc -l) 个文件"
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: cherry-studio-nightly-${{ steps.date.outputs.date }}-${{ matrix.os }}
path: renamed-artifacts/*
retention-days: 3 # 保留3天
compression-level: 8
Build-Summary:
needs: nightly-build
if: always()
runs-on: ubuntu-latest
steps:
- name: Get date tag
id: date
run: echo "date=$(date +'%Y%m%d')" >> $GITHUB_OUTPUT
shell: bash
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: all-artifacts
merge-multiple: false
continue-on-error: true
- name: Create summary report
run: |
echo "## ⚠️ 警告:这是每日构建版本" >> $GITHUB_STEP_SUMMARY
echo "此版本为自动构建的不稳定版本,仅供测试使用。不建议在生产环境中使用。" >> $GITHUB_STEP_SUMMARY
echo "安装此版本前请务必备份数据,并做好数据迁移准备。" >> $GITHUB_STEP_SUMMARY
echo "构建日期:$(date +'%Y-%m-%d')" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "## 📦 安装包校验和" >> $GITHUB_STEP_SUMMARY
echo "请在下载后验证文件完整性。提供 SHA256 校验和。" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
# Check each platform's artifacts and show checksums if available
# Windows
WIN_ARTIFACT_DIR="all-artifacts/cherry-studio-nightly-${{ steps.date.outputs.date }}-windows-latest"
if [ -d "$WIN_ARTIFACT_DIR" ] && [ -f "$WIN_ARTIFACT_DIR/SHA256SUMS.txt" ]; then
echo "### Windows 安装包" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
cat "$WIN_ARTIFACT_DIR/SHA256SUMS.txt" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
else
echo "### Windows 安装包" >> $GITHUB_STEP_SUMMARY
echo "❌ Windows 构建未成功完成或未生成校验和。" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
fi
# macOS
MAC_ARTIFACT_DIR="all-artifacts/cherry-studio-nightly-${{ steps.date.outputs.date }}-macos-latest"
if [ -d "$MAC_ARTIFACT_DIR" ] && [ -f "$MAC_ARTIFACT_DIR/SHA256SUMS.txt" ]; then
echo "### macOS 安装包" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
cat "$MAC_ARTIFACT_DIR/SHA256SUMS.txt" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
else
echo "### macOS 安装包" >> $GITHUB_STEP_SUMMARY
echo "❌ macOS 构建未成功完成或未生成校验和。" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
fi
# Linux
LINUX_ARTIFACT_DIR="all-artifacts/cherry-studio-nightly-${{ steps.date.outputs.date }}-ubuntu-latest"
if [ -d "$LINUX_ARTIFACT_DIR" ] && [ -f "$LINUX_ARTIFACT_DIR/SHA256SUMS.txt" ]; then
echo "### Linux 安装包" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
cat "$LINUX_ARTIFACT_DIR/SHA256SUMS.txt" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
else
echo "### Linux 安装包" >> $GITHUB_STEP_SUMMARY
echo "❌ Linux 构建未成功完成或未生成校验和。" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
fi
echo "## ⚠️ Warning: This is a nightly build version" >> $GITHUB_STEP_SUMMARY
echo "This version is an unstable version built automatically and is only for testing. It is not recommended to use it in a production environment." >> $GITHUB_STEP_SUMMARY
echo "Please backup your data before installing this version and prepare for data migration." >> $GITHUB_STEP_SUMMARY
echo "Build date: $(date +'%Y-%m-%d')" >> $GITHUB_STEP_SUMMARY

View File

@@ -1,46 +0,0 @@
name: Pull Request CI
on:
workflow_dispatch:
pull_request:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Check out Git repository
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install corepack
run: corepack enable && corepack prepare yarn@4.6.0 --activate
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT
- name: Cache yarn dependencies
uses: actions/cache@v4
with:
path: |
${{ steps.yarn-cache-dir-path.outputs.dir }}
node_modules
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install Dependencies
run: yarn install
- name: Build Check
run: yarn build:check
- name: Lint Check
run: yarn lint

View File

@@ -2,11 +2,6 @@ name: Release
on:
workflow_dispatch:
inputs:
tag:
description: 'Release tag (e.g. v1.0.0)'
required: true
default: 'v0.9.18'
push:
tags:
- v*.*.*
@@ -21,36 +16,25 @@ jobs:
strategy:
matrix:
os: [macos-latest, windows-latest, ubuntu-latest]
fail-fast: false
steps:
- name: Check out Git repository
uses: actions/checkout@v4
- name: Get release tag
id: get-tag
shell: bash
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT
else
echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
fi
uses: actions/checkout@v3
- name: Install Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v3
with:
node-version: 20
- name: Install corepack
run: corepack enable && corepack prepare yarn@4.6.0 --activate
run: corepack enable && corepack prepare yarn@4.3.1 --activate
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT
- name: Cache yarn dependencies
uses: actions/cache@v4
uses: actions/cache@v3
with:
path: |
${{ steps.yarn-cache-dir-path.outputs.dir }}
@@ -69,8 +53,7 @@ jobs:
yarn build:linux
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
GH_TOKEN: ${{ secrets.GH_TOKEN }}
- name: Build Mac
if: matrix.os == 'macos-latest'
@@ -83,25 +66,31 @@ jobs:
APPLE_ID: ${{ vars.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ vars.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ secrets.GH_TOKEN }}
- name: Build Windows
if: matrix.os == 'windows-latest'
run: yarn build:win
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
GH_TOKEN: ${{ secrets.GH_TOKEN }}
- name: Replace spaces in filenames
run: node scripts/replace-spaces.js
- name: Release
uses: ncipollo/release-action@v1
uses: softprops/action-gh-release@v2
with:
draft: true
allowUpdates: true
makeLatest: false
tag: ${{ steps.get-tag.outputs.tag }}
artifacts: 'dist/*.exe,dist/*.zip,dist/*.dmg,dist/*.AppImage,dist/*.snap,dist/*.deb,dist/*.rpm,dist/*.tar.gz,dist/latest*.yml,dist/*.blockmap'
token: ${{ secrets.GITHUB_TOKEN }}
files: |
dist/*.exe
dist/*.zip
dist/*.dmg
dist/*.AppImage
dist/*.snap
dist/*.deb
dist/*.rpm
dist/*.tar.gz
dist/latest*.yml
dist/*.blockmap
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}

3
.gitignore vendored
View File

@@ -44,6 +44,3 @@ stats.html
# Local
local
.aider*
.cursorrules
.cursor/rules

View File

@@ -1 +0,0 @@
yarn lint-staged

1
.npmrc
View File

@@ -1 +0,0 @@
electron_mirror=https://npmmirror.com/mirrors/electron/

View File

@@ -6,4 +6,3 @@ tsconfig.json
tsconfig.*.json
CHANGELOG*.md
agents.json
src/renderer/src/integration/nutstore/sso/lib

View File

@@ -4,8 +4,7 @@
"source.fixAll.eslint": "explicit"
},
"search.exclude": {
"**/dist/**": true,
".yarn/releases/**": true
"**/dist/**": true
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"

View File

@@ -1,19 +0,0 @@
diff --git a/dist/embeddings.js b/dist/embeddings.js
index 1f8154be3e9c22442a915eb4b85fa6d2a21b0d0c..dc13ef4a30e6c282824a5357bcee9bd0ae222aab 100644
--- a/dist/embeddings.js
+++ b/dist/embeddings.js
@@ -214,10 +214,12 @@ export class OpenAIEmbeddings extends Embeddings {
* @returns Promise that resolves to an embedding for the document.
*/
async embedQuery(text) {
+ const isBaiduCloud = this.clientConfig.baseURL.includes('baidubce.com')
+ const input = this.stripNewLines ? text.replace(/\n/g, ' ') : text
const params = {
model: this.model,
- input: this.stripNewLines ? text.replace(/\n/g, " ") : text,
- };
+ input: isBaiduCloud ? [input] : input
+ }
if (this.dimensions) {
params.dimensions = this.dimensions;
}

View File

@@ -0,0 +1,25 @@
diff --git a/src/libsql-db.js b/src/libsql-db.js
index 58c42e4910bd0e53bc497ff9b9702b1f7a961266..250bc97c50a9b790e8798441d904d040f2d2af43 100644
--- a/src/libsql-db.js
+++ b/src/libsql-db.js
@@ -41,9 +41,9 @@ export class LibSqlDb {
}
async similaritySearch(query, k) {
const statement = `SELECT id, pageContent, uniqueLoaderId, source, metadata,
- vector_distance_cos(vector, vector32('[${query.join(',')}]'))
+ vector_distance_cos(vector, vector32('[${query.join(',')}]')) as distance
FROM ${this.tableName}
- ORDER BY vector_distance_cos(vector, vector32('[${query.join(',')}]')) ASC
+ ORDER BY distance ASC
LIMIT ${k};`;
this.debug(`Executing statement - ${truncateCenterString(statement, 700)}`);
const results = await this.client.execute(statement);
@@ -52,7 +52,7 @@ export class LibSqlDb {
return {
metadata,
pageContent: result.pageContent.toString(),
- score: 1,
+ score: 1 - result.distance,
};
});
}

View File

@@ -0,0 +1,17 @@
diff --git a/src/core/rag-embedding.js b/src/core/rag-embedding.js
index 50c3c4064af17bc4c7c46554d8f2419b3afceb0e..632c9b2e04d2e0e3bb09ef1cd8f29d2560e6afc1 100644
--- a/src/core/rag-embedding.js
+++ b/src/core/rag-embedding.js
@@ -1,10 +1,8 @@
export class RAGEmbedding {
static singleton;
static async init(embeddingModel) {
- if (!this.singleton) {
- await embeddingModel.init();
- this.singleton = new RAGEmbedding(embeddingModel);
- }
+ await embeddingModel.init();
+ this.singleton = new RAGEmbedding(embeddingModel);
}
static getInstance() {
return RAGEmbedding.singleton;

View File

@@ -0,0 +1,54 @@
diff --git a/src/util/strings.cjs b/src/util/strings.cjs
index 9933cc6e3866c476b47342a29ddb206eb90fa4a5..2965c4f2808bf94af9ef3e2ec889e5552e30e6ae 100644
--- a/src/util/strings.cjs
+++ b/src/util/strings.cjs
@@ -38,13 +38,16 @@ function toTitleCase(str) {
});
}
function isValidURL(url) {
- try {
- new URL(url);
- return true;
- }
- catch {
- return false;
+ if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('ftp://')) {
+ try {
+ new URL(url);
+ return true;
+ }
+ catch {
+ return false;
+ }
}
+ return false;
}
function isValidJson(str) {
try {
diff --git a/src/util/strings.js b/src/util/strings.js
index f5c1655512099b880fc5022e95d5e0c4d1d073f2..1a64bd662a22efd2effd9d2846ffcf0b93391963 100644
--- a/src/util/strings.js
+++ b/src/util/strings.js
@@ -29,13 +29,16 @@ export function toTitleCase(str) {
});
}
export function isValidURL(url) {
- try {
- new URL(url);
- return true;
- }
- catch {
- return false;
+ if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('ftp://')) {
+ try {
+ new URL(url);
+ return true;
+ }
+ catch {
+ return false;
+ }
}
+ return false;
}
export function isValidJson(str) {
try {

View File

@@ -1,57 +0,0 @@
diff --git a/dist/index.js b/dist/index.js
index 88c405a000d21b3631eaa378690907c5527b8eaf..e03e66440c7c93aee38adf57df3096c6fefcd96d 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -82,7 +82,6 @@ module.exports = __toCommonJS(index_exports);
// src/utils.ts
var import_axios = __toESM(require("axios"));
-var import_js_tiktoken = require("js-tiktoken");
var BASE_URL = "https://api.tavily.com";
var DEFAULT_MODEL_ENCODING = "gpt-3.5-turbo";
var DEFAULT_MAX_TOKENS = 4e3;
@@ -97,8 +96,7 @@ function post(endpoint, body, apiKey) {
});
}
function getTotalTokensFromString(str, encodingName = DEFAULT_MODEL_ENCODING) {
- const encoding = (0, import_js_tiktoken.encodingForModel)(encodingName);
- return encoding.encode(str).length;
+ return 0;
}
function getMaxTokensFromList(data, maxTokens = DEFAULT_MAX_TOKENS) {
var result = [];
diff --git a/dist/index.mjs b/dist/index.mjs
index 0a9ea6a0add8d709e6721e806571f373d9fe0487..b81f1ea48a2b2a30ee98d53980a1b04ea3fdc5d4 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -49,7 +49,6 @@ var __async = (__this, __arguments, generator) => {
// src/utils.ts
import axios from "axios";
-import { encodingForModel } from "js-tiktoken";
var BASE_URL = "https://api.tavily.com";
var DEFAULT_MODEL_ENCODING = "gpt-3.5-turbo";
var DEFAULT_MAX_TOKENS = 4e3;
@@ -64,8 +63,7 @@ function post(endpoint, body, apiKey) {
});
}
function getTotalTokensFromString(str, encodingName = DEFAULT_MODEL_ENCODING) {
- const encoding = encodingForModel(encodingName);
- return encoding.encode(str).length;
+ return 0;
}
function getMaxTokensFromList(data, maxTokens = DEFAULT_MAX_TOKENS) {
var result = [];
diff --git a/package.json b/package.json
index 36d4a613166a7906c1dc5377a89dc0a65f746f73..dc6e0e9363046755cad123e627cc270a2e3580d1 100644
--- a/package.json
+++ b/package.json
@@ -36,7 +36,6 @@
"typescript": "^5.6.2"
},
"dependencies": {
- "axios": "^1.7.7",
- "js-tiktoken": "^1.0.14"
+ "axios": "^1.7.7"
}
}

View File

@@ -1,53 +0,0 @@
diff --git a/epub.js b/epub.js
index 50efff7678ca4879ed639d3bb70fd37e7477fd16..accbe689cd200bd59475dd20fca596511d0f33e0 100644
--- a/epub.js
+++ b/epub.js
@@ -3,9 +3,28 @@ var xml2jsOptions = xml2js.defaults['0.1'];
var EventEmitter = require('events').EventEmitter;
try {
- // zipfile is an optional dependency:
- var ZipFile = require("zipfile").ZipFile;
-} catch (err) {
+ var zipread = require("zipread");
+ var ZipFile = function(filename) {
+ var zip = zipread(filename);
+ this.zip = zip;
+ var files = zip.files;
+
+ files = Object.values(files).filter((file) => {
+ return !file.dir;
+ }).map((file) => {
+ return file.name;
+ });
+
+ this.names = files;
+ this.count = this.names.length;
+ };
+ ZipFile.prototype.readFile = function(name, cb) {
+ this.zip.readFile(name
+ , function(err, buffer) {
+ return cb(null, buffer);
+ });
+ };
+} catch(err) {
// Mock zipfile using pure-JS adm-zip:
var AdmZip = require('adm-zip');
diff --git a/package.json b/package.json
index 8c3dccf0caac8913a2edabd7049b25bb9063c905..57bac3b71ddd73916adbdf00b049089181db5bcb 100644
--- a/package.json
+++ b/package.json
@@ -40,10 +40,8 @@
],
"dependencies": {
"adm-zip": "^0.4.11",
- "xml2js": "^0.4.23"
- },
- "optionalDependencies": {
- "zipfile": "^0.5.11"
+ "xml2js": "^0.4.23",
+ "zipread": "^1.3.3"
},
"devDependencies": {
"@types/mocha": "^5.2.5",

View File

@@ -0,0 +1,26 @@
diff --git a/core.js b/core.js
index 30c91e66bf595a66c09eb3dbcbda7d58154865f5..b511ff24ea1891904c60174c6ed26ecdd4d5ac51 100644
--- a/core.js
+++ b/core.js
@@ -156,7 +156,7 @@ class APIClient {
Accept: 'application/json',
'Content-Type': 'application/json',
'User-Agent': this.getUserAgent(),
- ...getPlatformHeaders(),
+ // ...getPlatformHeaders(),
...this.authHeaders(opts),
};
}
diff --git a/core.mjs b/core.mjs
index ac267bcfcff44b1f7c9bea5513bba94726a31795..dd5bd9f29609d3f0eea4bd5b225f302893df14ad 100644
--- a/core.mjs
+++ b/core.mjs
@@ -149,7 +149,7 @@ export class APIClient {
Accept: 'application/json',
'Content-Type': 'application/json',
'User-Agent': this.getUserAgent(),
- ...getPlatformHeaders(),
+ // ...getPlatformHeaders(),
...this.authHeaders(opts),
};
}

View File

@@ -1,39 +0,0 @@
diff --git a/core.js b/core.js
index e75a18281ce8f051990c5a50bc1076afdddf91a3..e62f796791a155f23d054e74a429516c14d6e11b 100644
--- a/core.js
+++ b/core.js
@@ -156,7 +156,7 @@ class APIClient {
Accept: 'application/json',
'Content-Type': 'application/json',
'User-Agent': this.getUserAgent(),
- ...getPlatformHeaders(),
+ // ...getPlatformHeaders(),
...this.authHeaders(opts),
};
}
diff --git a/core.mjs b/core.mjs
index fcef58eb502664c41a77483a00db8adaf29b2817..18c5d6ed4be86b3640931277bdc27700006764d7 100644
--- a/core.mjs
+++ b/core.mjs
@@ -149,7 +149,7 @@ export class APIClient {
Accept: 'application/json',
'Content-Type': 'application/json',
'User-Agent': this.getUserAgent(),
- ...getPlatformHeaders(),
+ // ...getPlatformHeaders(),
...this.authHeaders(opts),
};
}
diff --git a/error.mjs b/error.mjs
index 7d19f5578040afa004bc887aab1725e8703d2bac..59ec725b6142299a62798ac4bdedb63ba7d9932c 100644
--- a/error.mjs
+++ b/error.mjs
@@ -36,7 +36,7 @@ export class APIError extends OpenAIError {
if (!status || !headers) {
return new APIConnectionError({ message, cause: castToError(errorResponse) });
}
- const error = errorResponse?.['error'];
+ const error = errorResponse?.['error'] || errorResponse;
if (status === 400) {
return new BadRequestError(status, error, message, headers);
}

View File

@@ -1,18 +0,0 @@
diff --git a/dist/index.node.js b/dist/index.node.js
index bb108cbc210af5b99e864fd1dd8c555e948ecf7a..8ef8c1aab59215c21d161c0e52125724528ecab8 100644
--- a/dist/index.node.js
+++ b/dist/index.node.js
@@ -1,8 +1,11 @@
let crypto;
crypto =
globalThis.crypto?.webcrypto ?? // Node.js 16 REPL has globalThis.crypto as node:crypto
- globalThis.crypto ?? // Node.js 18+
- (await import("node:crypto")).webcrypto; // Node.js 16 non-REPL
+ globalThis.crypto ?? // Node.js 18+
+ (async() => {
+ const crypto = await import("node:crypto");
+ return crypto.webcrypto;
+ })();
/**
* Creates an array of length `size` of random bytes
* @param size

File diff suppressed because one or more lines are too long

View File

@@ -3,5 +3,3 @@ enableImmutableInstalls: false
httpTimeout: 300000
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.6.0.cjs

View File

@@ -40,6 +40,6 @@
如果您有任何问题或建议,欢迎通过以下方式联系我们:
- 微信kangfenmao
- [GitHub Issues](https://github.com/CherryHQ/cherry-studio/issues)
- [GitHub Issues](https://github.com/kangfenmao/cherry-studio/issues)
感谢您的支持和贡献!我们期待与您一起将 Cherry Studio 打造成更好的产品。

63
LICENSE
View File

@@ -1,16 +1,19 @@
## Cherry Studio 用户协议
欢迎使用 Cherry Studio 桌面 AI 客户端工具。请仔细阅读以下协议条款,继续使用本软件即表示您同意本协议内容。
**许可协议**
本软件采用 Apache License 2.0 许可。除 Apache License 2.0 规定的条款外,您在使用 Cherry Studio 时还应遵守以下附加条款:
**一. 商用许可**
在以下任何一种情况下,您需要联系我们并获得明确的书面商业授权后,方可继续使用 Cherry Studio 材料:
1. **修改与衍生** 您对 Cherry Studio 材料进行修改或基于其进行衍生开发(包括但不限于修改应用名称、Logo、代码、功能、界面,数据等)。
2. **企业服务** 在您的企业内部,或为企业客户提供基于 CherryStudio 的服务,且该服务支持 10 人以上累计用户使用。
3. **硬件捆绑销售** 您将 Cherry Studio 预装或集成到硬件设备或产品中进行捆绑销售。
4. **政府或教育机构大规模采购** 您的使用场景属于政府或教育机构的大规模采购项目,特别是涉及安全、数据隐私等敏感需求时。
5. **面向公众的公有云服务**:基于 Cherry Studio提供面向公众的公有云服务。
1. **免费商用**:用户在不修改代码的情况下,可以免费用于商业目的。
2. **商业授权**:如果您满足以下任意条件之一,需取得商业授权:
1. 对本软件进行二次修改、开发(包括但不限于修改应用名称、logo、代码以及功能)。
2. 为企业客户提供多租户服务,且该服务支持 10 人以上使用。
3. 预装或集成到硬件设备或产品中进行捆绑销售。
4. 政府或教育机构的大规模采购项目,特别是涉及安全、数据隐私等敏感需求时。
**二. 贡献者协议**
@@ -30,33 +33,47 @@
---
根据 Apache 许可证 2.0 版(“许可证”)进行许可;除非符合许可证,否则您不得使用此文件。您可以在以下网址获取许可证副本:
http://www.apache.org/licenses/LICENSE-2.0
除非适用法律要求或书面同意,软件根据许可证分发的内容以“原样”分发,不附带任何明示或暗示的保证或条件。请参阅特定语言管理权限的许可证和许可证下的限制。
## Cherry Studio User Agreement
Welcome to Cherry Studio, a desktop AI client tool. Please read the following agreement carefully. By continuing to use this software, you agree to the terms outlined below.
**License Agreement**
This software is licensed under the Apache License 2.0. In addition to the terms stipulated by the Apache License 2.0, you must comply with the following supplementary terms when using Cherry Studio:
This software is licensed under the **Apache License 2.0**. In addition to the terms of the Apache License 2.0, the following additional terms apply to the use of Cherry Studio:
**I. Commercial Licensing**
**I. Commercial Use License**
You must contact us and obtain explicit written commercial authorization to continue using Cherry Studio materials under any of the following circumstances:
1. **Modifications and Derivatives:** You modify Cherry Studio materials or perform derivative development based on them (including but not limited to changing the applications name, logo, code, functionality, user interface, data, etc.).
2. **Enterprise Services:** You use Cherry Studio internally within your enterprise, or you provide Cherry Studio-based services for enterprise customers, and such services support cumulative usage by 10 or more users.
3. **Hardware Bundling and Sales:** You pre-install or integrate Cherry Studio into hardware devices or products for bundled sale.
4. **Large-scale Procurement by Government or Educational Institutions:** Your usage scenario involves large-scale procurement projects by government or educational institutions, especially in cases involving sensitive requirements such as security and data privacy.
5. **Public Cloud Services:** You provide public cloud-based product services utilizing Cherry Studio.
1. **Free Commercial Use**: Users can use the software for commercial purposes without modifying the code.
2. **Commercial License Required**: A commercial license is required if any of the following conditions are met:
1. You modify, develop, or alter the software, including but not limited to changes to the application name, logo, code, or functionality.
2. You provide multi-tenant services to enterprise customers with 10 or more users.
3. You pre-install or integrate the software into hardware devices or products and bundle it for sale.
4. You are engaging in large-scale procurement for government or educational institutions, especially involving security, data privacy, or other sensitive requirements.
**II. Contributor Agreement**
As a contributor to Cherry Studio, you must agree to the following terms:
As a contributor to Cherry Studio, you agree to the following:
1. **License Adjustments:** The producer reserves the right to adjust the open-source license as necessary, making it more strict or permissive.
2. **Commercial Usage:** Your contributed code may be used commercially, including but not limited to cloud business operations.
1. **License Adjustment**: The producer reserves the right to adjust the open-source license as needed, making it stricter or more lenient.
2. **Commercial Use**: Any code you contribute may be used for commercial purposes, including but not limited to cloud business operations.
**III. Other Terms**
1. Cherry Studio developers reserve the right of final interpretation of these agreement terms.
2. This agreement may be updated according to practical circumstances, and users will be notified of updates through this software.
1. The interpretation of these terms is subject to the discretion of Cherry Studio developers.
2. These terms may be updated, and users will be notified through the software when changes occur.
If you have any questions or need to apply for commercial authorization, please contact the Cherry Studio development team.
For any questions or to request a commercial license, please contact the Cherry Studio development team.
Other than these specific conditions, all remaining rights and restrictions follow the Apache License 2.0. For more detailed information regarding Apache License 2.0, please visit http://www.apache.org/licenses/LICENSE-2.0.
Apart from the specific conditions mentioned above, all other rights and restrictions follow the Apache License 2.0. Detailed information about the Apache License 2.0 can be found at http://www.apache.org/licenses/LICENSE-2.0.
---
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.

140
README.md
View File

@@ -1,25 +1,19 @@
<h1 align="center">
<a href="https://github.com/CherryHQ/cherry-studio/releases">
<img src="https://github.com/CherryHQ/cherry-studio/blob/main/build/icon.png?raw=true" width="150" height="150" alt="banner" /><br>
</a>
</h1>
<p align="center">English | <a href="./docs/README.zh.md">中文</a> | <a href="./docs/README.ja.md">日本語</a><br></p>
<div align="center">
<a href="https://trendshift.io/repositories/11772" target="_blank"><img src="https://trendshift.io/api/badge/repositories/11772" alt="kangfenmao%2Fcherry-studio | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<a href="https://www.producthunt.com/posts/cherry-studio?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cherry&#0045;studio" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=496640&theme=light" alt="Cherry&#0032;Studio - AI&#0032;Chatbots&#0044;&#0032;AI&#0032;Desktop&#0032;Client | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<a href="https://github.com/kangfenmao/cherry-studio/releases">
<img src="https://github.com/kangfenmao/cherry-studio/blob/main/build/icon.png?raw=true" width="150" height="150" alt="banner" />
</a>
</div>
<div align="center">
English | <a href="./docs/README.zh.md">中文</a> | <a href="./docs/README.ja.md">日本語</a>
</div>
# 🍒 Cherry Studio
![](https://github.com/user-attachments/assets/7b4f2f78-5cbe-4be8-9aec-f98d8405a505)
Cherry Studio is a desktop client that supports for multiple LLM providers, available on Windows, Mac and Linux.
👏 Join [Telegram Group](https://t.me/CherryStudioAI)[Discord](https://discord.gg/wez8HtpxqQ) | [QQ Group(472019156)](https://qm.qq.com/q/CbZiBWwCXu)
❤️ Like Cherry Studio? Give it a star 🌟 or [Sponsor](docs/sponsor.md) to support the development!
# 📖 Guide
https://docs.cherry-ai.com
👏 Join [Telegram Group](https://t.me/CherryStudioAI)
# 🌠 Screenshot
@@ -29,70 +23,72 @@ https://docs.cherry-ai.com
# 🌟 Key Features
![](https://github.com/user-attachments/assets/7b4f2f78-5cbe-4be8-9aec-f98d8405a505)
1. **Diverse LLM Provider Support**:
- ☁️ Major LLM Cloud Services: OpenAI, Gemini, Anthropic, and more
- 🔗 AI Web Service Integration: Claude, Peplexity, Poe, and others
- 💻 Local Model Support with Ollama, LM Studio
- ☁️ Major LLM Cloud Services: OpenAI, Gemini, Anthropic, and more
- 🔗 AI Web Service Integration: Claude, Peplexity, Poe, and others
- 💻 Local Model Support with Ollama
2. **AI Assistants & Conversations**:
- 📚 300+ Pre-configured AI Assistants
- 🤖 Custom Assistant Creation
- 💬 Multi-model Simultaneous Conversations
- 📚 300+ Pre-configured AI Assistants
- 🤖 Custom Assistant Creation
- 💬 Multi-model Simultaneous Conversations
3. **Document & Data Processing**:
- 📄 Support for Text, Images, Office, PDF, and more
- ☁️ WebDAV File Management and Backup
- 📊 Mermaid Chart Visualization
- 💻 Code Syntax Highlighting
- 📄 Support for Text, Images, Office, PDF, and more
- ☁️ WebDAV File Management and Backup
- 📊 Mermaid Chart Visualization
- 💻 Code Syntax Highlighting
4. **Practical Tools Integration**:
- 🔍 Global Search Functionality
- 📝 Topic Management System
- 🔤 AI-powered Translation
- 🎯 Drag-and-drop Sorting
- 🔌 Mini Program Support
- ⚙️ MCP(Model Context Protocol) Server
- 🔍 Global Search Functionality
- 📝 Topic Management System
- 🔤 AI-powered Translation
- 🎯 Drag-and-drop Sorting
- 🔌 Mini Program Support
5. **Enhanced User Experience**:
- 🖥️ Cross-platform Support for Windows, Mac, and Linux
- 📦 Ready to Use, No Environment Setup Required
- 🎨 Light/Dark Themes and Transparent Window
- 📝 Complete Markdown Rendering
- 🤲 Easy Content Sharing
# 📝 TODO
- [x] Quick popup (read clipboard, quick question, explain, translate, summarize)
- [x] Comparison of multi-model answers
- [x] Support login using SSO provided by service providers
- [x] All models support networking
- [x] Launch of the first official version
- [x] Bug fixes and improvements (In progress...)
- [ ] Plugin functionality (JavaScript)
- [ ] Browser extension (highlight text to translate, summarize, add to knowledge base)
- [ ] iOS & Android client
- [ ] AI notes
- [ ] Voice input and output (AI call)
- [ ] Data backup supports custom backup content
# 🌈 Theme
- Theme Gallery: https://cherrycss.com
- Aero Theme: https://github.com/hakadao/CherryStudio-Aero
- PaperMaterial Theme: https://github.com/rainoffallingstar/CherryStudio-PaperMaterial
Welcome PR for more themes
- 🖥️ Cross-platform Support for Windows, Mac, and Linux
- 📦 Ready to Use, No Environment Setup Required
- 🎨 Light/Dark Themes and Transparent Window
- 📝 Complete Markdown Rendering
- 🤲 Easy Content Sharing
# 🖥️ Develop
Refer to the [development documentation](docs/dev.md)
## IDE Setup
[Cursor](https://www.cursor.com/) + [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) + [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)
## Project Setup
### Install
```bash
$ yarn
```
### Development
```bash
$ yarn dev
```
### Build
```bash
# For windows
$ yarn build:win
# For macOS
$ yarn build:mac
# For Linux
$ yarn build:linux
```
# 🤝 Contributing
@@ -117,22 +113,20 @@ For more detailed guidelines, please refer to our [Contributing Guide](./CONTRIB
Thank you for your support and contributions!
## Related Projects
- [one-api](https://github.com/songquanpeng/one-api):LLM API management and distribution system, supporting mainstream models like OpenAI, Azure, and Anthropic. Features unified API interface, suitable for key management and secondary distribution.
- [ublacklist](https://github.com/iorate/ublacklist):Blocks specific sites from appearing in Google search results
# 🚀 Contributors
<a href="https://github.com/CherryHQ/cherry-studio/graphs/contributors">
<a href="https://github.com/kangfenmao/cherry-studio/graphs/contributors">
<img src="https://contrib.rocks/image?repo=kangfenmao/cherry-studio" />
</a>
<br /><br />
# 🌐 Community
[Telegram](https://t.me/CherryStudioAI) | [Email](mailto:support@cherry-ai.com) | [Twitter](https://x.com/kangfenmao)
[Telegram](https://t.me/CherryStudioAI) | [Email](mailto:kangfenmao@gmail.com) | [Twitter](https://x.com/kangfenmao)
# 📣 Product Hunt
<a href="https://www.producthunt.com/posts/cherry-studio?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cherry&#0045;studio" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=496640&theme=light" alt="Cherry&#0032;Studio - AI&#0032;Chatbots&#0044;&#0032;AI&#0032;Desktop&#0032;Client | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
# ☕ Sponsor
@@ -142,10 +136,6 @@ Thank you for your support and contributions!
[LICENSE](./LICENSE)
# ✉️ Contact
yinsenho@cherry-ai.com
# ⭐️ Star History
[![Star History Chart](https://api.star-history.com/svg?repos=kangfenmao/cherry-studio&type=Timeline)](https://star-history.com/#kangfenmao/cherry-studio&Timeline)

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 353 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

View File

@@ -1,8 +1,8 @@
# provider: generic
# url: http://127.0.0.1:8080
# updaterCacheDirName: cherry-studio-updater
provider: github
repo: cherry-studio
owner: kangfenmao
# provider: generic
# url: https://cherrystudio.ocool.online
# provider: github
# repo: cherry-studio
# owner: kangfenmao
provider: generic
url: https://cherrystudio.ocool.online

View File

@@ -1,26 +1,19 @@
<h1 align="center">
<a href="https://github.com/CherryHQ/cherry-studio/releases">
<img src="https://github.com/CherryHQ/cherry-studio/blob/main/build/icon.png?raw=true" width="150" height="150" alt="banner" />
</a>
</h1>
<p align="center">
<a href="https://github.com/CherryHQ/cherry-studio">English</a> | <a href="./README.zh.md">中文</a> | 日本語 <br>
</p>
<div align="center">
<a href="https://trendshift.io/repositories/11772" target="_blank"><img src="https://trendshift.io/api/badge/repositories/11772" alt="kangfenmao%2Fcherry-studio | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<a href="https://www.producthunt.com/posts/cherry-studio?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cherry&#0045;studio" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=496640&theme=light" alt="Cherry&#0032;Studio - AI&#0032;Chatbots&#0044;&#0032;AI&#0032;Desktop&#0032;Client | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<a href="https://github.com/kangfenmao/cherry-studio/releases">
<img src="https://github.com/kangfenmao/cherry-studio/blob/main/build/icon.png?raw=true" width="150" height="150" alt="banner" />
</a>
</div>
<div align="center">
<a href="./README.md">English</a> | <a href="./README.zh.md">中文</a> | 日本語
</div>
# 🍒 Cherry Studio
Cherry Studio は、複数の LLM プロバイダーをサポートするデスクトップクライアントで、Windows、Mac、Linux で利用可能です。
![](https://github.com/user-attachments/assets/7b4f2f78-5cbe-4be8-9aec-f98d8405a505)
👏 [Telegram](https://t.me/CherryStudioAI)[Discord](https://discord.gg/wez8HtpxqQ) | [QQグループ(472019156)](https://qm.qq.com/q/CbZiBWwCXu)
Cherry Studioは、複数のLLMプロバイダーをサポートするデスクトップクライアントで、Windows、Mac、Linuxで利用可能です。
❤️ Cherry Studio をお気に入りにしましたか?小さな星をつけてください 🌟 または [スポンサー](sponsor.md) をして開発をサポートしてください!❤️
# 📖 ガイド
https://docs.cherry-ai.com
👏 [Telegramグループ](https://t.me/CherryStudioAI)に参加しましょう
# 🌠 スクリーンショット
@@ -30,81 +23,84 @@ https://docs.cherry-ai.com
# 🌟 主な機能
![](https://github.com/user-attachments/assets/7b4f2f78-5cbe-4be8-9aec-f98d8405a505)
1. **多様な LLM サービス対応**
- ☁️ 主要な LLM クラウドサービス対応OpenAI、Gemini、Anthropic など
- 🔗 AI Web サービス統合Claude、Peplexity、Poe など
- 💻 Ollama、LM Studio によるローカルモデル実行対応
- ☁️ 主要な LLM クラウドサービス対応OpenAI、Gemini、Anthropic など
- 🔗 AI Web サービス統合Claude、Peplexity、Poe など
- 💻 Ollama によるローカルモデル実行対応
2. **AI アシスタントと対話**
- 📚 300+ の事前設定済み AI アシスタント
- 🤖 カスタム AI アシスタントの作成
- 💬 複数モデルでの同時対話機能
- 📚 300+ の事前設定済み AI アシスタント
- 🤖 カスタム AI アシスタントの作成
- 💬 複数モデルでの同時対話機能
3. **文書とデータ処理**
- 📄 テキスト、画像、Office、PDF など多様な形式対応
- ☁️ WebDAV によるファイル管理とバックアップ
- 📊 Mermaid による図表作成
- 💻 コードハイライト機能
- 📄 テキスト、画像、Office、PDF など多様な形式対応
- ☁️ WebDAV によるファイル管理とバックアップ
- 📊 Mermaid による図表作成
- 💻 コードハイライト機能
4. **実用的なツール統合**
- 🔍 グローバル検索機能
- 📝 トピック管理システム
- 🔤 AI による翻訳機能
- 🎯 ドラッグ&ドロップによる整理
- 🔌 ミニプログラム対応
- ⚙️ MCPモデルコンテキストプロトコル サービス
- 🔍 グローバル検索機能
- 📝 トピック管理システム
- 🔤 AI による翻訳機能
- 🎯 ドラッグ&ドロップによる整理
- 🔌 ミニプログラム対応
5. **優れたユーザー体験**
- 🖥️ Windows、Mac、Linux のクロスプラットフォーム対応
- 📦 環境構築不要ですぐに使用可能
- 🎨 ライト/ダークテーマと透明ウィンドウ対応
- 📝 完全な Markdown レンダリング
- 🤲 簡単な共有機能
# 📝 TODO
- [x] クイックポップアップ(クリップボードの読み取り、簡単な質問、説明、翻訳、要約)
- [x] 複数モデルの回答の比較
- [x] サービスプロバイダーが提供する SSO を使用したログインをサポート
- [x] すべてのモデルがネットワークをサポート
- [x] 最初の公式バージョンのリリース
- [ ] 錯誤修復と改善 (開発中...)
- [ ] プラグイン機能JavaScript
- [ ] ブラウザ拡張機能(テキストをハイライトして翻訳、要約、ナレッジベースに追加)
- [ ] iOS & Android クライアント
- [ ] AIート
- [ ] 音声入出力AI コール)
- [ ] データバックアップはカスタムバックアップコンテンツをサポート
# 🌈 テーマ
テーマギャラリー: https://cherrycss.com
Aero テーマ: https://github.com/hakadao/CherryStudio-Aero
より多くのテーマのPRを歓迎します
- 🖥️ Windows、Mac、Linux のクロスプラットフォーム対応
- 📦 環境構築不要ですぐに使用可能
- 🎨 ライト/ダークテーマと透明ウィンドウ対応
- 📝 完全な Markdown レンダリング
- 🤲 簡単な共有機能
# 🖥️ 開発
参考[開発ドキュメント](dev.md)
## IDEの設定
[Cursor](https://www.cursor.com/) + [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) + [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)
## プロジェクトの設定
### インストール
```bash
$ yarn
```
### 開発
```bash
$ yarn dev
```
### ビルド
```bash
# Windowsの場合
$ yarn build:win
# macOSの場合
$ yarn build:mac
# Linuxの場合
$ yarn build:linux
```
# 🤝 貢献
Cherry Studio への貢献を歓迎します!以下の方法で貢献できます:
Cherry Studioへの貢献を歓迎します以下の方法で貢献できます
1. **コードの貢献**:新機能を開発するか、既存のコードを最適化します。
2. **バグの修正**:見つけたバグを修正します。
3. **問題の管理**GitHub の問題を管理するのを手伝います。
3. **問題の管理**GitHubの問題を管理するのを手伝います。
4. **製品デザイン**:デザインの議論に参加します。
5. **ドキュメントの作成**:ユーザーマニュアルやガイドを改善します。
6. **コミュニティの参加**:ディスカッションに参加し、ユーザーを支援します。
7. **使用の促進**Cherry Studio を広めます。
7. **使用の促進**Cherry Studioを広めます。
## 始め方
@@ -113,23 +109,23 @@ Cherry Studio への貢献を歓迎します!以下の方法で貢献できま
3. **変更を提出**:変更をコミットしてプッシュします。
4. **プルリクエストを開く**:変更内容と理由を説明します。
詳細なガイドラインについては、[貢献ガイド](../CONTRIBUTING.md)をご覧ください。
詳細なガイドラインについては、[貢献ガイド](./CONTRIBUTING.md)をご覧ください。
ご支援と貢献に感謝します!
## 関連頁版
- [one-api](https://github.com/songquanpeng/one-api)LLM API の管理・配信システム。OpenAI、Azure、Anthropic などの主要モデルに対応し、統一 API インターフェースを提供。API キー管理と再配布に利用可能。
# 🚀 コントリビューター
<a href="https://github.com/CherryHQ/cherry-studio/graphs/contributors">
<a href="https://github.com/kangfenmao/cherry-studio/graphs/contributors">
<img src="https://contrib.rocks/image?repo=kangfenmao/cherry-studio" />
</a>
# コミュニティ
[Telegram](https://t.me/CherryStudioAI) | [Email](mailto:support@cherry-ai.com) | [Twitter](https://x.com/kangfenmao)
[Telegram](https://t.me/CherryStudioAI) | [Email](mailto:kangfenmao@gmail.com) | [Twitter](https://x.com/kangfenmao)
# 📣 プロダクトハント
<a href="https://www.producthunt.com/posts/cherry-studio?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cherry&#0045;studio" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=496640&theme=light" alt="Cherry&#0032;Studio - AI&#0032;Chatbots&#0044;&#0032;AI&#0032;Desktop&#0032;Client | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
# スポンサー
@@ -139,10 +135,6 @@ Cherry Studio への貢献を歓迎します!以下の方法で貢献できま
[LICENSE](../LICENSE)
# ✉️ お問い合わせ
yinsenho@cherry-ai.com
# ⭐️ スター履歴
[![Star History Chart](https://api.star-history.com/svg?repos=kangfenmao/cherry-studio&type=Timeline)](https://star-history.com/#kangfenmao/cherry-studio&Timeline)

View File

@@ -1,26 +1,19 @@
<h1 align="center">
<a href="https://github.com/CherryHQ/cherry-studio/releases">
<img src="https://github.com/CherryHQ/cherry-studio/blob/main/build/icon.png?raw=true" width="150" height="150" alt="banner" />
</a>
</h1>
<p align="center">
<a href="https://github.com/CherryHQ/cherry-studio">English</a> | 中文 | <a href="./README.ja.md">日本語</a><br></p>
<div align="center">
<a href="https://trendshift.io/repositories/11772" target="_blank"><img src="https://trendshift.io/api/badge/repositories/11772" alt="kangfenmao%2Fcherry-studio | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<a href="https://www.producthunt.com/posts/cherry-studio?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cherry&#0045;studio" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=496640&theme=light" alt="Cherry&#0032;Studio - AI&#0032;Chatbots&#0044;&#0032;AI&#0032;Desktop&#0032;Client | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<a href="https://github.com/kangfenmao/cherry-studio/releases">
<img src="https://github.com/kangfenmao/cherry-studio/blob/main/build/icon.png?raw=true" width="150" height="150" alt="banner" />
</a>
</div>
<div align="center">
中文 / <a href="https://github.com/kangfenmao/cherry-studio">English</a> / <a href="./README.ja.md">日本語</a>
</div>
# 🍒 Cherry Studio
![](https://github.com/user-attachments/assets/995910f3-177a-4d1e-97ea-04e3b009ba36)
Cherry Studio 是一款支持多个大语言模型LLM服务商的桌面客户端兼容 Windows、Mac 和 Linux 系统。
👏 欢迎加入 [Telegram 群组](https://t.me/CherryStudioAI)[Discord](https://discord.gg/wez8HtpxqQ) | [QQ群(472019156)](https://qm.qq.com/q/CbZiBWwCXu)
❤️ 喜欢 Cherry Studio? 点亮小星星 🌟 或 [赞助开发者](sponsor.md)! ❤️
# 📖 使用教程
https://docs.cherry-ai.com
👏 欢迎加入 [Telegram 群组](https://t.me/CherryStudioAI)
# 🌠 界面
@@ -30,69 +23,72 @@ https://docs.cherry-ai.com
# 🌟 主要特性
![](https://github.com/user-attachments/assets/995910f3-177a-4d1e-97ea-04e3b009ba36)
1. **多样化 LLM 服务支持**
- ☁️ 支持主流 LLM 云服务OpenAI、Gemini、Anthropic、硅基流动等
- 🔗 集成流行 AI Web 服务Claude、Peplexity、Poe、腾讯元宝、知乎直答等
- 💻 支持 Ollama、LM Studio 本地模型部署
- ☁️ 支持主流 LLM 云服务OpenAI、Gemini、Anthropic、硅基流动等
- 🔗 集成流行 AI Web 服务Claude、Peplexity、Poe、腾讯元宝、知乎直答等
- 💻 支持 Ollama 本地模型部署
2. **智能助手与对话**
- 📚 内置 300+ 预配置 AI 助手
- 🤖 支持自定义创建专属助手
- 💬 多模型同时对话,获得多样化观点
- 📚 内置 300+ 预配置 AI 助手
- 🤖 支持自定义创建专属助手
- 💬 多模型同时对话,获得多样化观点
3. **文档与数据处理**
- 📄 支持文本、图片、Office、PDF 等多种格式
- ☁️ WebDAV 文件管理与数据备份
- 📊 Mermaid 图表可视化
- 💻 代码高亮显示
- 📄 支持文本、图片、Office、PDF 等多种格式
- ☁️ WebDAV 文件管理与数据备份
- 📊 Mermaid 图表可视化
- 💻 代码高亮显示
4. **实用工具集成**
- 🔍 全局搜索功能
- 📝 话题管理系统
- 🔤 AI 驱动的翻译功能
- 🎯 拖拽排序
- 🔌 小程序支持
- ⚙️ MCP(模型上下文协议) 服务
- 🔍 全局搜索功能
- 📝 话题管理系统
- 🔤 AI 驱动的翻译功能
- 🎯 拖拽排序
- 🔌 小程序支持
5. **优质使用体验**
- 🖥️ Windows、Mac、Linux 跨平台支持
- 📦 开箱即用,无需配置环境
- 🎨 支持明暗主题与透明窗口
- 📝 完整的 Markdown 渲染
- 🤲 便捷的内容分享功能
# 📝 待辦事項
- [x] 快捷弹窗(读取剪贴板、快速提问、解释、翻译、总结)
- [x] 多模型回答对比
- [x] 支持使用服务供应商提供的 SSO 进行登入
- [x] 全部模型支持连网(开发中...
- [x] 推出第一个正式版
- [x] 错误修复和改进(开发中...
- [ ] 插件功能JavaScript
- [ ] 浏览器插件(划词翻译、总结、新增至知识库)
- [ ] iOS & Android 客户端
- [ ] AI 笔记
- [ ] 语音输入输出AI 通话)
- [ ] 数据备份支持自定义备份内容
# 🌈 主题
主题库https://cherrycss.com
Aero 主题https://github.com/hakadao/CherryStudio-Aero
欢迎 PR 更多主题
- 🖥️ Windows、Mac、Linux 跨平台支持
- 📦 开箱即用,无需配置环境
- 🎨 支持明暗主题与透明窗口
- 📝 完整的 Markdown 渲染
- 🤲 便捷的内容分享功能
# 🖥️ 开发
参考[开发文档](dev.md)
## IDE 设置
[Cursor](https://www.cursor.com/) + [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) + [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)
## 项目设置
### 安装
```bash
$ yarn
```
### 开发
```bash
$ yarn dev
```
### 构建
```bash
# Windows
$ yarn build:win
# macOS
$ yarn build:mac
# Linux
$ yarn build:linux
```
# 🤝 贡献
@@ -113,24 +109,24 @@ Aero 主题https://github.com/hakadao/CherryStudio-Aero
3. **提交更改**:提交并推送您的更改。
4. **打开 Pull Request**:描述您的更改和原因。
有关更详细的指南,请参阅我们的 [贡献指南](../CONTRIBUTING.md)。
有关更详细的指南,请参阅我们的 [贡献指南](./CONTRIBUTING.md)。
感谢您的支持和贡献!
## 相关项目
- [one-api](https://github.com/songquanpeng/one-api)LLM API 管理及分发系统,支持 OpenAI、Azure、Anthropic 等主流模型,统一 API 接口,可用于密钥管理与二次分发。
# 🚀 贡献者
<a href="https://github.com/CherryHQ/cherry-studio/graphs/contributors">
<a href="https://github.com/kangfenmao/cherry-studio/graphs/contributors">
<img src="https://contrib.rocks/image?repo=kangfenmao/cherry-studio" />
</a>
<br /><br />
# 🌐 社区
[Telegram](https://t.me/CherryStudioAI) | [Email](mailto:support@cherry-ai.com) | [Twitter](https://x.com/kangfenmao)
[Telegram](https://t.me/CherryStudioAI) | [Email](mailto:kangfenmao@gmail.com) | [Twitter](https://x.com/kangfenmao)
# 📣 产品猎人
<a href="https://www.producthunt.com/posts/cherry-studio?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cherry&#0045;studio" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=496640&theme=light" alt="Cherry&#0032;Studio - AI&#0032;Chatbots&#0044;&#0032;AI&#0032;Desktop&#0032;Client | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
# ☕ 赞助
@@ -140,10 +136,6 @@ Aero 主题https://github.com/hakadao/CherryStudio-Aero
[LICENSE](../LICENSE)
# ✉️ 联系我们
yinsenho@cherry-ai.com
# ⭐️ Star 记录
[![Star History Chart](https://api.star-history.com/svg?repos=kangfenmao/cherry-studio&type=Timeline)](https://star-history.com/#kangfenmao/cherry-studio&Timeline)

View File

@@ -1,51 +0,0 @@
# 🖥️ Develop
## IDE Setup
[Cursor](https://www.cursor.com/) + [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) + [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)
## Project Setup
### Install
```bash
yarn
```
### Development
### Setup Node.js
Download and install [Node.js v20.x.x](https://nodejs.org/en/download)
### Setup Yarn
```bash
corepack enable
corepack prepare yarn@4.6.0 --activate
```
### Install Dependencies
```bash
yarn install
```
### Start
```bash
yarn dev
```
### Build
```bash
# For windows
$ yarn build:win
# For macOS
$ yarn build:mac
# For Linux
$ yarn build:linux
```

View File

@@ -1,174 +0,0 @@
我来详细介绍 KnowledgeService 中控制并发处理的项目数量和工作负载的部分。
## 并发控制与工作负载管理机制
KnowledgeService 实现了一个精细的任务队列系统,用于控制并发处理的项目数量和工作负载。这个系统主要通过以下几个关键部分实现:
### 1. 关键变量和限制
```typescript
private workload = 0
private processingItemCount = 0
private knowledgeItemProcessingQueueMappingPromise: Map<LoaderTaskOfSet, () => void> = new Map()
private static MAXIMUM_WORKLOAD = 1024 * 1024 * 80 // 约80MB
private static MAXIMUM_PROCESSING_ITEM_COUNT = 30
```
- `workload`: 跟踪当前正在处理的总工作量(以字节为单位)
- `processingItemCount`: 跟踪当前正在处理的项目数量
- `MAXIMUM_WORKLOAD`: 设置最大工作负载为80MB
- `MAXIMUM_PROCESSING_ITEM_COUNT`: 设置最大并发处理项目数为30个
### 2. 工作负载评估
每个任务都有一个评估工作负载的机制,通过 `evaluateTaskWorkload` 属性来表示:
```typescript
interface EvaluateTaskWorkload {
workload: number
}
```
不同类型的任务有不同的工作负载评估方式:
- 文件任务:使用文件大小作为工作负载 `{ workload: file.size }`
- URL任务使用固定值 `{ workload: 1024 * 1024 * 2 }` (约2MB)
- 网站地图任务:使用固定值 `{ workload: 1024 * 1024 * 20 }` (约20MB)
- 笔记任务:使用文本内容的字节长度 `{ workload: contentBytes.length }`
### 3. 任务状态管理
任务通过状态枚举来跟踪其生命周期:
```typescript
enum LoaderTaskItemState {
PENDING, // 等待处理
PROCESSING, // 正在处理
DONE // 已完成
}
```
### 4. 任务队列处理核心逻辑
核心的队列处理逻辑在 `processingQueueHandle` 方法中:
```typescript
private processingQueueHandle() {
const getSubtasksUntilMaximumLoad = (): QueueTaskItem[] => {
const queueTaskList: QueueTaskItem[] = []
that: for (const [task, resolve] of this.knowledgeItemProcessingQueueMappingPromise) {
for (const item of task.loaderTasks) {
if (this.maximumLoad()) {
break that
}
const { state, task: taskPromise, evaluateTaskWorkload } = item
if (state !== LoaderTaskItemState.PENDING) {
continue
}
const { workload } = evaluateTaskWorkload
this.workload += workload
this.processingItemCount += 1
item.state = LoaderTaskItemState.PROCESSING
queueTaskList.push({
taskPromise: () =>
taskPromise().then(() => {
this.workload -= workload
this.processingItemCount -= 1
task.loaderTasks.delete(item)
if (task.loaderTasks.size === 0) {
this.knowledgeItemProcessingQueueMappingPromise.delete(task)
resolve()
}
this.processingQueueHandle()
}),
resolve: () => {},
evaluateTaskWorkload
})
}
}
return queueTaskList
}
const subTasks = getSubtasksUntilMaximumLoad()
if (subTasks.length > 0) {
const subTaskPromises = subTasks.map(({ taskPromise }) => taskPromise())
Promise.all(subTaskPromises).then(() => {
subTasks.forEach(({ resolve }) => resolve())
})
}
}
```
这个方法的工作流程是:
1. 遍历所有待处理的任务集合
2. 对于每个任务集合中的每个子任务:
- 检查是否已达到最大负载(通过 `maximumLoad()` 方法)
- 如果任务状态为 PENDING
- 增加当前工作负载和处理项目计数
- 将任务状态更新为 PROCESSING
- 将任务添加到待执行队列
3. 执行所有收集到的子任务
4. 当子任务完成时:
- 减少工作负载和处理项目计数
- 从任务集合中移除已完成的任务
- 如果任务集合为空,则解析相应的 Promise
- 递归调用 `processingQueueHandle()` 以处理更多任务
### 5. 负载检查
```typescript
private maximumLoad() {
return (
this.processingItemCount >= KnowledgeService.MAXIMUM_PROCESSING_ITEM_COUNT ||
this.workload >= KnowledgeService.MAXIMUM_WORKLOAD
)
}
```
这个方法检查当前是否已达到最大负载,通过两个条件:
- 处理项目数量达到上限30个
- 总工作负载达到上限80MB
### 6. 任务添加与执行流程
当添加新任务时,流程如下:
1. 创建任务(根据类型不同创建不同的任务)
2. 通过 `appendProcessingQueue` 将任务添加到队列
3. 调用 `processingQueueHandle` 开始处理队列中的任务
```typescript
private appendProcessingQueue(task: LoaderTask): Promise<LoaderReturn> {
return new Promise((resolve) => {
this.knowledgeItemProcessingQueueMappingPromise.set(loaderTaskIntoOfSet(task), () => {
resolve(task.loaderDoneReturn!)
})
})
}
```
## 并发控制的优势
这种并发控制机制有几个重要优势:
1. **资源使用优化**:通过限制同时处理的项目数量和总工作负载,避免系统资源过度使用
2. **自动调节**:当任务完成时,会自动从队列中获取新任务,保持资源的高效利用
3. **灵活性**:不同类型的任务有不同的工作负载评估,更准确地反映实际资源需求
4. **可靠性**通过状态管理和Promise解析机制确保任务正确完成并通知调用者
## 实际应用场景
这种并发控制在处理大量数据时特别有用,例如:
- 导入大型目录时,可能包含数百个文件
- 处理大型网站地图可能包含大量URL
- 处理多个用户同时添加知识库项目的请求
通过这种机制,系统可以平滑地处理大量请求,避免资源耗尽,同时保持良好的响应性。
总结来说KnowledgeService 实现了一个复杂而高效的任务队列系统,通过精确控制并发处理的项目数量和工作负载,确保系统在处理大量数据时保持稳定和高效。

View File

@@ -24,18 +24,14 @@ files:
- '!**/{LICENSE,LICENSE.txt,LICENSE-MIT.txt,*.LICENSE.txt,NOTICE.txt,README.md,CHANGELOG.md}'
- '!node_modules/rollup-plugin-visualizer'
- '!node_modules/js-tiktoken'
- '!node_modules/@tavily/core/node_modules/js-tiktoken'
- '!node_modules/pdf-parse/lib/pdf.js/{v1.9.426,v1.10.88,v2.0.550}'
- '!node_modules/mammoth/{mammoth.browser.js,mammoth.browser.min.js}'
- '!node_modules/html2canvas/dist/{html2canvas.min.js,html2canvas.esm.js}'
asarUnpack:
- resources/**
- '**/*.{node,dll,metal,exp,lib}'
win:
executableName: Cherry Studio
artifactName: ${productName}-${version}-portable.${ext}
target:
- target: nsis
- target: portable
nsis:
artifactName: ${productName}-${version}-setup.${ext}
shortcutName: ${productName}
@@ -47,7 +43,6 @@ nsis:
mac:
entitlementsInherit: build/entitlements.mac.plist
notarize: false
artifactName: ${productName}-${version}-${arch}.${ext}
extendInfo:
- NSCameraUsageDescription: Application requests access to the device's camera.
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
@@ -62,8 +57,9 @@ mac:
arch:
- arm64
- x64
linux:
dmg:
artifactName: ${productName}-${version}-${arch}.${ext}
linux:
target:
- target: AppImage
arch:
@@ -71,19 +67,15 @@ linux:
- x64
maintainer: electronjs.org
category: Utility
appImage:
artifactName: ${productName}-${version}-${arch}.${ext}
publish:
# provider: generic
# url: https://cherrystudio.ocool.online
provider: github
repo: cherry-studio
owner: CherryHQ
provider: generic
url: https://cherrystudio.ocool.online
electronDownload:
mirror: https://npmmirror.com/mirrors/electron/
afterPack: scripts/after-pack.js
afterSign: scripts/notarize.js
releaseInfo:
releaseNotes: |
小程序支持多开
支持 GPT-4o 图像生成
修复 MCP 服务器无法使用问题
修复升级导致旧版本数据丢失问题
增加 Genspark 小程序

View File

@@ -12,18 +12,15 @@ export default defineConfig({
plugins: [
externalizeDepsPlugin({
exclude: [
'@cherrystudio/embedjs',
'@cherrystudio/embedjs-openai',
'@cherrystudio/embedjs-loader-web',
'@cherrystudio/embedjs-loader-markdown',
'@cherrystudio/embedjs-loader-msoffice',
'@cherrystudio/embedjs-loader-xml',
'@cherrystudio/embedjs-loader-pdf',
'@cherrystudio/embedjs-loader-sitemap',
'@cherrystudio/embedjs-libsql',
'@cherrystudio/embedjs-loader-image',
'p-queue',
'webdav'
'@llm-tools/embedjs',
'@llm-tools/embedjs-openai',
'@llm-tools/embedjs-loader-web',
'@llm-tools/embedjs-loader-markdown',
'@llm-tools/embedjs-loader-msoffice',
'@llm-tools/embedjs-loader-xml',
'@llm-tools/embedjs-loader-pdf',
'@llm-tools/embedjs-loader-sitemap',
'@llm-tools/embedjs-libsql'
]
}),
...visualizerPlugin('main')
@@ -45,24 +42,7 @@ export default defineConfig({
plugins: [externalizeDepsPlugin()]
},
renderer: {
plugins: [
react({
babel: {
plugins: [
[
'styled-components',
{
displayName: true, // 开发环境下启用组件名称
fileName: false, // 不在类名中包含文件名
pure: true, // 优化性能
ssr: false // 不需要服务端渲染
}
]
]
}
}),
...visualizerPlugin('renderer')
],
plugins: [react(), ...visualizerPlugin('renderer')],
resolve: {
alias: {
'@renderer': resolve('src/renderer/src'),
@@ -70,7 +50,7 @@ export default defineConfig({
}
},
optimizeDeps: {
exclude: ['chunk-PZ64DZKH.js', 'chunk-JMKENWIY.js', 'chunk-UXYB6GHG.js', 'chunk-ALDIEZMG.js', 'chunk-4X6ZJEXY.js']
exclude: ['chunk-QH6N6I7P.js', 'chunk-PB73W2YU.js']
}
}
})

View File

@@ -1,68 +0,0 @@
import electronConfigPrettier from '@electron-toolkit/eslint-config-prettier'
import tseslint from '@electron-toolkit/eslint-config-ts'
import eslint from '@eslint/js'
import eslintReact from '@eslint-react/eslint-plugin'
import { defineConfig } from 'eslint/config'
import reactHooks from 'eslint-plugin-react-hooks'
import simpleImportSort from 'eslint-plugin-simple-import-sort'
import unusedImports from 'eslint-plugin-unused-imports'
export default defineConfig([
eslint.configs.recommended,
tseslint.configs.recommended,
electronConfigPrettier,
eslintReact.configs['recommended-typescript'],
reactHooks.configs['recommended-latest'],
{
plugins: {
'simple-import-sort': simpleImportSort,
'unused-imports': unusedImports
},
rules: {
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-non-null-asserted-optional-chain': 'off',
'simple-import-sort/imports': 'error',
'simple-import-sort/exports': 'error',
'unused-imports/no-unused-imports': 'error',
'@eslint-react/no-prop-types': 'error',
'prettier/prettier': ['error', { endOfLine: 'auto' }]
}
},
// Configuration for ensuring compatibility with the original ESLint(8.x) rules
...[
{
rules: {
'@typescript-eslint/no-require-imports': 'off',
'@typescript-eslint/no-unused-vars': ['error', { caughtErrors: 'none' }],
'@typescript-eslint/no-unused-expressions': 'off',
'@typescript-eslint/no-empty-object-type': 'off',
'@eslint-react/hooks-extra/no-direct-set-state-in-use-effect': 'off',
'@eslint-react/web-api/no-leaked-event-listener': 'off',
'@eslint-react/web-api/no-leaked-timeout': 'off',
'@eslint-react/no-unknown-property': 'off',
'@eslint-react/no-nested-component-definitions': 'off',
'@eslint-react/dom/no-dangerously-set-innerhtml': 'off',
'@eslint-react/no-array-index-key': 'off',
'@eslint-react/no-unstable-default-props': 'off',
'@eslint-react/no-unstable-context-value': 'off',
'@eslint-react/hooks-extra/prefer-use-state-lazy-initialization': 'off',
'@eslint-react/hooks-extra/no-unnecessary-use-prefix': 'off',
'@eslint-react/no-children-to-array': 'off'
}
}
],
{
ignores: [
'node_modules/**',
'build/**',
'dist/**',
'out/**',
'local/**',
'.yarn/**',
'.gitignore',
'scripts/cloudflare-worker.js',
'src/main/integration/nutstore/sso/lib/**'
]
}
])

View File

@@ -1,11 +1,11 @@
{
"name": "CherryStudio",
"version": "1.1.17",
"version": "0.9.2",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",
"author": "support@cherry-ai.com",
"homepage": "https://github.com/CherryHQ/cherry-studio",
"author": "kangfenmao@qq.com",
"homepage": "https://github.com/kangfenmao/cherry-studio",
"workspaces": {
"packages": [
"local",
@@ -18,10 +18,16 @@
}
},
"scripts": {
"format": "prettier --write .",
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "npm run typecheck:node && npm run typecheck:web",
"start": "electron-vite preview",
"dev": "electron-vite dev",
"build:check": "yarn typecheck",
"build": "npm run typecheck && electron-vite build",
"build:check": "yarn test && yarn typecheck && yarn check:i18n",
"postinstall": "electron-builder install-app-deps",
"build:unpack": "dotenv npm run build && electron-builder --dir",
"build:win": "dotenv npm run build && electron-builder --win",
"build:win:x64": "dotenv npm run build && electron-builder --win --x64",
@@ -33,97 +39,63 @@
"build:linux:x64": "dotenv electron-vite build && electron-builder --linux --x64",
"build:npm": "node scripts/build-npm.js",
"release": "node scripts/version.js",
"publish": "yarn build:check && yarn release patch push",
"publish": "yarn release patch push",
"pulish:artifacts": "cd packages/artifacts && npm publish && cd -",
"generate:agents": "yarn workspace @cherry-studio/database agents",
"generate:icons": "electron-icon-builder --input=./build/logo.png --output=build",
"analyze:renderer": "VISUALIZER_RENDERER=true yarn build",
"analyze:main": "VISUALIZER_MAIN=true yarn build",
"typecheck": "npm run typecheck:node && npm run typecheck:web",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"check:i18n": "node scripts/check-i18n.js",
"test": "npx -y tsx --test src/**/*.test.ts",
"format": "prettier --write .",
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
"postinstall": "electron-builder install-app-deps",
"prepare": "husky"
"analyze:main": "VISUALIZER_MAIN=true yarn build"
},
"dependencies": {
"@cherrystudio/embedjs": "^0.1.28",
"@cherrystudio/embedjs-libsql": "^0.1.28",
"@cherrystudio/embedjs-loader-csv": "^0.1.28",
"@cherrystudio/embedjs-loader-image": "^0.1.28",
"@cherrystudio/embedjs-loader-markdown": "^0.1.28",
"@cherrystudio/embedjs-loader-msoffice": "^0.1.28",
"@cherrystudio/embedjs-loader-pdf": "^0.1.28",
"@cherrystudio/embedjs-loader-sitemap": "^0.1.28",
"@cherrystudio/embedjs-loader-web": "^0.1.28",
"@cherrystudio/embedjs-loader-xml": "^0.1.28",
"@cherrystudio/embedjs-openai": "^0.1.28",
"@electron-toolkit/preload": "^3.0.0",
"@electron-toolkit/utils": "^3.0.0",
"@electron/notarize": "^2.5.0",
"@google/generative-ai": "^0.24.0",
"@langchain/community": "^0.3.36",
"@notionhq/client": "^2.2.15",
"@tryfabric/martian": "^1.2.4",
"@llm-tools/embedjs": "patch:@llm-tools/embedjs@npm%3A0.1.25#~/.yarn/patches/@llm-tools-embedjs-npm-0.1.25-ec5645cf36.patch",
"@llm-tools/embedjs-libsql": "patch:@llm-tools/embedjs-libsql@npm%3A0.1.25#~/.yarn/patches/@llm-tools-embedjs-libsql-npm-0.1.25-fad000d74c.patch",
"@llm-tools/embedjs-loader-csv": "^0.1.25",
"@llm-tools/embedjs-loader-markdown": "^0.1.25",
"@llm-tools/embedjs-loader-msoffice": "^0.1.25",
"@llm-tools/embedjs-loader-pdf": "^0.1.25",
"@llm-tools/embedjs-loader-sitemap": "^0.1.25",
"@llm-tools/embedjs-loader-web": "^0.1.25",
"@llm-tools/embedjs-loader-xml": "^0.1.25",
"@llm-tools/embedjs-openai": "^0.1.25",
"@types/react-infinite-scroll-component": "^5.0.0",
"@xyflow/react": "^12.4.4",
"adm-zip": "^0.5.16",
"apache-arrow": "^18.1.0",
"docx": "^9.0.2",
"electron-log": "^5.1.5",
"electron-store": "^8.2.0",
"electron-updater": "^6.3.9",
"electron-window-state": "^5.0.3",
"epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch",
"fast-xml-parser": "^5.0.9",
"fetch-socks": "^1.3.2",
"fs-extra": "^11.2.0",
"html2canvas": "^1.4.1",
"markdown-it": "^14.1.0",
"officeparser": "^4.1.1",
"proxy-agent": "^6.5.0",
"tar": "^7.4.3",
"undici": "^7.4.0",
"webdav": "^5.8.0",
"zipread": "^1.3.3"
"tokenx": "^0.4.1",
"webdav": "4.11.4"
},
"devDependencies": {
"@agentic/exa": "^7.3.3",
"@agentic/searxng": "^7.3.3",
"@agentic/tavily": "^7.3.3",
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"@anthropic-ai/sdk": "^0.38.0",
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
"@electron-toolkit/eslint-config-ts": "^3.0.0",
"@electron-toolkit/preload": "^3.0.0",
"@anthropic-ai/sdk": "^0.24.3",
"@electron-toolkit/eslint-config-prettier": "^2.0.0",
"@electron-toolkit/eslint-config-ts": "^1.0.1",
"@electron-toolkit/tsconfig": "^1.0.1",
"@emotion/is-prop-valid": "^1.3.1",
"@eslint-react/eslint-plugin": "^1.36.1",
"@eslint/js": "^9.22.0",
"@google/genai": "^0.4.0",
"@google/generative-ai": "^0.21.0",
"@hello-pangea/dnd": "^16.6.0",
"@kangfenmao/keyv-storage": "^0.1.0",
"@modelcontextprotocol/sdk": "^1.8.0",
"@notionhq/client": "^2.2.15",
"@reduxjs/toolkit": "^2.2.5",
"@tavily/core": "patch:@tavily/core@npm%3A0.3.1#~/.yarn/patches/@tavily-core-npm-0.3.1-fe69bf2bea.patch",
"@tryfabric/martian": "^1.2.4",
"@types/adm-zip": "^0",
"@types/fs-extra": "^11",
"@types/lodash": "^4.17.5",
"@types/markdown-it": "^14",
"@types/md5": "^2.3.5",
"@types/node": "^18.19.9",
"@types/pako": "^1.0.2",
"@types/react": "^19.0.12",
"@types/react-dom": "^19.0.4",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@types/react-infinite-scroll-component": "^5.0.0",
"@types/tinycolor2": "^1",
"@vitejs/plugin-react": "^4.2.1",
"antd": "^5.22.5",
"applescript": "^1.0.0",
"axios": "^1.7.3",
"babel-plugin-styled-components": "^2.1.4",
"axios": "^1.7.9",
"browser-image-compression": "^2.0.2",
"dayjs": "^1.11.11",
"dexie": "^4.0.8",
@@ -136,23 +108,18 @@
"electron-vite": "^2.3.0",
"emittery": "^1.0.3",
"emoji-picker-element": "^1.22.1",
"eslint": "^9.22.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint": "^8.56.0",
"eslint-plugin-react": "^7.34.3",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-unused-imports": "^4.1.4",
"html-to-image": "^1.11.13",
"husky": "^9.1.7",
"eslint-plugin-unused-imports": "^4.0.0",
"i18next": "^23.11.5",
"lint-staged": "^15.5.0",
"lodash": "^4.17.21",
"mime": "^4.0.4",
"npx-scope-finder": "^1.2.0",
"openai": "patch:openai@npm%3A4.77.3#~/.yarn/patches/openai-npm-4.77.3-59c6d42e7a.patch",
"p-queue": "^8.1.0",
"prettier": "^3.5.3",
"rc-virtual-list": "^3.18.5",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"openai": "patch:openai@npm%3A4.76.2#~/.yarn/patches/openai-npm-4.76.2-8ff1374617.patch",
"prettier": "^3.2.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hotkeys-hook": "^4.6.1",
"react-i18next": "^14.1.2",
"react-infinite-scroll-component": "^6.1.0",
@@ -164,37 +131,26 @@
"redux": "^5.0.1",
"redux-persist": "^6.0.0",
"rehype-katex": "^7.0.1",
"rehype-mathjax": "^7.0.0",
"rehype-mathjax": "^6.0.0",
"rehype-raw": "^7.0.0",
"remark-cjk-friendly": "^1.1.0",
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0",
"rollup-plugin-visualizer": "^5.12.0",
"sass": "^1.77.2",
"shiki": "^1.22.2",
"string-width": "^7.2.0",
"styled-components": "^6.1.11",
"tinycolor2": "^1.6.0",
"tokenx": "^0.4.1",
"typescript": "^5.6.2",
"uuid": "^10.0.0",
"vite": "^5.0.12"
},
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0",
"react-dom": "^17.0.0 || ^18.0.0"
},
"resolutions": {
"pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch",
"@langchain/openai@npm:^0.3.16": "patch:@langchain/openai@npm%3A0.3.16#~/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch",
"@langchain/openai@npm:>=0.1.0 <0.4.0": "patch:@langchain/openai@npm%3A0.3.16#~/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch",
"openai@npm:^4.77.0": "patch:openai@npm%3A4.77.3#~/.yarn/patches/openai-npm-4.77.3-59c6d42e7a.patch",
"pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch"
"@llm-tools/embedjs-utils@npm:0.1.25": "patch:@llm-tools/embedjs-utils@npm%3A0.1.25#~/.yarn/patches/@llm-tools-embedjs-utils-npm-0.1.25-fd8fe8a193.patch"
},
"packageManager": "yarn@4.6.0",
"lint-staged": {
"*.{js,jsx,ts,tsx,cjs,mjs,cts,mts}": [
"prettier --write",
"eslint --fix"
],
"*.{json,md,yml,yaml,css,scss,html}": [
"prettier --write"
]
}
"packageManager": "yarn@4.5.0"
}

View File

@@ -1,6 +1,6 @@
{
"name": "@cherry-studio/database",
"packageManager": "yarn@4.6.0",
"packageManager": "yarn@4.3.1",
"dependencies": {
"csv-parser": "^3.0.0",
"sqlite3": "^5.1.7"

View File

@@ -2,8 +2,6 @@ export const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp']
export const videoExts = ['.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv']
export const audioExts = ['.mp3', '.wav', '.ogg', '.flac', '.aac']
export const documentExts = ['.pdf', '.docx', '.pptx', '.xlsx', '.odt', '.odp', '.ods']
export const thirdPartyApplicationExts = ['.draftsExport']
export const bookExts = ['.epub']
export const textExts = [
'.txt', // 普通文本文件
'.md', // Markdown 文件
@@ -19,10 +17,7 @@ export const textExts = [
'.ini', // 配置文件
'.log', // 日志文件
'.rtf', // 富文本格式文件
'.org', // org-mode 文件
'.wiki', // VimWiki 文件
'.tex', // LaTeX 文件
'.bib', // BibTeX 文件
'.srt', // 字幕文件
'.xhtml', // XHTML 文件
'.nfo', // 信息文件(主要用于场景发布)
@@ -38,7 +33,6 @@ export const textExts = [
'.bat', // Windows 批处理文件
'.sh', // Unix/Linux Shell 脚本文件
'.py', // Python 脚本文件
'.ipynb', // Jupyter 笔记本格式
'.rb', // Ruby 脚本文件
'.pl', // Perl 脚本文件
'.sql', // SQL 脚本文件
@@ -88,50 +82,12 @@ export const textExts = [
'.ctp', // CakePHP 视图文件
'.cfm', // ColdFusion 标记语言文件
'.cfc', // ColdFusion 组件文件
'.m', // Objective-C 或 MATLAB 源文件
'.m', // Objective-C 源文件
'.mm', // Objective-C++ 源文件
'.gradle', // Gradle 构建文件
'.groovy', // Gradle 构建文件
'.kts', // Kotlin Script 文件
'.java', // Java 代码文件
'.cs', // C# 代码文件
'.cpp', // C++ 代码文件
'.c', // C++ 代码文件
'.h', // C++ 头文件
'.hpp', // C++ 头文件
'.cc', // C++ 源文件
'.cxx', // C++ 源文件
'.cppm', // C++20 模块接口文件
'.ipp', // 模板实现文件
'.ixx', // C++20 模块实现文件
'.f90', // Fortran 90 源文件
'.f', // Fortran 固定格式源代码文件
'.f03', // Fortran 2003+ 源代码文件
'.ahk', // AutoHotKey 语言文件
'.tcl', // Tcl 脚本
'.do', // Questa 或 Modelsim Tcl 脚本
'.v', // Verilog 源文件
'.sv', // SystemVerilog 源文件
'.svh', // SystemVerilog 头文件
'.vhd', // VHDL 源文件
'.vhdl', // VHDL 源文件
'.lef', // Library Exchange Format
'.def', // Design Exchange Format
'.edif', // Electronic Design Interchange Format
'.sdf', // Standard Delay Format
'.sdc', // Synopsys Design Constraints
'.xdc', // Xilinx Design Constraints
'.rpt', // 报告文件
'.lisp', // Lisp 脚本
'.il', // Cadence SKILL 脚本
'.ils', // Cadence SKILL++ 脚本
'.sp', // SPICE netlist 文件
'.spi', // SPICE netlist 文件
'.cir', // SPICE netlist 文件
'.net', // SPICE netlist 文件
'.scs', // Spectre netlist 文件
'.asc', // LTspice netlist schematic 文件
'.tf' // Technology File
'.java' // Java 代码文件
]
export const ZOOM_SHORTCUTS = [

View File

@@ -1 +0,0 @@
export const NUTSTORE_HOST = 'https://dav.jianguoyun.com/dav'

View File

@@ -1,6 +0,0 @@
export type LoaderReturn = {
entriesAdded: number
uniqueId: string
uniqueIds: string[]
loaderType: string
}

View File

@@ -1,35 +0,0 @@
const https = require('https')
const fs = require('fs')
/**
* Downloads a file from a URL with redirect handling
* @param {string} url The URL to download from
* @param {string} destinationPath The path to save the file to
* @returns {Promise<void>} Promise that resolves when download is complete
*/
async function downloadWithRedirects(url, destinationPath) {
return new Promise((resolve, reject) => {
const request = (url) => {
https
.get(url, (response) => {
if (response.statusCode == 301 || response.statusCode == 302) {
request(response.headers.location)
return
}
if (response.statusCode !== 200) {
reject(new Error(`Download failed: ${response.statusCode} ${response.statusMessage}`))
return
}
const file = fs.createWriteStream(destinationPath)
response.pipe(file)
file.on('finish', () => resolve())
})
.on('error', (err) => {
reject(err)
})
}
request(url)
})
}
module.exports = { downloadWithRedirects }

View File

@@ -1,171 +0,0 @@
const fs = require('fs')
const path = require('path')
const os = require('os')
const { execSync } = require('child_process')
const AdmZip = require('adm-zip')
const { downloadWithRedirects } = require('./download')
// Base URL for downloading bun binaries
const BUN_RELEASE_BASE_URL = 'https://github.com/oven-sh/bun/releases/download'
const DEFAULT_BUN_VERSION = '1.2.5' // Default fallback version
// Mapping of platform+arch to binary package name
const BUN_PACKAGES = {
'darwin-arm64': 'bun-darwin-aarch64.zip',
'darwin-x64': 'bun-darwin-x64.zip',
'win32-x64': 'bun-windows-x64.zip',
'win32-x64-baseline': 'bun-windows-x64-baseline.zip',
'linux-x64': 'bun-linux-x64.zip',
'linux-x64-baseline': 'bun-linux-x64-baseline.zip',
'linux-arm64': 'bun-linux-aarch64.zip',
// MUSL variants
'linux-musl-x64': 'bun-linux-x64-musl.zip',
'linux-musl-x64-baseline': 'bun-linux-x64-musl-baseline.zip',
'linux-musl-arm64': 'bun-linux-aarch64-musl.zip'
}
/**
* Downloads and extracts the bun binary for the specified platform and architecture
* @param {string} platform Platform to download for (e.g., 'darwin', 'win32', 'linux')
* @param {string} arch Architecture to download for (e.g., 'x64', 'arm64')
* @param {string} version Version of bun to download
* @param {boolean} isMusl Whether to use MUSL variant for Linux
* @param {boolean} isBaseline Whether to use baseline variant
*/
async function downloadBunBinary(platform, arch, version = DEFAULT_BUN_VERSION, isMusl = false, isBaseline = false) {
let platformKey = isMusl ? `${platform}-musl-${arch}` : `${platform}-${arch}`
if (isBaseline) {
platformKey += '-baseline'
}
const packageName = BUN_PACKAGES[platformKey]
if (!packageName) {
console.error(`No binary available for ${platformKey}`)
return false
}
// Create output directory structure
const binDir = path.join(os.homedir(), '.cherrystudio', 'bin')
// Ensure directories exist
fs.mkdirSync(binDir, { recursive: true })
// Download URL for the specific binary
const downloadUrl = `${BUN_RELEASE_BASE_URL}/bun-v${version}/${packageName}`
const tempdir = os.tmpdir()
// Create a temporary file for the downloaded binary
const tempFilename = path.join(tempdir, packageName)
try {
console.log(`Downloading bun ${version} for ${platformKey}...`)
console.log(`URL: ${downloadUrl}`)
// Use the new download function
await downloadWithRedirects(downloadUrl, tempFilename)
// Extract the zip file using adm-zip
console.log(`Extracting ${packageName} to ${binDir}...`)
const zip = new AdmZip(tempFilename)
zip.extractAllTo(tempdir, true)
// Move files using Node.js fs
const sourceDir = path.join(tempdir, packageName.split('.')[0])
const files = fs.readdirSync(sourceDir)
for (const file of files) {
const sourcePath = path.join(sourceDir, file)
const destPath = path.join(binDir, file)
fs.copyFileSync(sourcePath, destPath)
fs.unlinkSync(sourcePath)
// Set executable permissions for non-Windows platforms
if (platform !== 'win32') {
try {
// 755 permission: rwxr-xr-x
fs.chmodSync(destPath, '755')
} catch (error) {
console.warn(`Warning: Failed to set executable permissions: ${error.message}`)
}
}
}
// Clean up
fs.unlinkSync(tempFilename)
fs.rmSync(sourceDir, { recursive: true })
console.log(`Successfully installed bun ${version} for ${platformKey}`)
return true
} catch (error) {
console.error(`Error installing bun for ${platformKey}: ${error.message}`)
// Clean up temporary file if it exists
if (fs.existsSync(tempFilename)) {
fs.unlinkSync(tempFilename)
}
// Check if binDir is empty and remove it if so
try {
const files = fs.readdirSync(binDir)
if (files.length === 0) {
fs.rmSync(binDir, { recursive: true })
console.log(`Removed empty directory: ${binDir}`)
}
} catch (cleanupError) {
console.warn(`Warning: Failed to clean up directory: ${cleanupError.message}`)
}
return false
}
}
/**
* Detects current platform and architecture
*/
function detectPlatformAndArch() {
const platform = os.platform()
const arch = os.arch()
const isMusl = platform === 'linux' && detectIsMusl()
const isBaseline = platform === 'win32'
return { platform, arch, isMusl, isBaseline }
}
/**
* Attempts to detect if running on MUSL libc
*/
function detectIsMusl() {
try {
// Simple check for Alpine Linux which uses MUSL
const output = execSync('cat /etc/os-release').toString()
return output.toLowerCase().includes('alpine')
} catch (error) {
return false
}
}
/**
* Main function to install bun
*/
async function installBun() {
// Get the latest version if no specific version is provided
const version = DEFAULT_BUN_VERSION
console.log(`Using bun version: ${version}`)
const { platform, arch, isMusl, isBaseline } = detectPlatformAndArch()
console.log(
`Installing bun ${version} for ${platform}-${arch}${isMusl ? ' (MUSL)' : ''}${isBaseline ? ' (baseline)' : ''}...`
)
await downloadBunBinary(platform, arch, version, isMusl, isBaseline)
}
// Run the installation
installBun()
.then(() => {
console.log('Installation successful')
process.exit(0)
})
.catch((error) => {
console.error('Installation failed:', error)
process.exit(1)
})

View File

@@ -1,181 +0,0 @@
const fs = require('fs')
const path = require('path')
const os = require('os')
const { execSync } = require('child_process')
const tar = require('tar')
const AdmZip = require('adm-zip')
const { downloadWithRedirects } = require('./download')
// Base URL for downloading uv binaries
const UV_RELEASE_BASE_URL = 'https://github.com/astral-sh/uv/releases/download'
const DEFAULT_UV_VERSION = '0.6.6'
// Mapping of platform+arch to binary package name
const UV_PACKAGES = {
'darwin-arm64': 'uv-aarch64-apple-darwin.tar.gz',
'darwin-x64': 'uv-x86_64-apple-darwin.tar.gz',
'win32-arm64': 'uv-aarch64-pc-windows-msvc.zip',
'win32-ia32': 'uv-i686-pc-windows-msvc.zip',
'win32-x64': 'uv-x86_64-pc-windows-msvc.zip',
'linux-arm64': 'uv-aarch64-unknown-linux-gnu.tar.gz',
'linux-ia32': 'uv-i686-unknown-linux-gnu.tar.gz',
'linux-ppc64': 'uv-powerpc64-unknown-linux-gnu.tar.gz',
'linux-ppc64le': 'uv-powerpc64le-unknown-linux-gnu.tar.gz',
'linux-s390x': 'uv-s390x-unknown-linux-gnu.tar.gz',
'linux-x64': 'uv-x86_64-unknown-linux-gnu.tar.gz',
'linux-armv7l': 'uv-armv7-unknown-linux-gnueabihf.tar.gz',
// MUSL variants
'linux-musl-arm64': 'uv-aarch64-unknown-linux-musl.tar.gz',
'linux-musl-ia32': 'uv-i686-unknown-linux-musl.tar.gz',
'linux-musl-x64': 'uv-x86_64-unknown-linux-musl.tar.gz',
'linux-musl-armv6l': 'uv-arm-unknown-linux-musleabihf.tar.gz',
'linux-musl-armv7l': 'uv-armv7-unknown-linux-musleabihf.tar.gz'
}
/**
* Downloads and extracts the uv binary for the specified platform and architecture
* @param {string} platform Platform to download for (e.g., 'darwin', 'win32', 'linux')
* @param {string} arch Architecture to download for (e.g., 'x64', 'arm64')
* @param {string} version Version of uv to download
* @param {boolean} isMusl Whether to use MUSL variant for Linux
*/
async function downloadUvBinary(platform, arch, version = DEFAULT_UV_VERSION, isMusl = false) {
const platformKey = isMusl ? `${platform}-musl-${arch}` : `${platform}-${arch}`
const packageName = UV_PACKAGES[platformKey]
if (!packageName) {
console.error(`No binary available for ${platformKey}`)
return false
}
// Create output directory structure
const binDir = path.join(os.homedir(), '.cherrystudio', 'bin')
// Ensure directories exist
fs.mkdirSync(binDir, { recursive: true })
// Download URL for the specific binary
const downloadUrl = `${UV_RELEASE_BASE_URL}/${version}/${packageName}`
const tempdir = os.tmpdir()
const tempFilename = path.join(tempdir, packageName)
try {
console.log(`Downloading uv ${version} for ${platformKey}...`)
console.log(`URL: ${downloadUrl}`)
await downloadWithRedirects(downloadUrl, tempFilename)
console.log(`Extracting ${packageName} to ${binDir}...`)
// 根据文件扩展名选择解压方法
if (packageName.endsWith('.zip')) {
// 使用 adm-zip 处理 zip 文件
const zip = new AdmZip(tempFilename)
zip.extractAllTo(binDir, true)
fs.unlinkSync(tempFilename)
console.log(`Successfully installed uv ${version} for ${platform}-${arch}`)
return true
} else {
// tar.gz 文件的处理保持不变
await tar.x({
file: tempFilename,
cwd: tempdir,
z: true
})
// Move files using Node.js fs
const sourceDir = path.join(tempdir, packageName.split('.')[0])
const files = fs.readdirSync(sourceDir)
for (const file of files) {
const sourcePath = path.join(sourceDir, file)
const destPath = path.join(binDir, file)
fs.copyFileSync(sourcePath, destPath)
fs.unlinkSync(sourcePath)
// Set executable permissions for non-Windows platforms
if (platform !== 'win32') {
try {
fs.chmodSync(destPath, '755')
} catch (error) {
console.warn(`Warning: Failed to set executable permissions: ${error.message}`)
}
}
}
// Clean up
fs.unlinkSync(tempFilename)
fs.rmSync(sourceDir, { recursive: true })
}
console.log(`Successfully installed uv ${version} for ${platform}-${arch}`)
return true
} catch (error) {
console.error(`Error installing uv for ${platformKey}: ${error.message}`)
if (fs.existsSync(tempFilename)) {
fs.unlinkSync(tempFilename)
}
// Check if binDir is empty and remove it if so
try {
const files = fs.readdirSync(binDir)
if (files.length === 0) {
fs.rmSync(binDir, { recursive: true })
console.log(`Removed empty directory: ${binDir}`)
}
} catch (cleanupError) {
console.warn(`Warning: Failed to clean up directory: ${cleanupError.message}`)
}
return false
}
}
/**
* Detects current platform and architecture
*/
function detectPlatformAndArch() {
const platform = os.platform()
const arch = os.arch()
const isMusl = platform === 'linux' && detectIsMusl()
return { platform, arch, isMusl }
}
/**
* Attempts to detect if running on MUSL libc
*/
function detectIsMusl() {
try {
// Simple check for Alpine Linux which uses MUSL
const output = execSync('cat /etc/os-release').toString()
return output.toLowerCase().includes('alpine')
} catch (error) {
return false
}
}
/**
* Main function to install uv
*/
async function installUv() {
// Get the latest version if no specific version is provided
const version = DEFAULT_UV_VERSION
console.log(`Using uv version: ${version}`)
const { platform, arch, isMusl } = detectPlatformAndArch()
console.log(`Installing uv ${version} for ${platform}-${arch}${isMusl ? ' (MUSL)' : ''}...`)
await downloadUvBinary(platform, arch, version, isMusl)
}
// Run the installation
installUv()
.then(() => {
console.log('Installation successful')
process.exit(0)
})
.catch((error) => {
console.error('Installation failed:', error)
process.exit(1)
})

View File

@@ -1,117 +0,0 @@
import Cocoa
import Foundation
class TextSelectionObserver: NSObject {
let workspace = NSWorkspace.shared
var lastSelectedText: String?
override init() {
super.init()
//
let observer = NSWorkspace.shared.notificationCenter
observer.addObserver(
self,
selector: #selector(handleSelectionChange),
name: NSWorkspace.didActivateApplicationNotification,
object: nil
)
//
var axObserver: AXObserver?
let error = AXObserverCreate(getpid(), { observer, element, notification, userData in
let selfPointer = userData!.load(as: TextSelectionObserver.self)
selfPointer.checkSelectedText()
}, &axObserver)
if error == .success, let axObserver = axObserver {
CFRunLoopAddSource(
RunLoop.main.getCFRunLoop(),
AXObserverGetRunLoopSource(axObserver),
.defaultMode
)
//
updateActiveAppObserver(axObserver)
}
}
@objc func handleSelectionChange(_ notification: Notification) {
//
var axObserver: AXObserver?
let error = AXObserverCreate(getpid(), { _, _, _, _ in }, &axObserver)
if error == .success, let axObserver = axObserver {
updateActiveAppObserver(axObserver)
}
}
func updateActiveAppObserver(_ axObserver: AXObserver) {
guard let app = workspace.frontmostApplication else { return }
let pid = app.processIdentifier
let element = AXUIElementCreateApplication(pid)
//
AXObserverAddNotification(
axObserver,
element,
kAXSelectedTextChangedNotification as CFString,
UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque())
)
}
func checkSelectedText() {
if let text = getSelectedText() {
if text.count > 0 && text != lastSelectedText {
print(text)
fflush(stdout)
lastSelectedText = text
}
}
}
func getSelectedText() -> String? {
guard let app = NSWorkspace.shared.frontmostApplication else { return nil }
let pid = app.processIdentifier
let axApp = AXUIElementCreateApplication(pid)
var focusedElement: AnyObject?
// Get focused element
let result = AXUIElementCopyAttributeValue(axApp, kAXFocusedUIElementAttribute as CFString, &focusedElement)
guard result == .success else { return nil }
// Try different approaches to get selected text
var selectedText: AnyObject?
// First try: Direct selected text
var textResult = AXUIElementCopyAttributeValue(focusedElement as! AXUIElement, kAXSelectedTextAttribute as CFString, &selectedText)
// Second try: Selected text in text area
if textResult != .success {
var selectedTextRange: AnyObject?
textResult = AXUIElementCopyAttributeValue(focusedElement as! AXUIElement, kAXSelectedTextRangeAttribute as CFString, &selectedTextRange)
if textResult == .success {
textResult = AXUIElementCopyAttributeValue(focusedElement as! AXUIElement, kAXValueAttribute as CFString, &selectedText)
}
}
// Third try: Get selected text from parent element
if textResult != .success {
var parent: AnyObject?
if AXUIElementCopyAttributeValue(focusedElement as! AXUIElement, kAXParentAttribute as CFString, &parent) == .success {
textResult = AXUIElementCopyAttributeValue(parent as! AXUIElement, kAXSelectedTextAttribute as CFString, &selectedText)
}
}
guard textResult == .success, let text = selectedText as? String else { return nil }
return text
}
}
let observer = TextSelectionObserver()
signal(SIGINT) { _ in
exit(0)
}
RunLoop.main.run()

View File

@@ -1,104 +0,0 @@
'use strict'
Object.defineProperty(exports, '__esModule', { value: true })
var fs = require('fs')
var path = require('path')
var translationsDir = path.join(__dirname, '../src/renderer/src/i18n/locales')
var baseLocale = 'zh-CN'
var baseFileName = ''.concat(baseLocale, '.json')
var baseFilePath = path.join(translationsDir, baseFileName)
/**
* 递归同步 target 对象,使其与 template 对象保持一致
* 1. 如果 template 中存在 target 中缺少的 key则添加'[to be translated]'
* 2. 如果 target 中存在 template 中不存在的 key则删除
* 3. 对于子对象,递归同步
*
* @param target 目标对象(需要更新的语言对象)
* @param template 主模板对象(中文)
* @returns 返回是否对 target 进行了更新
*/
function syncRecursively(target, template) {
var isUpdated = false
// 添加 template 中存在但 target 中缺少的 key
for (var key in template) {
if (!(key in target)) {
target[key] =
typeof template[key] === 'object' && template[key] !== null ? {} : '[to be translated]:'.concat(template[key])
console.log('\u6DFB\u52A0\u65B0\u5C5E\u6027\uFF1A'.concat(key))
isUpdated = true
}
if (typeof template[key] === 'object' && template[key] !== null) {
if (typeof target[key] !== 'object' || target[key] === null) {
target[key] = {}
isUpdated = true
}
// 递归同步子对象
var childUpdated = syncRecursively(target[key], template[key])
if (childUpdated) {
isUpdated = true
}
}
}
// 删除 target 中存在但 template 中没有的 key
for (var targetKey in target) {
if (!(targetKey in template)) {
console.log('\u79FB\u9664\u591A\u4F59\u5C5E\u6027\uFF1A'.concat(targetKey))
delete target[targetKey]
isUpdated = true
}
}
return isUpdated
}
function syncTranslations() {
if (!fs.existsSync(baseFilePath)) {
console.error(
'\u4E3B\u6A21\u677F\u6587\u4EF6 '.concat(
baseFileName,
' \u4E0D\u5B58\u5728\uFF0C\u8BF7\u68C0\u67E5\u8DEF\u5F84\u6216\u6587\u4EF6\u540D\u3002'
)
)
return
}
var baseContent = fs.readFileSync(baseFilePath, 'utf-8')
var baseJson = {}
try {
baseJson = JSON.parse(baseContent)
} catch (error) {
console.error('\u89E3\u6790 '.concat(baseFileName, ' \u51FA\u9519:'), error)
return
}
var files = fs.readdirSync(translationsDir).filter(function (file) {
return file.endsWith('.json') && file !== baseFileName
})
for (var _i = 0, files_1 = files; _i < files_1.length; _i++) {
var file = files_1[_i]
var filePath = path.join(translationsDir, file)
var targetJson = {}
try {
var fileContent = fs.readFileSync(filePath, 'utf-8')
targetJson = JSON.parse(fileContent)
} catch (error) {
console.error(
'\u89E3\u6790 '.concat(
file,
' \u51FA\u9519\uFF0C\u8DF3\u8FC7\u6B64\u6587\u4EF6\u3002\u9519\u8BEF\u4FE1\u606F:'
),
error
)
continue
}
var isUpdated = syncRecursively(targetJson, baseJson)
if (isUpdated) {
try {
fs.writeFileSync(filePath, JSON.stringify(targetJson, null, 2), 'utf-8')
console.log(
'\u6587\u4EF6 '.concat(file, ' \u5DF2\u66F4\u65B0\u540C\u6B65\u4E3B\u6A21\u677F\u7684\u5185\u5BB9\u3002')
)
} catch (error) {
console.error('\u5199\u5165 '.concat(file, ' \u51FA\u9519:'), error)
}
} else {
console.log('\u6587\u4EF6 '.concat(file, ' \u65E0\u9700\u66F4\u65B0\u3002'))
}
}
}
syncTranslations()

View File

@@ -1,98 +0,0 @@
import * as fs from 'fs'
import * as path from 'path'
const translationsDir = path.join(__dirname, '../src/renderer/src/i18n/locales')
const baseLocale = 'zh-CN'
const baseFileName = `${baseLocale}.json`
const baseFilePath = path.join(translationsDir, baseFileName)
/**
* 递归同步 target 对象,使其与 template 对象保持一致
* 1. 如果 template 中存在 target 中缺少的 key则添加'[to be translated]'
* 2. 如果 target 中存在 template 中不存在的 key则删除
* 3. 对于子对象,递归同步
*
* @param target 目标对象(需要更新的语言对象)
* @param template 主模板对象(中文)
* @returns 返回是否对 target 进行了更新
*/
function syncRecursively(target: any, template: any): boolean {
let isUpdated = false
// 添加 template 中存在但 target 中缺少的 key
for (const key in template) {
if (!(key in target)) {
target[key] =
typeof template[key] === 'object' && template[key] !== null ? {} : `[to be translated]:${template[key]}`
console.log(`添加新属性:${key}`)
isUpdated = true
}
if (typeof template[key] === 'object' && template[key] !== null) {
if (typeof target[key] !== 'object' || target[key] === null) {
target[key] = {}
isUpdated = true
}
// 递归同步子对象
const childUpdated = syncRecursively(target[key], template[key])
if (childUpdated) {
isUpdated = true
}
}
}
// 删除 target 中存在但 template 中没有的 key
for (const targetKey in target) {
if (!(targetKey in template)) {
console.log(`移除多余属性:${targetKey}`)
delete target[targetKey]
isUpdated = true
}
}
return isUpdated
}
function syncTranslations() {
if (!fs.existsSync(baseFilePath)) {
console.error(`主模板文件 ${baseFileName} 不存在,请检查路径或文件名`)
return
}
const baseContent = fs.readFileSync(baseFilePath, 'utf-8')
let baseJson: Record<string, any> = {}
try {
baseJson = JSON.parse(baseContent)
} catch (error) {
console.error(`解析 ${baseFileName} 出错:`, error)
return
}
const files = fs.readdirSync(translationsDir).filter((file) => file.endsWith('.json') && file !== baseFileName)
for (const file of files) {
const filePath = path.join(translationsDir, file)
let targetJson: Record<string, any> = {}
try {
const fileContent = fs.readFileSync(filePath, 'utf-8')
targetJson = JSON.parse(fileContent)
} catch (error) {
console.error(`解析 ${file} 出错,跳过此文件。错误信息:`, error)
continue
}
const isUpdated = syncRecursively(targetJson, baseJson)
if (isUpdated) {
try {
fs.writeFileSync(filePath, JSON.stringify(targetJson, null, 2) + '\n', 'utf-8')
console.log(`文件 ${file} 已更新同步主模板的内容`)
} catch (error) {
console.error(`写入 ${file} 出错:`, error)
}
} else {
console.log(`文件 ${file} 无需更新`)
}
}
}
syncTranslations()

View File

@@ -1,130 +0,0 @@
/**
* OCOOL_API_KEY=sk-abcxxxxxxxxxxxxxxxxxxxxxxx123 ts-node scripts/update-i18n.ts
*/
// OCOOL API KEY
const OCOOL_API_KEY = process.env.OCOOL_API_KEY
const INDEX = [
// 语言的名称 代码 用来翻译的模型
{ name: 'France', code: 'fr-fr', model: 'qwen2.5-32b-instruct' },
{ name: 'Spanish', code: 'es-es', model: 'qwen2.5-32b-instruct' },
{ name: 'Portuguese', code: 'pt-pt', model: 'qwen2.5-72b-instruct' },
{ name: 'Greek', code: 'el-gr', model: 'qwen-turbo' }
]
const fs = require('fs')
import OpenAI from 'openai'
const zh = JSON.parse(fs.readFileSync('src/renderer/src/i18n/locales/zh-cn.json', 'utf8')) as object
const openai = new OpenAI({
apiKey: OCOOL_API_KEY,
baseURL: 'https://one.ocoolai.com/v1'
})
// 递归遍历翻译
async function translate(zh: object, obj: object, target: string, model: string, updateFile) {
const texts: { [key: string]: string } = {}
for (const e in zh) {
if (typeof zh[e] == 'object') {
// 遍历下一层
if (!obj[e] || typeof obj[e] != 'object') obj[e] = {}
await translate(zh[e], obj[e], target, model, updateFile)
} else {
// 加入到本层待翻译列表
if (!obj[e] || typeof obj[e] != 'string') {
texts[e] = zh[e]
}
}
}
if (Object.keys(texts).length > 0) {
const completion = await openai.chat.completions.create({
model: model,
response_format: { type: 'json_object' },
messages: [
{
role: 'user',
content: `
You are a robot specifically designed for translation tasks. As a model that has been extensively fine-tuned on Russian language corpora, you are proficient in using the Russian language.
Now, please output the translation based on the input content. The input will include both Chinese and English key values, and you should output the corresponding key values in the Russian language.
When translating, ensure that no key value is omitted, and maintain the accuracy and fluency of the translation. Pay attention to the capitalization rules in the output to match the source text, and especially pay attention to whether to capitalize the first letter of each word except for prepositions. For strings containing \`{{value}}\`, ensure that the format is not disrupted.
Output in JSON.
######################################################
INPUT
######################################################
${JSON.stringify({
confirm: '确定要备份数据吗?',
select_model: '选择模型',
title: '文件',
deeply_thought: '已深度思考(用时 {{secounds}} 秒)'
})}
######################################################
MAKE SURE TO OUTPUT IN Russian. DO NOT OUTPUT IN UNSPECIFIED LANGUAGE.
######################################################
`
},
{
role: 'assistant',
content: JSON.stringify({
confirm: 'Подтвердите резервное копирование данных?',
select_model: 'Выберите Модель',
title: 'Файл',
deeply_thought: 'Глубоко продумано (заняло {{seconds}} секунд)'
})
},
{
role: 'user',
content: `
You are a robot specifically designed for translation tasks. As a model that has been extensively fine-tuned on ${target} language corpora, you are proficient in using the ${target} language.
Now, please output the translation based on the input content. The input will include both Chinese and English key values, and you should output the corresponding key values in the ${target} language.
When translating, ensure that no key value is omitted, and maintain the accuracy and fluency of the translation. Pay attention to the capitalization rules in the output to match the source text, and especially pay attention to whether to capitalize the first letter of each word except for prepositions. For strings containing \`{{value}}\`, ensure that the format is not disrupted.
Output in JSON.
######################################################
INPUT
######################################################
${JSON.stringify(texts)}
######################################################
MAKE SURE TO OUTPUT IN ${target}. DO NOT OUTPUT IN UNSPECIFIED LANGUAGE.
######################################################
`
}
]
})
// 添加翻译后的键值,并打印错译漏译内容
try {
const result = JSON.parse(completion.choices[0].message.content!)
for (const e in texts) {
if (result[e] && typeof result[e] === 'string') {
obj[e] = result[e]
} else {
console.log('[warning]', `missing value "${e}" in ${target} translation`)
}
}
} catch (e) {
console.log('[error]', e)
for (const e in texts) {
console.log('[warning]', `missing value "${e}" in ${target} translation`)
}
}
}
// 删除多余的键值
for (const e in obj) {
if (!zh[e]) {
delete obj[e]
}
}
// 更新文件
updateFile()
}
;(async () => {
for (const { name, code, model } of INDEX) {
const obj = fs.existsSync(`src/renderer/src/i18n/translate/${code}.json`)
? JSON.parse(fs.readFileSync(`src/renderer/src/i18n/translate/${code}.json`, 'utf8'))
: {}
await translate(zh, obj, name, model, () => {
fs.writeFileSync(`src/renderer/src/i18n/translate/${code}.json`, JSON.stringify(obj, null, 2), 'utf8')
})
}
})()

View File

@@ -12,12 +12,12 @@ export const DATA_PATH = getDataPath()
export const titleBarOverlayDark = {
height: 40,
color: 'rgba(0,0,0,0)',
color: '#00000000',
symbolColor: '#ffffff'
}
export const titleBarOverlayLight = {
height: 40,
color: 'rgba(255,255,255,0)',
color: '#00000000',
symbolColor: '#000000'
}

View File

@@ -1,4 +1,3 @@
export const isMac = process.platform === 'darwin'
export const isWin = process.platform === 'win32'
export const isLinux = process.platform === 'linux'
export const isDev = process.env.NODE_ENV === 'development'

View File

@@ -1,24 +0,0 @@
import type { BaseEmbeddings } from '@cherrystudio/embedjs-interfaces'
import { KnowledgeBaseParams } from '@types'
import EmbeddingsFactory from './EmbeddingsFactory'
export default class Embeddings {
private sdk: BaseEmbeddings
constructor({ model, apiKey, apiVersion, baseURL, dimensions }: KnowledgeBaseParams) {
this.sdk = EmbeddingsFactory.create({ model, apiKey, apiVersion, baseURL, dimensions } as KnowledgeBaseParams)
}
public async init(): Promise<void> {
return this.sdk.init()
}
public async getDimensions(): Promise<number> {
return this.sdk.getDimensions()
}
public async embedDocuments(texts: string[]): Promise<number[][]> {
return this.sdk.embedDocuments(texts)
}
public async embedQuery(text: string): Promise<number[]> {
return this.sdk.embedQuery(text)
}
}

View File

@@ -1,38 +0,0 @@
import type { BaseEmbeddings } from '@cherrystudio/embedjs-interfaces'
import { OpenAiEmbeddings } from '@cherrystudio/embedjs-openai'
import { AzureOpenAiEmbeddings } from '@cherrystudio/embedjs-openai/src/azure-openai-embeddings'
import { getInstanceName } from '@main/utils'
import { KnowledgeBaseParams } from '@types'
import VoyageEmbeddings from './VoyageEmbeddings'
export default class EmbeddingsFactory {
static create({ model, apiKey, apiVersion, baseURL, dimensions }: KnowledgeBaseParams): BaseEmbeddings {
const batchSize = 10
if (model.includes('voyage')) {
return new VoyageEmbeddings({
modelName: model,
apiKey,
outputDimension: dimensions,
batchSize: 8
})
}
if (apiVersion !== undefined) {
return new AzureOpenAiEmbeddings({
azureOpenAIApiKey: apiKey,
azureOpenAIApiVersion: apiVersion,
azureOpenAIApiDeploymentName: model,
azureOpenAIApiInstanceName: getInstanceName(baseURL),
dimensions,
batchSize
})
}
return new OpenAiEmbeddings({
model,
apiKey,
dimensions,
batchSize,
configuration: { baseURL }
})
}
}

View File

@@ -1,30 +0,0 @@
import { BaseEmbeddings } from '@cherrystudio/embedjs-interfaces'
import { VoyageEmbeddings as _VoyageEmbeddings } from '@langchain/community/embeddings/voyage'
export default class VoyageEmbeddings extends BaseEmbeddings {
private model: _VoyageEmbeddings
constructor(private readonly configuration?: ConstructorParameters<typeof _VoyageEmbeddings>[0]) {
super()
if (!this.configuration) this.configuration = {}
if (!this.configuration.modelName) this.configuration.modelName = 'voyage-3'
if (!this.configuration.outputDimension) {
throw new Error('You need to pass in the optional dimensions parameter for this model')
}
this.model = new _VoyageEmbeddings(this.configuration)
}
override async getDimensions(): Promise<number> {
if (!this.configuration?.outputDimension) {
throw new Error('You need to pass in the optional dimensions parameter for this model')
}
return this.configuration?.outputDimension
}
override async embedDocuments(texts: string[]): Promise<number[][]> {
return this.model.embedDocuments(texts)
}
override async embedQuery(text: string): Promise<number[]> {
return this.model.embedQuery(text)
}
}

View File

@@ -1,14 +1,12 @@
import { electronApp, optimizer } from '@electron-toolkit/utils'
import { replaceDevtoolsFont } from '@main/utils/windowUtil'
import { app, ipcMain } from 'electron'
import installExtension, { REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } from 'electron-devtools-installer'
import { app, BrowserWindow } from 'electron'
import installExtension, { REDUX_DEVTOOLS } from 'electron-devtools-installer'
import { registerIpc } from './ipc'
import { configManager } from './services/ConfigManager'
import { CHERRY_STUDIO_PROTOCOL, handleProtocolUrl, registerProtocolClient } from './services/ProtocolClient'
import { registerShortcuts } from './services/ShortcutService'
import { TrayService } from './services/TrayService'
import { windowService } from './services/WindowService'
import { updateUserDataPath } from './utils/upgrade'
// Check for single instance lock
if (!app.requestSingleInstanceLock()) {
@@ -18,23 +16,19 @@ if (!app.requestSingleInstanceLock()) {
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.whenReady().then(async () => {
await updateUserDataPath()
// Set app user model id for windows
electronApp.setAppUserModelId(import.meta.env.VITE_MAIN_BUNDLE_ID || 'com.kangfenmao.CherryStudio')
// Mac: Hide dock icon before window creation when launch to tray is set
const isLaunchToTray = configManager.getLaunchToTray()
if (isLaunchToTray) {
app.dock?.hide()
}
const mainWindow = windowService.createMainWindow()
new TrayService()
app.on('activate', function () {
const mainWindow = windowService.getMainWindow()
if (!mainWindow || mainWindow.isDestroyed()) {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (BrowserWindow.getAllWindows().length === 0) {
windowService.createMainWindow()
} else {
windowService.showMainWindow()
@@ -45,42 +39,21 @@ if (!app.requestSingleInstanceLock()) {
registerIpc(mainWindow, app)
replaceDevtoolsFont(mainWindow)
if (process.env.NODE_ENV === 'development') {
installExtension([REDUX_DEVTOOLS, REACT_DEVELOPER_TOOLS])
installExtension(REDUX_DEVTOOLS)
.then((name) => console.log(`Added Extension: ${name}`))
.catch((err) => console.log('An error occurred: ', err))
}
ipcMain.handle('system:getDeviceType', () => {
return process.platform === 'darwin' ? 'mac' : process.platform === 'win32' ? 'windows' : 'linux'
})
})
registerProtocolClient(app)
// macOS specific: handle protocol when app is already running
app.on('open-url', (event, url) => {
event.preventDefault()
handleProtocolUrl(url)
})
registerProtocolClient(app)
// macOS specific: handle protocol when app is already running
app.on('open-url', (event, url) => {
event.preventDefault()
handleProtocolUrl(url)
})
// Listen for second instance
app.on('second-instance', (_event, argv) => {
windowService.showMainWindow()
// Protocol handler for Windows/Linux
// The commandLine is an array of strings where the last item might be the URL
const url = argv.find((arg) => arg.startsWith(CHERRY_STUDIO_PROTOCOL + '://'))
if (url) handleProtocolUrl(url)
app.on('second-instance', () => {
const mainWindow = BrowserWindow.getAllWindows()[0]
if (mainWindow) {
mainWindow.isMinimized() && mainWindow.restore()
mainWindow.show()
mainWindow.focus()
}
})
app.on('browser-window-created', (_, window) => {

View File

@@ -1,8 +0,0 @@
declare function decrypt(app: string, s: string): string;
interface Secret {
app: string;
}
declare function createOAuthUrl(secret: Secret): string;
export { type Secret, createOAuthUrl, decrypt };

File diff suppressed because one or more lines are too long

View File

@@ -1,126 +1,61 @@
import fs from 'node:fs'
import path from 'node:path'
import vm from 'node:vm'
import { isMac, isWin } from '@main/constant'
import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process'
import { Shortcut, ThemeMode } from '@types'
import { BrowserWindow, ipcMain, session, shell } from 'electron'
import axios from 'axios'
import { BrowserWindow, ipcMain, ProxyConfig, session, shell } from 'electron'
import log from 'electron-log'
import { titleBarOverlayDark, titleBarOverlayLight } from './config'
import AppUpdater from './services/AppUpdater'
import BackupManager from './services/BackupManager'
import { configManager } from './services/ConfigManager'
import CopilotService from './services/CopilotService'
import { ExportService } from './services/ExportService'
import FileService from './services/FileService'
import FileStorage from './services/FileStorage'
import { GeminiService } from './services/GeminiService'
import KnowledgeService from './services/KnowledgeService'
import mcpService from './services/MCPService'
import * as NutstoreService from './services/NutstoreService'
import ObsidianVaultService from './services/ObsidianVaultService'
import { ProxyConfig, proxyManager } from './services/ProxyManager'
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
import { TrayService } from './services/TrayService'
import { windowService } from './services/WindowService'
import { getResourcePath } from './utils'
import { decrypt, encrypt } from './utils/aes'
import { getFilesDir } from './utils/file'
import { compress, decompress } from './utils/zip'
const fileManager = new FileStorage()
const backupManager = new BackupManager()
const exportService = new ExportService(fileManager)
const obsidianVaultService = new ObsidianVaultService()
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
const appUpdater = new AppUpdater(mainWindow)
const { autoUpdater } = new AppUpdater(mainWindow)
ipcMain.handle('app:info', () => ({
version: app.getVersion(),
isPackaged: app.isPackaged,
appPath: app.getAppPath(),
filesPath: getFilesDir(),
filesPath: path.join(app.getPath('userData'), 'Data', 'Files'),
appDataPath: app.getPath('userData'),
resourcesPath: getResourcePath(),
logsPath: log.transports.file.getFile().path
}))
ipcMain.handle('app:proxy', async (_, proxy: string) => {
let proxyConfig: ProxyConfig
if (proxy === 'system') {
proxyConfig = { mode: 'system' }
} else if (proxy) {
proxyConfig = { mode: 'custom', url: proxy }
} else {
proxyConfig = { mode: 'none' }
}
await proxyManager.configureProxy(proxyConfig)
const sessions = [session.defaultSession, session.fromPartition('persist:webview')]
const proxyConfig: ProxyConfig = proxy === 'system' ? { mode: 'system' } : proxy ? { proxyRules: proxy } : {}
await Promise.all(sessions.map((session) => session.setProxy(proxyConfig)))
})
ipcMain.handle('app:reload', () => mainWindow.reload())
ipcMain.handle('open:website', (_, url: string) => shell.openExternal(url))
// Update
ipcMain.handle('app:show-update-dialog', () => appUpdater.showUpdateDialog(mainWindow))
// language
ipcMain.handle('app:set-language', (_, language) => {
configManager.setLanguage(language)
})
// launch on boot
ipcMain.handle('app:set-launch-on-boot', (_, openAtLogin: boolean) => {
// Set login item settings for windows and mac
// linux is not supported because it requires more file operations
if (isWin || isMac) {
app.setLoginItemSettings({ openAtLogin })
}
})
// launch to tray
ipcMain.handle('app:set-launch-to-tray', (_, isActive: boolean) => {
configManager.setLaunchToTray(isActive)
})
// tray
ipcMain.handle('app:set-tray', (_, isActive: boolean) => {
configManager.setTray(isActive)
})
// to tray on close
ipcMain.handle('app:set-tray-on-close', (_, isActive: boolean) => {
configManager.setTrayOnClose(isActive)
})
ipcMain.handle('app:restart-tray', () => TrayService.getInstance().restartTray())
ipcMain.handle('config:set', (_, key: string, value: any) => {
configManager.set(key, value)
})
ipcMain.handle('config:get', (_, key: string) => {
return configManager.get(key)
})
// theme
ipcMain.handle('app:set-theme', (event, theme: ThemeMode) => {
if (theme === configManager.getTheme()) return
ipcMain.handle('app:set-theme', (_, theme: ThemeMode) => {
configManager.setTheme(theme)
// should sync theme change to all windows
const senderWindowId = event.sender.id
const windows = BrowserWindow.getAllWindows()
// 向其他窗口广播主题变化
windows.forEach((win) => {
if (win.webContents.id !== senderWindowId) {
win.webContents.send('theme:change', theme)
}
})
mainWindow?.setTitleBarOverlay &&
mainWindow.setTitleBarOverlay(theme === 'dark' ? titleBarOverlayDark : titleBarOverlayLight)
})
@@ -149,9 +84,9 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
// check for update
ipcMain.handle('app:check-for-update', async () => {
const update = await appUpdater.autoUpdater.checkForUpdates()
const update = await autoUpdater.checkForUpdates()
return {
currentVersion: appUpdater.autoUpdater.currentVersion,
currentVersion: autoUpdater.currentVersion,
updateInfo: update?.updateInfo
}
})
@@ -165,9 +100,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle('backup:restore', backupManager.restore)
ipcMain.handle('backup:backupToWebdav', backupManager.backupToWebdav)
ipcMain.handle('backup:restoreFromWebdav', backupManager.restoreFromWebdav)
ipcMain.handle('backup:listWebdavFiles', backupManager.listWebdavFiles)
ipcMain.handle('backup:checkConnection', backupManager.checkConnection)
ipcMain.handle('backup:createDirectory', backupManager.createDirectory)
// file
ipcMain.handle('file:open', fileManager.open)
@@ -186,10 +118,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle('file:base64Image', fileManager.base64Image)
ipcMain.handle('file:download', fileManager.downloadFile)
ipcMain.handle('file:copy', fileManager.copyFile)
ipcMain.handle('file:binaryFile', fileManager.binaryFile)
// fs
ipcMain.handle('fs:read', FileService.readFile)
// minapp
ipcMain.handle('minapp', (_, args) => {
@@ -228,75 +156,10 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle('knowledge-base:add', KnowledgeService.add)
ipcMain.handle('knowledge-base:remove', KnowledgeService.remove)
ipcMain.handle('knowledge-base:search', KnowledgeService.search)
ipcMain.handle('knowledge-base:rerank', KnowledgeService.rerank)
// window
ipcMain.handle('window:set-minimum-size', (_, width: number, height: number) => {
mainWindow?.setMinimumSize(width, height)
// vm
ipcMain.handle('run-js', (_, code: string) => {
const context = vm.createContext(Object.assign({ fetch: fetch, URL: URL, axios: axios }, global))
return vm.runInContext(code, context)
})
ipcMain.handle('window:reset-minimum-size', () => {
mainWindow?.setMinimumSize(1080, 600)
const [width, height] = mainWindow?.getSize() ?? [1080, 600]
if (width < 1080) {
mainWindow?.setSize(1080, height)
}
})
// gemini
ipcMain.handle('gemini:upload-file', GeminiService.uploadFile)
ipcMain.handle('gemini:base64-file', GeminiService.base64File)
ipcMain.handle('gemini:retrieve-file', GeminiService.retrieveFile)
ipcMain.handle('gemini:list-files', GeminiService.listFiles)
ipcMain.handle('gemini:delete-file', GeminiService.deleteFile)
// mini window
ipcMain.handle('miniwindow:show', () => windowService.showMiniWindow())
ipcMain.handle('miniwindow:hide', () => windowService.hideMiniWindow())
ipcMain.handle('miniwindow:close', () => windowService.closeMiniWindow())
ipcMain.handle('miniwindow:toggle', () => windowService.toggleMiniWindow())
ipcMain.handle('miniwindow:set-pin', (_, isPinned) => windowService.setPinMiniWindow(isPinned))
// aes
ipcMain.handle('aes:encrypt', (_, text: string, secretKey: string, iv: string) => encrypt(text, secretKey, iv))
ipcMain.handle('aes:decrypt', (_, encryptedData: string, iv: string, secretKey: string) =>
decrypt(encryptedData, iv, secretKey)
)
// Register MCP handlers
ipcMain.handle('mcp:remove-server', mcpService.removeServer)
ipcMain.handle('mcp:restart-server', mcpService.restartServer)
ipcMain.handle('mcp:stop-server', mcpService.stopServer)
ipcMain.handle('mcp:list-tools', mcpService.listTools)
ipcMain.handle('mcp:call-tool', mcpService.callTool)
ipcMain.handle('mcp:get-install-info', mcpService.getInstallInfo)
ipcMain.handle('app:is-binary-exist', (_, name: string) => isBinaryExists(name))
ipcMain.handle('app:get-binary-path', (_, name: string) => getBinaryPath(name))
ipcMain.handle('app:install-uv-binary', () => runInstallScript('install-uv.js'))
ipcMain.handle('app:install-bun-binary', () => runInstallScript('install-bun.js'))
//copilot
ipcMain.handle('copilot:get-auth-message', CopilotService.getAuthMessage)
ipcMain.handle('copilot:get-copilot-token', CopilotService.getCopilotToken)
ipcMain.handle('copilot:save-copilot-token', CopilotService.saveCopilotToken)
ipcMain.handle('copilot:get-token', CopilotService.getToken)
ipcMain.handle('copilot:logout', CopilotService.logout)
ipcMain.handle('copilot:get-user', CopilotService.getUser)
// Obsidian service
ipcMain.handle('obsidian:get-vaults', () => {
return obsidianVaultService.getVaults()
})
ipcMain.handle('obsidian:get-files', (_event, vaultName) => {
return obsidianVaultService.getFilesByVaultName(vaultName)
})
// nutstore
ipcMain.handle('nutstore:get-sso-url', NutstoreService.getNutstoreSSOUrl)
ipcMain.handle('nutstore:decrypt-token', (_, token: string) => NutstoreService.decryptToken(token))
ipcMain.handle('nutstore:get-directory-contents', (_, token: string, path: string) =>
NutstoreService.getDirectoryContents(token, path)
)
}

View File

@@ -1,22 +0,0 @@
import * as fs from 'node:fs'
import { JsonLoader } from '@cherrystudio/embedjs'
/**
* Drafts 应用导出的笔记文件加载器
* 原始文件是一个 JSON 数组。每条笔记只保留 content、tags、modified_at 三个字段
*/
export class DraftsExportLoader extends JsonLoader {
constructor(filePath: string) {
const fileContent = fs.readFileSync(filePath, 'utf-8')
const rawJson = JSON.parse(fileContent) as any[]
const json = rawJson.map((item) => {
return {
content: item.content?.replace(/\n/g, '<br>'),
tags: item.tags,
modified_at: item.created_at
}
})
super({ object: json })
}
}

View File

@@ -1,248 +0,0 @@
import { BaseLoader } from '@cherrystudio/embedjs-interfaces'
import { cleanString } from '@cherrystudio/embedjs-utils'
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters'
import { getTempDir } from '@main/utils/file'
import Logger from 'electron-log'
import EPub from 'epub'
import * as fs from 'fs'
import path from 'path'
/**
* epub 加载器的配置选项
*/
interface EpubLoaderOptions {
/** epub 文件路径 */
filePath: string
/** 文本分块大小 */
chunkSize: number
/** 分块重叠大小 */
chunkOverlap: number
}
/**
* epub 文件的元数据信息
*/
interface EpubMetadata {
/** 作者显示名称(例如:"Lewis Carroll" */
creator?: string
/** 作者规范化名称,用于排序和索引(例如:"Carroll, Lewis" */
creatorFileAs?: string
/** 书籍标题(例如:"Alice's Adventures in Wonderland" */
title?: string
/** 语言代码(例如:"en" 或 "zh-CN" */
language?: string
/** 主题或分类(例如:"Fantasy"、"Fiction" */
subject?: string
/** 创建日期(例如:"2024-02-14" */
date?: string
/** 书籍描述或简介 */
description?: string
}
/**
* epub 章节信息
*/
interface EpubChapter {
/** 章节 ID */
id: string
/** 章节标题 */
title?: string
/** 章节顺序 */
order?: number
}
/**
* epub 文件加载器
* 用于解析 epub 电子书文件,提取文本内容和元数据
*/
export class EpubLoader extends BaseLoader<Record<string, string | number | boolean>, Record<string, unknown>> {
protected filePath: string
protected chunkSize: number
protected chunkOverlap: number
private extractedText: string
private metadata: EpubMetadata | null
/**
* 创建 epub 加载器实例
* @param options 加载器配置选项
*/
constructor(options: EpubLoaderOptions) {
super(options.filePath, {
chunkSize: options.chunkSize,
chunkOverlap: options.chunkOverlap
})
this.filePath = options.filePath
this.chunkSize = options.chunkSize
this.chunkOverlap = options.chunkOverlap
this.extractedText = ''
this.metadata = null
}
/**
* 等待 epub 文件初始化完成
* epub 库使用事件机制,需要等待 'end' 事件触发后才能访问文件内容
* @param epub epub 实例
* @returns 元数据和章节信息
*/
private waitForEpubInit(epub: any): Promise<{ metadata: EpubMetadata; chapters: EpubChapter[] }> {
return new Promise((resolve, reject) => {
epub.on('end', () => {
// 提取元数据
const metadata: EpubMetadata = {
creator: epub.metadata.creator,
creatorFileAs: epub.metadata.creatorFileAs,
title: epub.metadata.title,
language: epub.metadata.language,
subject: epub.metadata.subject,
date: epub.metadata.date,
description: epub.metadata.description
}
// 提取章节信息
const chapters: EpubChapter[] = epub.flow.map((chapter: any, index: number) => ({
id: chapter.id,
title: chapter.title || `Chapter ${index + 1}`,
order: index + 1
}))
resolve({ metadata, chapters })
})
epub.on('error', (error: Error) => {
reject(error)
})
epub.parse()
})
}
/**
* 获取章节内容
* @param epub epub 实例
* @param chapterId 章节 ID
* @returns 章节文本内容
*/
private getChapter(epub: any, chapterId: string): Promise<string> {
return new Promise((resolve, reject) => {
epub.getChapter(chapterId, (error: Error | null, text: string) => {
if (error) {
reject(error)
} else {
resolve(text)
}
})
})
}
/**
* 从 epub 文件中提取文本内容
* 1. 检查文件是否存在
* 2. 初始化 epub 并获取元数据
* 3. 遍历所有章节并提取文本
* 4. 清理 HTML 标签
* 5. 合并所有章节文本
*/
private async extractTextFromEpub() {
try {
// 检查文件是否存在
if (!fs.existsSync(this.filePath)) {
throw new Error(`File not found: ${this.filePath}`)
}
const epub = new EPub(this.filePath)
// 等待 epub 初始化完成并获取元数据
const { metadata, chapters } = await this.waitForEpubInit(epub)
this.metadata = metadata
if (!epub.flow || epub.flow.length === 0) {
throw new Error('No content found in epub file')
}
// 使用临时文件而不是内存数组
const tempFilePath = path.join(getTempDir(), `epub-${Date.now()}.txt`)
const writeStream = fs.createWriteStream(tempFilePath)
// 遍历所有章节
for (const chapter of chapters) {
try {
const content = await this.getChapter(epub, chapter.id)
if (!content) {
continue
}
// 移除 HTML 标签并清理文本
const text = content
.replace(/<[^>]*>/g, ' ') // 移除所有 HTML 标签
.replace(/\s+/g, ' ') // 将多个空白字符替换为单个空格
.trim() // 移除首尾空白
if (text) {
// 直接写入文件
writeStream.write(text + '\n\n')
}
} catch (error) {
Logger.error(`[EpubLoader] Error processing chapter ${chapter.id}:`, error)
}
}
// 关闭写入流
writeStream.end()
// 等待写入完成
await new Promise<void>((resolve, reject) => {
writeStream.on('finish', resolve)
writeStream.on('error', reject)
})
// 从临时文件读取内容
this.extractedText = fs.readFileSync(tempFilePath, 'utf-8')
// 删除临时文件
fs.unlinkSync(tempFilePath)
// 只添加一条完成日志
Logger.info(`[EpubLoader] 电子书 ${this.metadata?.title || path.basename(this.filePath)} 处理完成`)
} catch (error) {
Logger.error('[EpubLoader] Error in extractTextFromEpub:', error)
throw error
}
}
/**
* 生成文本块
* 重写 BaseLoader 的方法,将提取的文本分割成适当大小的块
* 每个块都包含源文件和元数据信息
*/
override async *getUnfilteredChunks() {
// 如果还没有提取文本,先提取
if (!this.extractedText) {
await this.extractTextFromEpub()
}
Logger.info('[EpubLoader] 书名:', this.metadata?.title || '未知书名', ' 文本大小:', this.extractedText.length)
// 创建文本分块器
const chunker = new RecursiveCharacterTextSplitter({
chunkSize: this.chunkSize,
chunkOverlap: this.chunkOverlap
})
// 清理并分割文本
const chunks = await chunker.splitText(cleanString(this.extractedText))
// 为每个文本块添加元数据
for (const chunk of chunks) {
yield {
pageContent: chunk,
metadata: {
source: this.filePath,
title: this.metadata?.title || '',
creator: this.metadata?.creator || '',
language: this.metadata?.language || ''
}
}
}
}
}

View File

@@ -1,158 +0,0 @@
import * as fs from 'node:fs'
import { JsonLoader, LocalPathLoader, RAGApplication, TextLoader } from '@cherrystudio/embedjs'
import type { AddLoaderReturn } from '@cherrystudio/embedjs-interfaces'
import { WebLoader } from '@cherrystudio/embedjs-loader-web'
import { LoaderReturn } from '@shared/config/types'
import { FileType, KnowledgeBaseParams } from '@types'
import Logger from 'electron-log'
import { DraftsExportLoader } from './draftsExportLoader'
import { EpubLoader } from './epubLoader'
import { OdLoader, OdType } from './odLoader'
// 文件扩展名到加载器类型的映射
const FILE_LOADER_MAP: Record<string, string> = {
// 内置类型
'.pdf': 'common',
'.csv': 'common',
'.docx': 'common',
'.pptx': 'common',
'.xlsx': 'common',
'.md': 'common',
// OD类型
'.odt': 'od',
'.ods': 'od',
'.odp': 'od',
// epub类型
'.epub': 'epub',
// Drafts类型
'.draftsexport': 'drafts',
// HTML类型
'.html': 'html',
'.htm': 'html',
// JSON类型
'.json': 'json'
// 其他类型默认为文本类型
}
export async function addOdLoader(
ragApplication: RAGApplication,
file: FileType,
base: KnowledgeBaseParams,
forceReload: boolean
): Promise<AddLoaderReturn> {
const loaderMap: Record<string, OdType> = {
'.odt': OdType.OdtLoader,
'.ods': OdType.OdsLoader,
'.odp': OdType.OdpLoader
}
const odType = loaderMap[file.ext]
if (!odType) {
throw new Error('Unknown odType')
}
return ragApplication.addLoader(
new OdLoader({
odType,
filePath: file.path,
chunkSize: base.chunkSize,
chunkOverlap: base.chunkOverlap
}) as any,
forceReload
)
}
export async function addFileLoader(
ragApplication: RAGApplication,
file: FileType,
base: KnowledgeBaseParams,
forceReload: boolean
): Promise<LoaderReturn> {
// 获取文件类型,如果没有匹配则默认为文本类型
const loaderType = FILE_LOADER_MAP[file.ext.toLowerCase()] || 'text'
let loaderReturn: AddLoaderReturn
// JSON类型处理
let jsonObject = {}
let jsonParsed = true
Logger.info(`[KnowledgeBase] processing file ${file.path} as ${loaderType} type`)
switch (loaderType) {
case 'common':
// 内置类型处理
loaderReturn = await ragApplication.addLoader(
new LocalPathLoader({
path: file.path,
chunkSize: base.chunkSize,
chunkOverlap: base.chunkOverlap
}) as any,
forceReload
)
break
case 'od':
// OD类型处理
loaderReturn = await addOdLoader(ragApplication, file, base, forceReload)
break
case 'epub':
// epub类型处理
loaderReturn = await ragApplication.addLoader(
new EpubLoader({
filePath: file.path,
chunkSize: base.chunkSize ?? 1000,
chunkOverlap: base.chunkOverlap ?? 200
}) as any,
forceReload
)
break
case 'drafts':
// Drafts类型处理
loaderReturn = await ragApplication.addLoader(new DraftsExportLoader(file.path) as any, forceReload)
break
case 'html':
// HTML类型处理
loaderReturn = await ragApplication.addLoader(
new WebLoader({
urlOrContent: fs.readFileSync(file.path, 'utf-8'),
chunkSize: base.chunkSize,
chunkOverlap: base.chunkOverlap
}) as any,
forceReload
)
break
case 'json':
try {
jsonObject = JSON.parse(fs.readFileSync(file.path, 'utf-8'))
} catch (error) {
jsonParsed = false
Logger.warn('[KnowledgeBase] failed parsing json file, falling back to text processing:', file.path, error)
}
if (jsonParsed) {
loaderReturn = await ragApplication.addLoader(new JsonLoader({ object: jsonObject }), forceReload)
break
}
// fallthrough - JSON 解析失败时作为文本处理
default:
// 文本类型处理(默认)
// 如果是其他文本类型且尚未读取文件,则读取文件
loaderReturn = await ragApplication.addLoader(
new TextLoader({
text: fs.readFileSync(file.path, 'utf-8'),
chunkSize: base.chunkSize,
chunkOverlap: base.chunkOverlap
}) as any,
forceReload
)
break
}
return {
entriesAdded: loaderReturn.entriesAdded,
uniqueId: loaderReturn.uniqueId,
uniqueIds: [loaderReturn.uniqueId],
loaderType: loaderReturn.loaderType
} as LoaderReturn
}

View File

@@ -1,71 +0,0 @@
import { BaseLoader } from '@cherrystudio/embedjs-interfaces'
import { cleanString } from '@cherrystudio/embedjs-utils'
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters'
import md5 from 'md5'
import { OfficeParserConfig, parseOfficeAsync } from 'officeparser'
export enum OdType {
OdtLoader = 'OdtLoader',
OdsLoader = 'OdsLoader',
OdpLoader = 'OdpLoader',
undefined = 'undefined'
}
export class OdLoader<OdType> extends BaseLoader<{ type: string }> {
private readonly odType: OdType
private readonly filePath: string
private extractedText: string
private config: OfficeParserConfig
constructor({
odType,
filePath,
chunkSize,
chunkOverlap
}: {
odType: OdType
filePath: string
chunkSize?: number
chunkOverlap?: number
}) {
super(`${odType}_${md5(filePath)}`, { filePath }, chunkSize ?? 1000, chunkOverlap ?? 0)
this.odType = odType
this.filePath = filePath
this.extractedText = ''
this.config = {
newlineDelimiter: ' ',
ignoreNotes: false
}
}
private async extractTextFromOdt() {
try {
this.extractedText = await parseOfficeAsync(this.filePath, this.config)
} catch (err) {
console.error('odLoader error', err)
throw err
}
}
override async *getUnfilteredChunks() {
if (!this.extractedText) {
await this.extractTextFromOdt()
}
const chunker = new RecursiveCharacterTextSplitter({
chunkSize: this.chunkSize,
chunkOverlap: this.chunkOverlap
})
const chunks = await chunker.splitText(cleanString(this.extractedText))
for (const chunk of chunks) {
yield {
pageContent: chunk,
metadata: {
type: this.odType as string,
source: this.filePath
}
}
}
}
}

View File

@@ -1,31 +0,0 @@
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
import { KnowledgeBaseParams } from '@types'
export default abstract class BaseReranker {
protected base: KnowledgeBaseParams
constructor(base: KnowledgeBaseParams) {
if (!base.rerankModel) {
throw new Error('Rerank model is required')
}
this.base = base
}
abstract rerank(query: string, searchResults: ExtractChunkData[]): Promise<ExtractChunkData[]>
public defaultHeaders() {
return {
Authorization: `Bearer ${this.base.rerankApiKey}`,
'Content-Type': 'application/json'
}
}
public formatErrorMessage(url: string, error: any, requestBody: any) {
const errorDetails = {
url: url,
message: error.message,
status: error.response?.status,
statusText: error.response?.statusText,
requestBody: requestBody
}
return JSON.stringify(errorDetails, null, 2)
}
}

View File

@@ -1,14 +0,0 @@
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
import { KnowledgeBaseParams } from '@types'
import BaseReranker from './BaseReranker'
export default class DefaultReranker extends BaseReranker {
constructor(base: KnowledgeBaseParams) {
super(base)
}
async rerank(): Promise<ExtractChunkData[]> {
throw new Error('Method not implemented.')
}
}

View File

@@ -1,56 +0,0 @@
import { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
import { KnowledgeBaseParams } from '@types'
import axios from 'axios'
import BaseReranker from './BaseReranker'
export default class JinaReranker extends BaseReranker {
constructor(base: KnowledgeBaseParams) {
super(base)
}
public rerank = async (query: string, searchResults: ExtractChunkData[]): Promise<ExtractChunkData[]> => {
let baseURL = this.base?.rerankBaseURL?.endsWith('/')
? this.base.rerankBaseURL.slice(0, -1)
: this.base.rerankBaseURL
// 必须携带/v1否则会404
if (baseURL && !baseURL.endsWith('/v1')) {
baseURL = `${baseURL}/v1`
}
const url = `${baseURL}/rerank`
const requestBody = {
model: this.base.rerankModel,
query,
documents: searchResults.map((doc) => doc.pageContent),
top_n: this.base.topN
}
try {
const { data } = await axios.post(url, requestBody, { headers: this.defaultHeaders() })
const rerankResults = data.results
console.log(rerankResults)
const resultMap = new Map(rerankResults.map((result: any) => [result.index, result.relevance_score || 0]))
return searchResults
.map((doc: ExtractChunkData, index: number) => {
const score = resultMap.get(index)
if (score === undefined) return undefined
return {
...doc,
score
}
})
.filter((doc): doc is ExtractChunkData => doc !== undefined)
.sort((a, b) => b.score - a.score)
} catch (error: any) {
const errorDetails = this.formatErrorMessage(url, error, requestBody)
console.error('Jina Reranker API Error:', errorDetails)
throw new Error(`重排序请求失败: ${error.message}\n请求详情: ${errorDetails}`)
}
}
}

View File

@@ -1,15 +0,0 @@
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
import { KnowledgeBaseParams } from '@types'
import BaseReranker from './BaseReranker'
import RerankerFactory from './RerankerFactory'
export default class Reranker {
private sdk: BaseReranker
constructor(base: KnowledgeBaseParams) {
this.sdk = RerankerFactory.create(base)
}
public async rerank(query: string, searchResults: ExtractChunkData[]): Promise<ExtractChunkData[]> {
return this.sdk.rerank(query, searchResults)
}
}

View File

@@ -1,20 +0,0 @@
import { KnowledgeBaseParams } from '@types'
import BaseReranker from './BaseReranker'
import DefaultReranker from './DefaultReranker'
import JinaReranker from './JinaReranker'
import SiliconFlowReranker from './SiliconFlowReranker'
import VoyageReranker from './VoyageReranker'
export default class RerankerFactory {
static create(base: KnowledgeBaseParams): BaseReranker {
if (base.rerankModelProvider === 'silicon') {
return new SiliconFlowReranker(base)
} else if (base.rerankModelProvider === 'jina') {
return new JinaReranker(base)
} else if (base.rerankModelProvider === 'voyageai') {
return new VoyageReranker(base)
}
return new DefaultReranker(base)
}
}

View File

@@ -1,58 +0,0 @@
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
import { KnowledgeBaseParams } from '@types'
import axios from 'axios'
import BaseReranker from './BaseReranker'
export default class SiliconFlowReranker extends BaseReranker {
constructor(base: KnowledgeBaseParams) {
super(base)
}
public rerank = async (query: string, searchResults: ExtractChunkData[]): Promise<ExtractChunkData[]> => {
let baseURL = this.base?.rerankBaseURL?.endsWith('/')
? this.base.rerankBaseURL.slice(0, -1)
: this.base.rerankBaseURL
// 必须携带/v1否则会404
if (baseURL && !baseURL.endsWith('/v1')) {
baseURL = `${baseURL}/v1`
}
const url = `${baseURL}/rerank`
const requestBody = {
model: this.base.rerankModel,
query,
documents: searchResults.map((doc) => doc.pageContent),
top_n: this.base.topN,
max_chunks_per_doc: this.base.chunkSize,
overlap_tokens: this.base.chunkOverlap
}
try {
const { data } = await axios.post(url, requestBody, { headers: this.defaultHeaders() })
const rerankResults = data.results
const resultMap = new Map(rerankResults.map((result: any) => [result.index, result.relevance_score || 0]))
return searchResults
.map((doc: ExtractChunkData, index: number) => {
const score = resultMap.get(index)
if (score === undefined) return undefined
return {
...doc,
score
}
})
.filter((doc): doc is ExtractChunkData => doc !== undefined)
.sort((a, b) => b.score - a.score)
} catch (error: any) {
const errorDetails = this.formatErrorMessage(url, error, requestBody)
console.error('SiliconFlow Reranker API 错误:', errorDetails)
throw new Error(`重排序请求失败: ${error.message}\n请求详情: ${errorDetails}`)
}
}
}

View File

@@ -1,62 +0,0 @@
import { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
import { KnowledgeBaseParams } from '@types'
import axios from 'axios'
import BaseReranker from './BaseReranker'
export default class VoyageReranker extends BaseReranker {
constructor(base: KnowledgeBaseParams) {
super(base)
}
public rerank = async (query: string, searchResults: ExtractChunkData[]): Promise<ExtractChunkData[]> => {
let baseURL = this.base?.rerankBaseURL?.endsWith('/')
? this.base.rerankBaseURL.slice(0, -1)
: this.base.rerankBaseURL
if (baseURL && !baseURL.endsWith('/v1')) {
baseURL = `${baseURL}/v1`
}
const url = `${baseURL}/rerank`
const requestBody = {
model: this.base.rerankModel,
query,
documents: searchResults.map((doc) => doc.pageContent),
top_k: this.base.topN,
return_documents: false,
truncation: true
}
try {
const { data } = await axios.post(url, requestBody, {
headers: {
...this.defaultHeaders()
}
})
const rerankResults = data.data
const resultMap = new Map(rerankResults.map((result: any) => [result.index, result.relevance_score || 0]))
return searchResults
.map((doc: ExtractChunkData, index: number) => {
const score = resultMap.get(index)
if (score === undefined) return undefined
return {
...doc,
score
}
})
.filter((doc): doc is ExtractChunkData => doc !== undefined)
.sort((a, b) => b.score - a.score)
} catch (error: any) {
const errorDetails = this.formatErrorMessage(url, error, requestBody)
console.error('Voyage Reranker API Error:', errorDetails)
throw new Error(`重排序请求失败: ${error.message}\n请求详情: ${errorDetails}`)
}
}
}

BIN
src/main/resources/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

View File

@@ -1,13 +1,11 @@
import { UpdateInfo } from 'builder-util-runtime'
import { app, BrowserWindow, dialog } from 'electron'
import logger from 'electron-log'
import { AppUpdater as _AppUpdater, autoUpdater } from 'electron-updater'
import { AppUpdater as _AppUpdater, autoUpdater, UpdateInfo } from 'electron-updater'
import icon from '../../../build/icon.png?asset'
export default class AppUpdater {
autoUpdater: _AppUpdater = autoUpdater
private releaseInfo: UpdateInfo | undefined
constructor(mainWindow: BrowserWindow) {
logger.transports.file.level = 'info'
@@ -18,12 +16,7 @@ export default class AppUpdater {
// 检测下载错误
autoUpdater.on('error', (error) => {
// 简单记录错误信息和时间戳
logger.error('更新异常', {
message: error.message,
stack: error.stack,
time: new Date().toISOString()
})
logger.error('更新异常', error)
mainWindow.webContents.send('update-error', error)
})
@@ -44,40 +37,34 @@ export default class AppUpdater {
// 当需要更新的内容下载完成后
autoUpdater.on('update-downloaded', (releaseInfo: UpdateInfo) => {
mainWindow.webContents.send('update-downloaded', releaseInfo)
this.releaseInfo = releaseInfo
logger.info('下载完成', releaseInfo)
mainWindow.webContents.send('update-downloaded')
logger.info('下载完成,询问用户是否更新', releaseInfo)
dialog
.showMessageBox({
type: 'info',
title: '安装更新',
icon,
message: `新版本 ${releaseInfo.version} 已准备就绪`,
detail: this.formatReleaseNotes(releaseInfo.releaseNotes),
buttons: ['稍后安装', '立即安装'],
defaultId: 1,
cancelId: 0
})
.then(({ response }) => {
if (response === 1) {
app.isQuitting = true
setImmediate(() => autoUpdater.quitAndInstall())
} else {
mainWindow.webContents.send('update-downloaded-cancelled')
}
})
})
this.autoUpdater = autoUpdater
}
public async showUpdateDialog(mainWindow: BrowserWindow) {
if (!this.releaseInfo) {
return
}
dialog
.showMessageBox({
type: 'info',
title: '安装更新',
icon,
message: `新版本 ${this.releaseInfo.version} 已准备就绪`,
detail: this.formatReleaseNotes(this.releaseInfo.releaseNotes),
buttons: ['稍后安装', '立即安装'],
defaultId: 1,
cancelId: 0
})
.then(({ response }) => {
if (response === 1) {
app.isQuitting = true
setImmediate(() => autoUpdater.quitAndInstall())
} else {
mainWindow.webContents.send('update-downloaded-cancelled')
}
})
}
private formatReleaseNotes(releaseNotes: string | ReleaseNoteInfo[] | null | undefined): string {
if (!releaseNotes) {
return '暂无更新说明'

View File

@@ -1,73 +1,21 @@
import { WebDavConfig } from '@types'
import AdmZip from 'adm-zip'
import { exec } from 'child_process'
import { app } from 'electron'
import Logger from 'electron-log'
import * as fs from 'fs-extra'
import * as path from 'path'
import { createClient, CreateDirectoryOptions, FileStat } from 'webdav'
import WebDav from './WebDav'
import { windowService } from './WindowService'
class BackupManager {
private tempDir = path.join(app.getPath('temp'), 'cherry-studio', 'backup', 'temp')
private backupDir = path.join(app.getPath('temp'), 'cherry-studio', 'backup')
constructor() {
this.checkConnection = this.checkConnection.bind(this)
this.backup = this.backup.bind(this)
this.restore = this.restore.bind(this)
this.backupToWebdav = this.backupToWebdav.bind(this)
this.restoreFromWebdav = this.restoreFromWebdav.bind(this)
this.listWebdavFiles = this.listWebdavFiles.bind(this)
}
private async setWritableRecursive(dirPath: string): Promise<void> {
try {
const items = await fs.readdir(dirPath, { withFileTypes: true })
for (const item of items) {
const fullPath = path.join(dirPath, item.name)
// 先处理子目录
if (item.isDirectory()) {
await this.setWritableRecursive(fullPath)
}
// 统一设置权限Windows需要特殊处理
await this.forceSetWritable(fullPath)
}
// 确保根目录权限
await this.forceSetWritable(dirPath)
} catch (error) {
Logger.error(`权限设置失败:${dirPath}`, error)
throw error
}
}
// 新增跨平台权限设置方法
private async forceSetWritable(targetPath: string): Promise<void> {
try {
// Windows系统需要先取消只读属性
if (process.platform === 'win32') {
await fs.chmod(targetPath, 0o666) // Windows会忽略权限位但能移除只读
} else {
const stats = await fs.stat(targetPath)
const mode = stats.isDirectory() ? 0o777 : 0o666
await fs.chmod(targetPath, mode)
}
// 双重保险使用文件属性命令Windows专用
if (process.platform === 'win32') {
await exec(`attrib -R "${targetPath}" /L /D`)
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
Logger.warn(`权限设置警告:${targetPath}`, error)
}
}
}
async backup(
@@ -76,46 +24,17 @@ class BackupManager {
data: string,
destinationPath: string = this.backupDir
): Promise<string> {
const mainWindow = windowService.getMainWindow()
const onProgress = (processData: { stage: string; progress: number; total: number }) => {
mainWindow?.webContents.send('backup-progress', processData)
Logger.log('[BackupManager] backup progress', processData)
}
try {
await fs.ensureDir(this.tempDir)
onProgress({ stage: 'preparing', progress: 0, total: 100 })
// 使用流的方式写入 data.json
// 将 data 写入临时文件
const tempDataPath = path.join(this.tempDir, 'data.json')
await new Promise<void>((resolve, reject) => {
const writeStream = fs.createWriteStream(tempDataPath)
writeStream.write(data)
writeStream.end()
writeStream.on('finish', () => resolve())
writeStream.on('error', (error) => reject(error))
})
onProgress({ stage: 'writing_data', progress: 20, total: 100 })
await fs.writeFile(tempDataPath, data)
// 复制 Data 目录到临时目录
const sourcePath = path.join(app.getPath('userData'), 'Data')
const tempDataDir = path.join(this.tempDir, 'Data')
// 获取源目录总大小
const totalSize = await this.getDirSize(sourcePath)
let copiedSize = 0
// 使用流式复制
await this.copyDirWithProgress(sourcePath, tempDataDir, (size) => {
copiedSize += size
const progress = Math.min(80, 20 + Math.floor((copiedSize / totalSize) * 60))
onProgress({ stage: 'copying_files', progress, total: 100 })
})
await this.setWritableRecursive(tempDataDir)
onProgress({ stage: 'compressing', progress: 80, total: 100 })
await fs.copy(sourcePath, tempDataDir)
// 使用 adm-zip 创建压缩文件
const zip = new AdmZip()
@@ -125,65 +44,44 @@ class BackupManager {
// 清理临时目录
await fs.remove(this.tempDir)
onProgress({ stage: 'completed', progress: 100, total: 100 })
Logger.log('[BackupManager] Backup completed successfully')
Logger.log('Backup completed successfully')
return backupedFilePath
} catch (error) {
Logger.error('[BackupManager] Backup failed:', error)
Logger.error('Backup failed:', error)
throw error
}
}
async restore(_: Electron.IpcMainInvokeEvent, backupPath: string): Promise<string> {
const mainWindow = windowService.getMainWindow()
const onProgress = (processData: { stage: string; progress: number; total: number }) => {
mainWindow?.webContents.send('restore-progress', processData)
Logger.log('[BackupManager] restore progress', processData)
}
try {
// 创建临时目录
await fs.ensureDir(this.tempDir)
onProgress({ stage: 'preparing', progress: 0, total: 100 })
Logger.log('[backup] step 1: unzip backup file', this.tempDir)
// 使用 adm-zip 解压
const zip = new AdmZip(backupPath)
zip.extractAllTo(this.tempDir, true) // true 表示覆盖已存在的文件
onProgress({ stage: 'extracting', progress: 20, total: 100 })
Logger.log('[backup] step 2: read data.json')
// 读取 data.json
const dataPath = path.join(this.tempDir, 'data.json')
const data = await fs.readFile(dataPath, 'utf-8')
onProgress({ stage: 'reading_data', progress: 40, total: 100 })
Logger.log('[backup] step 3: restore Data directory')
// 恢复 Data 目录
const sourcePath = path.join(this.tempDir, 'Data')
const destPath = path.join(app.getPath('userData'), 'Data')
// 获取源目录总大小
const totalSize = await this.getDirSize(sourcePath)
let copiedSize = 0
await this.setWritableRecursive(destPath)
await fs.remove(destPath)
// 使用流式复制
await this.copyDirWithProgress(sourcePath, destPath, (size) => {
copiedSize += size
const progress = Math.min(90, 40 + Math.floor((copiedSize / totalSize) * 50))
onProgress({ stage: 'copying_files', progress, total: 100 })
})
await fs.copy(sourcePath, destPath)
Logger.log('[backup] step 4: clean up temp directory')
// 清理临时目录
await this.setWritableRecursive(this.tempDir)
await fs.remove(this.tempDir)
onProgress({ stage: 'completed', progress: 100, total: 100 })
Logger.log('[backup] step 5: Restore completed successfully')
@@ -196,7 +94,7 @@ class BackupManager {
}
async backupToWebdav(_: Electron.IpcMainInvokeEvent, data: string, webdavConfig: WebDavConfig) {
const filename = webdavConfig.fileName || 'cherry-studio.backup.zip'
const filename = 'cherry-studio.backup.zip'
const backupedFilePath = await this.backup(_, filename, data)
const webdavClient = new WebDav(webdavConfig)
return await webdavClient.putFileContents(filename, fs.createReadStream(backupedFilePath), {
@@ -205,108 +103,18 @@ class BackupManager {
}
async restoreFromWebdav(_: Electron.IpcMainInvokeEvent, webdavConfig: WebDavConfig) {
const filename = webdavConfig.fileName || 'cherry-studio.backup.zip'
const filename = 'cherry-studio.backup.zip'
const webdavClient = new WebDav(webdavConfig)
try {
const retrievedFile = await webdavClient.getFileContents(filename)
const backupedFilePath = path.join(this.backupDir, filename)
const retrievedFile = await webdavClient.getFileContents(filename)
const backupedFilePath = path.join(this.backupDir, filename)
if (!fs.existsSync(this.backupDir)) {
fs.mkdirSync(this.backupDir, { recursive: true })
}
// 使用流的方式写入文件
await new Promise<void>((resolve, reject) => {
const writeStream = fs.createWriteStream(backupedFilePath)
writeStream.write(retrievedFile as Buffer)
writeStream.end()
writeStream.on('finish', () => resolve())
writeStream.on('error', (error) => reject(error))
})
return await this.restore(_, backupedFilePath)
} catch (error: any) {
Logger.error('[backup] Failed to restore from WebDAV:', error)
throw new Error(error.message || 'Failed to restore backup file')
if (!fs.existsSync(this.backupDir)) {
fs.mkdirSync(this.backupDir, { recursive: true })
}
}
listWebdavFiles = async (_: Electron.IpcMainInvokeEvent, config: WebDavConfig) => {
try {
const client = createClient(config.webdavHost, {
username: config.webdavUser,
password: config.webdavPass
})
await fs.writeFileSync(backupedFilePath, retrievedFile as Buffer)
const response = await client.getDirectoryContents(config.webdavPath)
const files = Array.isArray(response) ? response : response.data
return files
.filter((file: FileStat) => file.type === 'file' && file.basename.endsWith('.zip'))
.map((file: FileStat) => ({
fileName: file.basename,
modifiedTime: file.lastmod,
size: file.size
}))
.sort((a, b) => new Date(b.modifiedTime).getTime() - new Date(a.modifiedTime).getTime())
} catch (error: any) {
Logger.error('Failed to list WebDAV files:', error)
throw new Error(error.message || 'Failed to list backup files')
}
}
private async getDirSize(dirPath: string): Promise<number> {
let size = 0
const items = await fs.readdir(dirPath, { withFileTypes: true })
for (const item of items) {
const fullPath = path.join(dirPath, item.name)
if (item.isDirectory()) {
size += await this.getDirSize(fullPath)
} else {
const stats = await fs.stat(fullPath)
size += stats.size
}
}
return size
}
private async copyDirWithProgress(
source: string,
destination: string,
onProgress: (size: number) => void
): Promise<void> {
const items = await fs.readdir(source, { withFileTypes: true })
for (const item of items) {
const sourcePath = path.join(source, item.name)
const destPath = path.join(destination, item.name)
if (item.isDirectory()) {
await fs.ensureDir(destPath)
await this.copyDirWithProgress(sourcePath, destPath, onProgress)
} else {
const stats = await fs.stat(sourcePath)
await fs.copy(sourcePath, destPath)
onProgress(stats.size)
}
}
}
async checkConnection(_: Electron.IpcMainInvokeEvent, webdavConfig: WebDavConfig) {
const webdavClient = new WebDav(webdavConfig)
return await webdavClient.checkConnection()
}
async createDirectory(
_: Electron.IpcMainInvokeEvent,
webdavConfig: WebDavConfig,
path: string,
options?: CreateDirectoryOptions
) {
const webdavClient = new WebDav(webdavConfig)
return await webdavClient.createDirectory(path, options)
return await this.restore(_, backupedFilePath)
}
}

View File

@@ -1,74 +0,0 @@
interface CacheItem<T> {
data: T
timestamp: number
duration: number
}
export class CacheService {
private static cache: Map<string, CacheItem<any>> = new Map()
/**
* Set cache
* @param key Cache key
* @param data Cache data
* @param duration Cache duration (in milliseconds)
*/
static set<T>(key: string, data: T, duration: number): void {
this.cache.set(key, {
data,
timestamp: Date.now(),
duration
})
}
/**
* Get cache
* @param key Cache key
* @returns Returns data if cache exists and not expired, otherwise returns null
*/
static get<T>(key: string): T | null {
const item = this.cache.get(key)
if (!item) return null
const now = Date.now()
if (now - item.timestamp > item.duration) {
this.remove(key)
return null
}
return item.data
}
/**
* Remove specific cache
* @param key Cache key
*/
static remove(key: string): void {
this.cache.delete(key)
}
/**
* Clear all cache
*/
static clear(): void {
this.cache.clear()
}
/**
* Check if cache exists and is valid
* @param key Cache key
* @returns boolean
*/
static has(key: string): boolean {
const item = this.cache.get(key)
if (!item) return false
const now = Date.now()
if (now - item.timestamp > item.duration) {
this.remove(key)
return false
}
return true
}
}

View File

@@ -1,118 +0,0 @@
import { debounce, getResourcePath } from '@main/utils'
import { exec } from 'child_process'
import { screen } from 'electron'
import path from 'path'
import { windowService } from './WindowService'
export default class ClipboardMonitor {
private platform: string
private lastText: string
private user32: any
private observer: any
public onTextSelected: (text: string) => void
constructor() {
this.platform = process.platform
this.lastText = ''
this.onTextSelected = debounce((text: string) => this.handleTextSelected(text), 550)
if (this.platform === 'win32') {
this.setupWindows()
} else if (this.platform === 'darwin') {
this.setupMacOS()
}
}
setupMacOS() {
// 使用 Swift 脚本来监听文本选择
const scriptPath = path.join(getResourcePath(), 'textMonitor.swift')
// 启动 Swift 进程来监听文本选择
const process = exec(`swift ${scriptPath}`)
process?.stdout?.on('data', (data: string) => {
console.log('[ClipboardMonitor] MacOS data:', data)
const text = data.toString().trim()
if (text && text !== this.lastText) {
this.lastText = text
this.onTextSelected(text)
}
})
process.on('error', (error) => {
console.error('[ClipboardMonitor] MacOS error:', error)
})
}
setupWindows() {
// 使用 Windows API 监听文本选择事件
const ffi = require('ffi-napi')
const ref = require('ref-napi')
this.user32 = new ffi.Library('user32', {
SetWinEventHook: ['pointer', ['uint32', 'uint32', 'pointer', 'pointer', 'uint32', 'uint32', 'uint32']],
UnhookWinEvent: ['bool', ['pointer']]
})
// 定义事件常量
const EVENT_OBJECT_SELECTION = 0x8006
const WINEVENT_OUTOFCONTEXT = 0x0000
const WINEVENT_SKIPOWNTHREAD = 0x0001
const WINEVENT_SKIPOWNPROCESS = 0x0002
// 创建回调函数
const callback = ffi.Callback('void', ['pointer', 'uint32', 'pointer', 'long', 'long', 'uint32', 'uint32'], () => {
this.getSelectedText()
})
// 设置事件钩子
this.observer = this.user32.SetWinEventHook(
EVENT_OBJECT_SELECTION,
EVENT_OBJECT_SELECTION,
ref.NULL,
callback,
0,
0,
WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNTHREAD | WINEVENT_SKIPOWNPROCESS
)
}
getSelectedText() {
// Get selected text
if (this.platform === 'win32') {
const ref = require('ref-napi')
if (this.user32.OpenClipboard(ref.NULL)) {
// Get clipboard content
const text = this.user32.GetClipboardData(1) // CF_TEXT = 1
this.user32.CloseClipboard()
if (text && text !== this.lastText) {
this.lastText = text
this.onTextSelected(text)
}
}
}
}
private handleTextSelected(text: string) {
if (!text) return
console.log('[ClipboardMonitor] handleTextSelected', text)
windowService.setLastSelectedText(text)
const mousePosition = screen.getCursorScreenPoint()
windowService.showSelectionMenu({
x: mousePosition.x,
y: mousePosition.y + 10
})
}
dispose() {
if (this.platform === 'win32' && this.observer) {
this.user32.UnhookWinEvent(this.observer)
}
}
}

View File

@@ -30,15 +30,7 @@ export class ConfigManager {
this.store.set('theme', theme)
}
getLaunchToTray(): boolean {
return !!this.store.get('launchToTray', false)
}
setLaunchToTray(value: boolean) {
this.store.set('launchToTray', value)
}
getTray(): boolean {
isTray(): boolean {
return !!this.store.get('tray', true)
}
@@ -47,14 +39,6 @@ export class ConfigManager {
this.notifySubscribers('tray', value)
}
getTrayOnClose(): boolean {
return !!this.store.get('trayOnClose', true)
}
setTrayOnClose(value: boolean) {
this.store.set('trayOnClose', value)
}
getZoomFactor(): number {
return this.store.get('zoomFactor', 1) as number
}
@@ -99,30 +83,6 @@ export class ConfigManager {
)
this.notifySubscribers('shortcuts', shortcuts)
}
getClickTrayToShowQuickAssistant(): boolean {
return this.store.get('clickTrayToShowQuickAssistant', false) as boolean
}
setClickTrayToShowQuickAssistant(value: boolean) {
this.store.set('clickTrayToShowQuickAssistant', value)
}
getEnableQuickAssistant(): boolean {
return this.store.get('enableQuickAssistant', false) as boolean
}
setEnableQuickAssistant(value: boolean) {
this.store.set('enableQuickAssistant', value)
}
set(key: string, value: any) {
this.store.set(key, value)
}
get(key: string) {
return this.store.get(key)
}
}
export const configManager = new ConfigManager()

View File

@@ -1,247 +0,0 @@
import axios, { AxiosRequestConfig } from 'axios'
import { app, safeStorage } from 'electron'
import fs from 'fs/promises'
import path from 'path'
// 配置常量,集中管理
const CONFIG = {
GITHUB_CLIENT_ID: 'Iv1.b507a08c87ecfe98',
POLLING: {
MAX_ATTEMPTS: 8,
INITIAL_DELAY_MS: 1000,
MAX_DELAY_MS: 16000 // 最大延迟16秒
},
DEFAULT_HEADERS: {
accept: 'application/json',
'editor-version': 'Neovim/0.6.1',
'editor-plugin-version': 'copilot.vim/1.16.0',
'content-type': 'application/json',
'user-agent': 'GithubCopilot/1.155.0',
'accept-encoding': 'gzip,deflate,br'
},
// API端点集中管理
API_URLS: {
GITHUB_USER: 'https://api.github.com/user',
GITHUB_DEVICE_CODE: 'https://github.com/login/device/code',
GITHUB_ACCESS_TOKEN: 'https://github.com/login/oauth/access_token',
COPILOT_TOKEN: 'https://api.github.com/copilot_internal/v2/token'
}
}
// 接口定义移到顶部,便于查阅
interface UserResponse {
login: string
avatar: string
}
interface AuthResponse {
device_code: string
user_code: string
verification_uri: string
}
interface TokenResponse {
access_token: string
}
interface CopilotTokenResponse {
token: string
}
// 自定义错误类,统一错误处理
class CopilotServiceError extends Error {
constructor(
message: string,
public readonly cause?: unknown
) {
super(message)
this.name = 'CopilotServiceError'
}
}
class CopilotService {
private readonly tokenFilePath: string
private headers: Record<string, string>
constructor() {
this.tokenFilePath = path.join(app.getPath('userData'), '.copilot_token')
this.headers = { ...CONFIG.DEFAULT_HEADERS }
}
/**
* 设置自定义请求头
*/
private updateHeaders = (headers?: Record<string, string>): void => {
if (headers && Object.keys(headers).length > 0) {
this.headers = { ...headers }
}
}
/**
* 获取GitHub登录信息
*/
public getUser = async (_: Electron.IpcMainInvokeEvent, token: string): Promise<UserResponse> => {
try {
const config: AxiosRequestConfig = {
headers: {
Connection: 'keep-alive',
'user-agent': 'Visual Studio Code (desktop)',
'Sec-Fetch-Site': 'none',
'Sec-Fetch-Mode': 'no-cors',
'Sec-Fetch-Dest': 'empty',
authorization: `token ${token}`
}
}
const response = await axios.get(CONFIG.API_URLS.GITHUB_USER, config)
return {
login: response.data.login,
avatar: response.data.avatar_url
}
} catch (error) {
console.error('Failed to get user information:', error)
throw new CopilotServiceError('无法获取GitHub用户信息', error)
}
}
/**
* 获取GitHub设备授权信息
*/
public getAuthMessage = async (
_: Electron.IpcMainInvokeEvent,
headers?: Record<string, string>
): Promise<AuthResponse> => {
try {
this.updateHeaders(headers)
const response = await axios.post<AuthResponse>(
CONFIG.API_URLS.GITHUB_DEVICE_CODE,
{
client_id: CONFIG.GITHUB_CLIENT_ID,
scope: 'read:user'
},
{ headers: this.headers }
)
return response.data
} catch (error) {
console.error('Failed to get auth message:', error)
throw new CopilotServiceError('无法获取GitHub授权信息', error)
}
}
/**
* 使用设备码获取访问令牌 - 优化轮询逻辑
*/
public getCopilotToken = async (
_: Electron.IpcMainInvokeEvent,
device_code: string,
headers?: Record<string, string>
): Promise<TokenResponse> => {
this.updateHeaders(headers)
let currentDelay = CONFIG.POLLING.INITIAL_DELAY_MS
for (let attempt = 0; attempt < CONFIG.POLLING.MAX_ATTEMPTS; attempt++) {
await this.delay(currentDelay)
try {
const response = await axios.post<TokenResponse>(
CONFIG.API_URLS.GITHUB_ACCESS_TOKEN,
{
client_id: CONFIG.GITHUB_CLIENT_ID,
device_code,
grant_type: 'urn:ietf:params:oauth:grant-type:device_code'
},
{ headers: this.headers }
)
const { access_token } = response.data
if (access_token) {
return { access_token }
}
} catch (error) {
// 指数退避策略
currentDelay = Math.min(currentDelay * 2, CONFIG.POLLING.MAX_DELAY_MS)
// 仅在最后一次尝试失败时记录详细错误
const isLastAttempt = attempt === CONFIG.POLLING.MAX_ATTEMPTS - 1
if (isLastAttempt) {
console.error(`Token polling failed after ${CONFIG.POLLING.MAX_ATTEMPTS} attempts:`, error)
}
}
}
throw new CopilotServiceError('获取访问令牌超时,请重试')
}
/**
* 保存Copilot令牌到本地文件
*/
public saveCopilotToken = async (_: Electron.IpcMainInvokeEvent, token: string): Promise<void> => {
try {
const encryptedToken = safeStorage.encryptString(token)
await fs.writeFile(this.tokenFilePath, encryptedToken)
} catch (error) {
console.error('Failed to save token:', error)
throw new CopilotServiceError('无法保存访问令牌', error)
}
}
/**
* 从本地文件读取令牌并获取Copilot令牌
*/
public getToken = async (
_: Electron.IpcMainInvokeEvent,
headers?: Record<string, string>
): Promise<CopilotTokenResponse> => {
try {
this.updateHeaders(headers)
const encryptedToken = await fs.readFile(this.tokenFilePath)
const access_token = safeStorage.decryptString(Buffer.from(encryptedToken))
const config: AxiosRequestConfig = {
headers: {
...this.headers,
authorization: `token ${access_token}`
}
}
const response = await axios.get<CopilotTokenResponse>(CONFIG.API_URLS.COPILOT_TOKEN, config)
return response.data
} catch (error) {
console.error('Failed to get Copilot token:', error)
throw new CopilotServiceError('无法获取Copilot令牌请重新授权', error)
}
}
/**
* 退出登录删除本地token文件
*/
public logout = async (): Promise<void> => {
try {
try {
await fs.access(this.tokenFilePath)
await fs.unlink(this.tokenFilePath)
console.log('Successfully logged out from Copilot')
} catch (error) {
// 文件不存在不是错误,只是记录一下
console.log('Token file not found, nothing to delete')
}
} catch (error) {
console.error('Failed to logout:', error)
throw new CopilotServiceError('无法完成退出登录操作', error)
}
}
/**
* 辅助方法:延迟执行
*/
private delay = (ms: number): Promise<void> => {
return new Promise((resolve) => setTimeout(resolve, ms))
}
}
export default new CopilotService()

View File

@@ -1,22 +1,7 @@
/* eslint-disable no-case-declarations */
// ExportService
import {
AlignmentType,
BorderStyle,
Document,
ExternalHyperlink,
HeadingLevel,
Packer,
Paragraph,
ShadingType,
Table,
TableCell,
TableRow,
TextRun,
VerticalAlign,
WidthType
} from 'docx'
import { AlignmentType, BorderStyle, Document, HeadingLevel, Packer, Paragraph, ShadingType, TextRun } from 'docx'
import { dialog } from 'electron'
import Logger from 'electron-log'
import MarkdownIt from 'markdown-it'
@@ -36,54 +21,13 @@ export class ExportService {
const tokens = this.md.parse(markdown, {})
const elements: any[] = []
let listLevel = 0
let currentTable: Table | null = null
let currentRowCells: TableCell[] = []
let isHeaderRow = false
let tableColumnCount = 0
let tableRows: TableRow[] = [] // Store rows temporarily
const processInlineTokens = (tokens: any[], isHeaderRow: boolean): (TextRun | ExternalHyperlink)[] => {
const runs: (TextRun | ExternalHyperlink)[] = []
let linkText = ''
let linkUrl = ''
let insideLink = false
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i]
const processInlineTokens = (tokens: any[]): TextRun[] => {
const runs: TextRun[] = []
for (const token of tokens) {
switch (token.type) {
case 'link_open':
insideLink = true
linkUrl = token.attrs.find((attr: [string, string]) => attr[0] === 'href')[1]
linkText = tokens[i + 1].content
i += 1
break
case 'link_close':
if (insideLink && linkUrl && linkText) {
// Handle any accumulated link text with the ExternalHyperlink
runs.push(
new ExternalHyperlink({
children: [
new TextRun({
text: linkText,
style: 'Hyperlink',
color: '0000FF',
underline: {
type: 'single'
}
})
],
link: linkUrl
})
)
// Reset link variables
linkText = ''
linkUrl = ''
insideLink = false
}
break
case 'text':
runs.push(new TextRun({ text: token.content, bold: isHeaderRow ? true : false }))
runs.push(new TextRun(token.content))
break
case 'strong':
runs.push(new TextRun({ text: token.content, bold: true }))
@@ -101,6 +45,7 @@ export class ExportService {
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i]
switch (token.type) {
case 'heading_open':
// 获取标题级别 (h1 -> h6)
@@ -123,7 +68,7 @@ export class ExportService {
const inlineTokens = tokens[i + 1].children || []
elements.push(
new Paragraph({
children: processInlineTokens(inlineTokens, false),
children: processInlineTokens(inlineTokens),
spacing: {
before: 120,
after: 120
@@ -148,7 +93,7 @@ export class ExportService {
children: [
new TextRun({ text: '•', bold: true }),
new TextRun({ text: '\t' }),
...processInlineTokens(itemInlineTokens, false)
...processInlineTokens(itemInlineTokens)
],
indent: {
left: listLevel * 720
@@ -226,116 +171,6 @@ export class ExportService {
)
i += 3
break
// 表格处理
case 'table_open':
tableRows = [] // Reset table rows for new table
break
case 'thead_open':
isHeaderRow = true
break
case 'tbody_open':
isHeaderRow = false
break
case 'tr_open':
currentRowCells = []
break
case 'tr_close':
const row = new TableRow({
children: currentRowCells,
tableHeader: isHeaderRow
})
tableRows.push(row)
// 计算表格有多少列(针对第一行)
if (tableColumnCount === 0) {
tableColumnCount = currentRowCells.length
}
break
case 'th_open':
case 'td_open':
const isFirstColumn = currentRowCells.length === 0 // 判断是否是第一列
const borders = {
top: {
style: BorderStyle.NONE
},
bottom: isHeaderRow
? {
style: BorderStyle.SINGLE,
size: 0.5,
color: '000000'
}
: {
style: BorderStyle.NONE
},
left: {
style: BorderStyle.NONE
},
right: {
style: BorderStyle.NONE
}
}
const cellContent = tokens[i + 1]
const cellOptions = {
children: [
new Paragraph({
children: cellContent.children
? processInlineTokens(cellContent.children, isHeaderRow || isFirstColumn)
: [new TextRun({ text: cellContent.content || '', bold: isHeaderRow || isFirstColumn })],
alignment: AlignmentType.CENTER
})
],
verticalAlign: VerticalAlign.CENTER,
borders: borders
}
currentRowCells.push(new TableCell(cellOptions))
i += 2 // 跳过内容和结束标记
break
case 'table_close':
// Create table with the collected rows - avoid using protected properties
// Create the table with all rows
currentTable = new Table({
width: {
size: 100,
type: WidthType.PERCENTAGE
},
rows: tableRows,
borders: {
top: {
style: BorderStyle.SINGLE,
size: 1,
color: '000000'
},
bottom: {
style: BorderStyle.SINGLE,
size: 1,
color: '000000'
},
left: {
style: BorderStyle.NONE
},
right: {
style: BorderStyle.NONE
},
insideHorizontal: {
style: BorderStyle.NONE
},
insideVertical: {
style: BorderStyle.NONE
}
}
})
elements.push(currentTable)
currentTable = null
tableColumnCount = 0
tableRows = []
currentRowCells = []
isHeaderRow = false
break
}
}

View File

@@ -1,7 +0,0 @@
import fs from 'node:fs'
export default class FileService {
public static async readFile(_: Electron.IpcMainInvokeEvent, path: string) {
return fs.readFileSync(path, 'utf8')
}
}

View File

@@ -1,8 +1,9 @@
import { getFilesDir, getFileType, getTempDir } from '@main/utils/file'
import { getFileType } from '@main/utils/file'
import { documentExts, imageExts } from '@shared/config/constant'
import { FileType } from '@types'
import * as crypto from 'crypto'
import {
app,
dialog,
OpenDialogOptions,
OpenDialogReturnValue,
@@ -20,8 +21,8 @@ import { chdir } from 'process'
import { v4 as uuidv4 } from 'uuid'
class FileStorage {
private storageDir = getFilesDir()
private tempDir = getTempDir()
private storageDir = path.join(app.getPath('userData'), 'Data', 'Files')
private tempDir = path.join(app.getPath('temp'), 'CherryStudio')
constructor() {
this.initStorageDir()
@@ -69,7 +70,7 @@ class FileStorage {
origin_name: file,
name: file + ext,
path: storedFilePath,
created_at: storedStats.birthtime.toISOString(),
created_at: storedStats.birthtime,
size: storedStats.size,
ext,
type: getFileType(ext),
@@ -108,7 +109,7 @@ class FileStorage {
origin_name: path.basename(filePath),
name: path.basename(filePath),
path: filePath,
created_at: stats.birthtime.toISOString(),
created_at: stats.birthtime,
size: stats.size,
ext: ext,
type: fileType,
@@ -173,7 +174,7 @@ class FileStorage {
origin_name,
name: uuid + ext,
path: destPath,
created_at: stats.birthtime.toISOString(),
created_at: stats.birthtime,
size: stats.size,
ext: ext,
type: fileType,
@@ -197,7 +198,7 @@ class FileStorage {
origin_name: path.basename(filePath),
name: path.basename(filePath),
path: filePath,
created_at: stats.birthtime.toISOString(),
created_at: stats.birthtime,
size: stats.size,
ext: ext,
type: fileType,
@@ -254,8 +255,7 @@ class FileStorage {
const filePath = path.join(this.storageDir, id)
const data = await fs.promises.readFile(filePath)
const base64 = data.toString('base64')
const ext = path.extname(filePath).slice(1) == 'jpg' ? 'jpeg' : path.extname(filePath).slice(1)
const mime = `image/${ext}`
const mime = `image/${path.extname(filePath).slice(1)}`
return {
mime,
base64,
@@ -263,20 +263,13 @@ class FileStorage {
}
}
public binaryFile = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<{ data: Buffer; mime: string }> => {
const filePath = path.join(this.storageDir, id)
const data = await fs.promises.readFile(filePath)
const mime = `image/${path.extname(filePath).slice(1)}`
return { data, mime }
}
public clear = async (): Promise<void> => {
await fs.promises.rm(this.storageDir, { recursive: true })
await fs.promises.rmdir(this.storageDir, { recursive: true })
await this.initStorageDir()
}
public clearTemp = async (): Promise<void> => {
await fs.promises.rm(this.tempDir, { recursive: true })
await fs.promises.rmdir(this.tempDir, { recursive: true })
await fs.promises.mkdir(this.tempDir, { recursive: true })
}
@@ -388,7 +381,7 @@ class FileStorage {
}
// 如果URL中有文件名使用URL中的文件名
const urlFilename = url.split('/').pop()?.split('?')[0]
const urlFilename = url.split('/').pop()
if (urlFilename && urlFilename.includes('.')) {
filename = urlFilename
}
@@ -416,7 +409,7 @@ class FileStorage {
origin_name: filename,
name: uuid + ext,
path: destPath,
created_at: stats.birthtime.toISOString(),
created_at: stats.birthtime,
size: stats.size,
ext: ext,
type: fileType,

View File

@@ -1,63 +0,0 @@
import { FileMetadataResponse, FileState, GoogleAIFileManager } from '@google/generative-ai/server'
import { FileType } from '@types'
import fs from 'fs'
import { CacheService } from './CacheService'
export class GeminiService {
private static readonly FILE_LIST_CACHE_KEY = 'gemini_file_list'
private static readonly CACHE_DURATION = 3000
static async uploadFile(_: Electron.IpcMainInvokeEvent, file: FileType, apiKey: string) {
const fileManager = new GoogleAIFileManager(apiKey)
const uploadResult = await fileManager.uploadFile(file.path, {
mimeType: 'application/pdf',
displayName: file.origin_name
})
return uploadResult
}
static async base64File(_: Electron.IpcMainInvokeEvent, file: FileType) {
return {
data: Buffer.from(fs.readFileSync(file.path)).toString('base64'),
mimeType: 'application/pdf'
}
}
static async retrieveFile(
_: Electron.IpcMainInvokeEvent,
file: FileType,
apiKey: string
): Promise<FileMetadataResponse | undefined> {
const fileManager = new GoogleAIFileManager(apiKey)
const cachedResponse = CacheService.get<any>(GeminiService.FILE_LIST_CACHE_KEY)
if (cachedResponse) {
return GeminiService.processResponse(cachedResponse, file)
}
const response = await fileManager.listFiles()
CacheService.set(GeminiService.FILE_LIST_CACHE_KEY, response, GeminiService.CACHE_DURATION)
return GeminiService.processResponse(response, file)
}
private static processResponse(response: any, file: FileType) {
if (response.files) {
return response.files
.filter((file) => file.state === FileState.ACTIVE)
.find((i) => i.displayName === file.origin_name && Number(i.sizeBytes) === file.size)
}
return undefined
}
static async listFiles(_: Electron.IpcMainInvokeEvent, apiKey: string) {
const fileManager = new GoogleAIFileManager(apiKey)
return await fileManager.listFiles()
}
static async deleteFile(_: Electron.IpcMainInvokeEvent, apiKey: string, fileId: string) {
const fileManager = new GoogleAIFileManager(apiKey)
await fileManager.deleteFile(fileId)
}
}

View File

@@ -1,99 +1,21 @@
/**
* Knowledge Service - Manages knowledge bases using RAG (Retrieval-Augmented Generation)
*
* This service handles creation, management, and querying of knowledge bases from various sources
* including files, directories, URLs, sitemaps, and notes.
*
* Features:
* - Concurrent task processing with workload management
* - Multiple data source support
* - Vector database integration
*
* For detailed documentation, see:
* @see {@link ../../../docs/technical/KnowledgeService.md}
*/
import * as fs from 'node:fs'
import path from 'node:path'
import { RAGApplication, RAGApplicationBuilder, TextLoader } from '@cherrystudio/embedjs'
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
import { LibSqlDb } from '@cherrystudio/embedjs-libsql'
import { SitemapLoader } from '@cherrystudio/embedjs-loader-sitemap'
import { WebLoader } from '@cherrystudio/embedjs-loader-web'
import Embeddings from '@main/embeddings/Embeddings'
import { addFileLoader } from '@main/loader'
import Reranker from '@main/reranker/Reranker'
import { windowService } from '@main/services/WindowService'
import { getAllFiles } from '@main/utils/file'
import type { LoaderReturn } from '@shared/config/types'
import { LocalPathLoader, RAGApplication, RAGApplicationBuilder, TextLoader } from '@llm-tools/embedjs'
import type { AddLoaderReturn, ExtractChunkData } from '@llm-tools/embedjs-interfaces'
import { LibSqlDb } from '@llm-tools/embedjs-libsql'
import { MarkdownLoader } from '@llm-tools/embedjs-loader-markdown'
import { DocxLoader, ExcelLoader, PptLoader } from '@llm-tools/embedjs-loader-msoffice'
import { PdfLoader } from '@llm-tools/embedjs-loader-pdf'
import { SitemapLoader } from '@llm-tools/embedjs-loader-sitemap'
import { WebLoader } from '@llm-tools/embedjs-loader-web'
import { AzureOpenAiEmbeddings, OpenAiEmbeddings } from '@llm-tools/embedjs-openai'
import { getInstanceName } from '@main/utils'
import { FileType, KnowledgeBaseParams, KnowledgeItem } from '@types'
import { app } from 'electron'
import Logger from 'electron-log'
import { v4 as uuidv4 } from 'uuid'
export interface KnowledgeBaseAddItemOptions {
base: KnowledgeBaseParams
item: KnowledgeItem
forceReload?: boolean
}
interface KnowledgeBaseAddItemOptionsNonNullableAttribute {
base: KnowledgeBaseParams
item: KnowledgeItem
forceReload: boolean
}
interface EvaluateTaskWorkload {
workload: number
}
type LoaderDoneReturn = LoaderReturn | null
enum LoaderTaskItemState {
PENDING,
PROCESSING,
DONE
}
interface LoaderTaskItem {
state: LoaderTaskItemState
task: () => Promise<unknown>
evaluateTaskWorkload: EvaluateTaskWorkload
}
interface LoaderTask {
loaderTasks: LoaderTaskItem[]
loaderDoneReturn: LoaderDoneReturn
}
interface LoaderTaskOfSet {
loaderTasks: Set<LoaderTaskItem>
loaderDoneReturn: LoaderDoneReturn
}
interface QueueTaskItem {
taskPromise: () => Promise<unknown>
resolve: () => void
evaluateTaskWorkload: EvaluateTaskWorkload
}
const loaderTaskIntoOfSet = (loaderTask: LoaderTask): LoaderTaskOfSet => {
return {
loaderTasks: new Set(loaderTask.loaderTasks),
loaderDoneReturn: loaderTask.loaderDoneReturn
}
}
class KnowledgeService {
private storageDir = path.join(app.getPath('userData'), 'Data', 'KnowledgeBase')
// Byte based
private workload = 0
private processingItemCount = 0
private knowledgeItemProcessingQueueMappingPromise: Map<LoaderTaskOfSet, () => void> = new Map()
private static MAXIMUM_WORKLOAD = 1024 * 1024 * 80
private static MAXIMUM_PROCESSING_ITEM_COUNT = 30
private static ERROR_LOADER_RETURN: LoaderReturn = { entriesAdded: 0, uniqueId: '', uniqueIds: [''], loaderType: '' }
constructor() {
this.initStorageDir()
@@ -113,20 +35,28 @@ class KnowledgeService {
baseURL,
dimensions
}: KnowledgeBaseParams): Promise<RAGApplication> => {
let ragApplication: RAGApplication
const embeddings = new Embeddings({ model, apiKey, apiVersion, baseURL, dimensions } as KnowledgeBaseParams)
try {
ragApplication = await new RAGApplicationBuilder()
.setModel('NO_MODEL')
.setEmbeddingModel(embeddings)
.setVectorDatabase(new LibSqlDb({ path: path.join(this.storageDir, id) }))
.build()
} catch (e) {
Logger.error(e)
throw new Error(`Failed to create RAGApplication: ${e}`)
}
return ragApplication
return new RAGApplicationBuilder()
.setModel('NO_MODEL')
.setEmbeddingModel(
apiVersion
? new AzureOpenAiEmbeddings({
azureOpenAIApiKey: apiKey,
azureOpenAIApiVersion: apiVersion,
azureOpenAIApiDeploymentName: model,
azureOpenAIApiInstanceName: getInstanceName(baseURL),
dimensions,
batchSize: 15
})
: new OpenAiEmbeddings({
model,
apiKey,
configuration: { baseURL },
dimensions,
batchSize: 15
})
)
.setVectorDatabase(new LibSqlDb({ path: path.join(this.storageDir, id) }))
.build()
}
public create = async (_: Electron.IpcMainInvokeEvent, base: KnowledgeBaseParams): Promise<void> => {
@@ -145,322 +75,71 @@ class KnowledgeService {
}
}
private maximumLoad() {
return (
this.processingItemCount >= KnowledgeService.MAXIMUM_PROCESSING_ITEM_COUNT ||
this.workload >= KnowledgeService.MAXIMUM_WORKLOAD
)
}
public add = async (
_: Electron.IpcMainInvokeEvent,
{ base, item, forceReload = false }: { base: KnowledgeBaseParams; item: KnowledgeItem; forceReload: boolean }
): Promise<AddLoaderReturn> => {
const ragApplication = await this.getRagApplication(base)
private fileTask(
ragApplication: RAGApplication,
options: KnowledgeBaseAddItemOptionsNonNullableAttribute
): LoaderTask {
const { base, item, forceReload } = options
const file = item.content as FileType
const loaderTask: LoaderTask = {
loaderTasks: [
{
state: LoaderTaskItemState.PENDING,
task: () =>
addFileLoader(ragApplication, file, base, forceReload)
.then((result) => {
loaderTask.loaderDoneReturn = result
return result
})
.catch((err) => {
Logger.error(err)
return KnowledgeService.ERROR_LOADER_RETURN
}),
evaluateTaskWorkload: { workload: file.size }
}
],
loaderDoneReturn: null
if (item.type === 'directory') {
const directory = item.content as string
return await ragApplication.addLoader(new LocalPathLoader({ path: directory }), forceReload)
}
return loaderTask
}
private directoryTask(
ragApplication: RAGApplication,
options: KnowledgeBaseAddItemOptionsNonNullableAttribute
): LoaderTask {
const { base, item, forceReload } = options
const directory = item.content as string
const files = getAllFiles(directory)
const totalFiles = files.length
let processedFiles = 0
const sendDirectoryProcessingPercent = (totalFiles: number, processedFiles: number) => {
const mainWindow = windowService.getMainWindow()
mainWindow?.webContents.send('directory-processing-percent', {
itemId: item.id,
percent: (processedFiles / totalFiles) * 100
})
}
const loaderDoneReturn: LoaderDoneReturn = {
entriesAdded: 0,
uniqueId: `DirectoryLoader_${uuidv4()}`,
uniqueIds: [],
loaderType: 'DirectoryLoader'
}
const loaderTasks: LoaderTaskItem[] = []
for (const file of files) {
loaderTasks.push({
state: LoaderTaskItemState.PENDING,
task: () =>
addFileLoader(ragApplication, file, base, forceReload)
.then((result) => {
loaderDoneReturn.entriesAdded += 1
processedFiles += 1
sendDirectoryProcessingPercent(totalFiles, processedFiles)
loaderDoneReturn.uniqueIds.push(result.uniqueId)
return result
})
.catch((err) => {
Logger.error(err)
return KnowledgeService.ERROR_LOADER_RETURN
}),
evaluateTaskWorkload: { workload: file.size }
})
}
return {
loaderTasks,
loaderDoneReturn
}
}
private urlTask(
ragApplication: RAGApplication,
options: KnowledgeBaseAddItemOptionsNonNullableAttribute
): LoaderTask {
const { base, item, forceReload } = options
const content = item.content as string
const loaderTask: LoaderTask = {
loaderTasks: [
{
state: LoaderTaskItemState.PENDING,
task: () => {
const loaderReturn = ragApplication.addLoader(
new WebLoader({
urlOrContent: content,
chunkSize: base.chunkSize,
chunkOverlap: base.chunkOverlap
}),
forceReload
) as Promise<LoaderReturn>
return loaderReturn
.then((result) => {
const { entriesAdded, uniqueId, loaderType } = result
loaderTask.loaderDoneReturn = {
entriesAdded: entriesAdded,
uniqueId: uniqueId,
uniqueIds: [uniqueId],
loaderType: loaderType
}
return result
})
.catch((err) => {
Logger.error(err)
return KnowledgeService.ERROR_LOADER_RETURN
})
},
evaluateTaskWorkload: { workload: 1024 * 1024 * 2 }
}
],
loaderDoneReturn: null
}
return loaderTask
}
private sitemapTask(
ragApplication: RAGApplication,
options: KnowledgeBaseAddItemOptionsNonNullableAttribute
): LoaderTask {
const { base, item, forceReload } = options
const content = item.content as string
const loaderTask: LoaderTask = {
loaderTasks: [
{
state: LoaderTaskItemState.PENDING,
task: () =>
ragApplication
.addLoader(
new SitemapLoader({ url: content, chunkSize: base.chunkSize, chunkOverlap: base.chunkOverlap }) as any,
forceReload
)
.then((result) => {
const { entriesAdded, uniqueId, loaderType } = result
loaderTask.loaderDoneReturn = {
entriesAdded: entriesAdded,
uniqueId: uniqueId,
uniqueIds: [uniqueId],
loaderType: loaderType
}
return result
})
.catch((err) => {
Logger.error(err)
return KnowledgeService.ERROR_LOADER_RETURN
}),
evaluateTaskWorkload: { workload: 1024 * 1024 * 20 }
}
],
loaderDoneReturn: null
}
return loaderTask
}
private noteTask(
ragApplication: RAGApplication,
options: KnowledgeBaseAddItemOptionsNonNullableAttribute
): LoaderTask {
const { base, item, forceReload } = options
const content = item.content as string
const encoder = new TextEncoder()
const contentBytes = encoder.encode(content)
const loaderTask: LoaderTask = {
loaderTasks: [
{
state: LoaderTaskItemState.PENDING,
task: () => {
const loaderReturn = ragApplication.addLoader(
new TextLoader({ text: content, chunkSize: base.chunkSize, chunkOverlap: base.chunkOverlap }),
forceReload
) as Promise<LoaderReturn>
return loaderReturn
.then(({ entriesAdded, uniqueId, loaderType }) => {
loaderTask.loaderDoneReturn = {
entriesAdded: entriesAdded,
uniqueId: uniqueId,
uniqueIds: [uniqueId],
loaderType: loaderType
}
})
.catch((err) => {
Logger.error(err)
return KnowledgeService.ERROR_LOADER_RETURN
})
},
evaluateTaskWorkload: { workload: contentBytes.length }
}
],
loaderDoneReturn: null
}
return loaderTask
}
private processingQueueHandle() {
const getSubtasksUntilMaximumLoad = (): QueueTaskItem[] => {
const queueTaskList: QueueTaskItem[] = []
that: for (const [task, resolve] of this.knowledgeItemProcessingQueueMappingPromise) {
for (const item of task.loaderTasks) {
if (this.maximumLoad()) {
break that
}
const { state, task: taskPromise, evaluateTaskWorkload } = item
if (state !== LoaderTaskItemState.PENDING) {
continue
}
const { workload } = evaluateTaskWorkload
this.workload += workload
this.processingItemCount += 1
item.state = LoaderTaskItemState.PROCESSING
queueTaskList.push({
taskPromise: () =>
taskPromise().then(() => {
this.workload -= workload
this.processingItemCount -= 1
task.loaderTasks.delete(item)
if (task.loaderTasks.size === 0) {
this.knowledgeItemProcessingQueueMappingPromise.delete(task)
resolve()
}
this.processingQueueHandle()
}),
resolve: () => {},
evaluateTaskWorkload
})
}
if (item.type === 'url') {
const content = item.content as string
if (content.startsWith('http')) {
return await ragApplication.addLoader(new WebLoader({ urlOrContent: content }), forceReload)
}
return queueTaskList
}
const subTasks = getSubtasksUntilMaximumLoad()
if (subTasks.length > 0) {
const subTaskPromises = subTasks.map(({ taskPromise }) => taskPromise())
Promise.all(subTaskPromises).then(() => {
subTasks.forEach(({ resolve }) => resolve())
})
if (item.type === 'sitemap') {
const content = item.content as string
return await ragApplication.addLoader(new SitemapLoader({ url: content }), forceReload)
}
}
private appendProcessingQueue(task: LoaderTask): Promise<LoaderReturn> {
return new Promise((resolve) => {
this.knowledgeItemProcessingQueueMappingPromise.set(loaderTaskIntoOfSet(task), () => {
resolve(task.loaderDoneReturn!)
})
})
}
if (item.type === 'note') {
const content = item.content as string
return await ragApplication.addLoader(new TextLoader({ text: content }), forceReload)
}
public add = (_: Electron.IpcMainInvokeEvent, options: KnowledgeBaseAddItemOptions): Promise<LoaderReturn> => {
return new Promise((resolve) => {
const { base, item, forceReload = false } = options
const optionsNonNullableAttribute = { base, item, forceReload }
this.getRagApplication(base)
.then((ragApplication) => {
const task = (() => {
switch (item.type) {
case 'file':
return this.fileTask(ragApplication, optionsNonNullableAttribute)
case 'directory':
return this.directoryTask(ragApplication, optionsNonNullableAttribute)
case 'url':
return this.urlTask(ragApplication, optionsNonNullableAttribute)
case 'sitemap':
return this.sitemapTask(ragApplication, optionsNonNullableAttribute)
case 'note':
return this.noteTask(ragApplication, optionsNonNullableAttribute)
default:
return null
}
})()
if (item.type === 'file') {
const file = item.content as FileType
if (task) {
this.appendProcessingQueue(task).then(() => {
resolve(task.loaderDoneReturn!)
})
this.processingQueueHandle()
} else {
resolve(KnowledgeService.ERROR_LOADER_RETURN)
}
})
.catch((err) => {
Logger.error(err)
resolve(KnowledgeService.ERROR_LOADER_RETURN)
})
})
if (file.ext === '.pdf') {
return await ragApplication.addLoader(new PdfLoader({ filePathOrUrl: file.path }) as any, forceReload)
}
if (file.ext === '.docx') {
return await ragApplication.addLoader(new DocxLoader({ filePathOrUrl: file.path }) as any, forceReload)
}
if (file.ext === '.pptx') {
return await ragApplication.addLoader(new PptLoader({ filePathOrUrl: file.path }) as any, forceReload)
}
if (file.ext === '.xlsx') {
return await ragApplication.addLoader(new ExcelLoader({ filePathOrUrl: file.path }) as any, forceReload)
}
if (['.md', '.mdx'].includes(file.ext)) {
return await ragApplication.addLoader(new MarkdownLoader({ filePathOrUrl: file.path }) as any, forceReload)
}
const fileContent = fs.readFileSync(file.path, 'utf-8')
return await ragApplication.addLoader(new TextLoader({ text: fileContent }), forceReload)
}
return { entriesAdded: 0, uniqueId: '', loaderType: '' }
}
public remove = async (
_: Electron.IpcMainInvokeEvent,
{ uniqueId, uniqueIds, base }: { uniqueId: string; uniqueIds: string[]; base: KnowledgeBaseParams }
{ uniqueId, base }: { uniqueId: string; base: KnowledgeBaseParams }
): Promise<void> => {
const ragApplication = await this.getRagApplication(base)
console.log(`[ KnowledgeService Remove Item UniqueId: ${uniqueId}]`)
for (const id of uniqueIds) {
await ragApplication.deleteLoader(id)
}
await ragApplication.deleteLoader(uniqueId)
}
public search = async (
@@ -470,16 +149,6 @@ class KnowledgeService {
const ragApplication = await this.getRagApplication(base)
return await ragApplication.search(search)
}
public rerank = async (
_: Electron.IpcMainInvokeEvent,
{ search, base, results }: { search: string; base: KnowledgeBaseParams; results: ExtractChunkData[] }
): Promise<ExtractChunkData[]> => {
if (results.length === 0) {
return results
}
return await new Reranker(base).rerank(search, results)
}
}
export default new KnowledgeService()

View File

@@ -1,293 +0,0 @@
import os from 'node:os'
import path from 'node:path'
import { isLinux, isMac, isWin } from '@main/constant'
import { makeSureDirExists } from '@main/utils'
import { getBinaryName, getBinaryPath } from '@main/utils/process'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
import { getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import { nanoid } from '@reduxjs/toolkit'
import { MCPServer, MCPTool } from '@types'
import { app } from 'electron'
import Logger from 'electron-log'
import { CacheService } from './CacheService'
class McpService {
private clients: Map<string, Client> = new Map()
private getServerKey(server: MCPServer): string {
return JSON.stringify({
baseUrl: server.baseUrl,
command: server.command,
args: server.args,
registryUrl: server.registryUrl,
env: server.env,
id: server.id
})
}
constructor() {
this.initClient = this.initClient.bind(this)
this.listTools = this.listTools.bind(this)
this.callTool = this.callTool.bind(this)
this.closeClient = this.closeClient.bind(this)
this.removeServer = this.removeServer.bind(this)
this.restartServer = this.restartServer.bind(this)
this.stopServer = this.stopServer.bind(this)
}
async initClient(server: MCPServer): Promise<Client> {
const serverKey = this.getServerKey(server)
// Check if we already have a client for this server configuration
const existingClient = this.clients.get(serverKey)
if (existingClient) {
// Check if the existing client is still connected
const pingResult = await existingClient.ping()
Logger.info(`[MCP] Ping result for ${server.name}:`, pingResult)
// If the ping fails, remove the client from the cache
// and create a new one
if (!pingResult) {
this.clients.delete(serverKey)
} else {
return existingClient
}
}
// Create new client instance for each connection
const client = new Client({ name: 'Cherry Studio', version: app.getVersion() }, { capabilities: {} })
const args = [...(server.args || [])]
let transport: StdioClientTransport | SSEClientTransport
try {
// Create appropriate transport based on configuration
if (server.baseUrl) {
transport = new SSEClientTransport(new URL(server.baseUrl))
} else if (server.command) {
let cmd = server.command
if (server.command === 'npx' || server.command === 'bun' || server.command === 'bunx') {
cmd = await getBinaryPath('bun')
Logger.info(`[MCP] Using command: ${cmd}`)
// add -x to args if args exist
if (args && args.length > 0) {
if (!args.includes('-y')) {
!args.includes('-y') && args.unshift('-y')
}
if (!args.includes('x')) {
args.unshift('x')
}
}
if (server.registryUrl) {
server.env = {
...server.env,
NPM_CONFIG_REGISTRY: server.registryUrl
}
// if the server name is mcp-auto-install, use the mcp-registry.json file in the bin directory
if (server.name === 'mcp-auto-install') {
const binPath = await getBinaryPath()
makeSureDirExists(binPath)
server.env.MCP_REGISTRY_PATH = path.join(binPath, 'mcp-registry.json')
}
}
} else if (server.command === 'uvx' || server.command === 'uv') {
cmd = await getBinaryPath(server.command)
if (server.registryUrl) {
server.env = {
...server.env,
UV_DEFAULT_INDEX: server.registryUrl,
PIP_INDEX_URL: server.registryUrl
}
}
}
Logger.info(`[MCP] Starting server with command: ${cmd} ${args ? args.join(' ') : ''}`)
// Logger.info(`[MCP] Environment variables for server:`, server.env)
transport = new StdioClientTransport({
command: cmd,
args,
env: {
...getDefaultEnvironment(),
PATH: this.getEnhancedPath(process.env.PATH || ''),
...server.env
}
})
} else {
throw new Error('Either baseUrl or command must be provided')
}
await client.connect(transport)
// Store the new client in the cache
this.clients.set(serverKey, client)
Logger.info(`[MCP] Activated server: ${server.name}`)
return client
} catch (error: any) {
Logger.error(`[MCP] Error activating server ${server.name}:`, error)
throw error
}
}
async closeClient(serverKey: string) {
const client = this.clients.get(serverKey)
if (client) {
// Remove the client from the cache
await client.close()
Logger.info(`[MCP] Closed server: ${serverKey}`)
this.clients.delete(serverKey)
CacheService.remove(`mcp:list_tool:${serverKey}`)
Logger.info(`[MCP] Cleared cache for server: ${serverKey}`)
} else {
Logger.warn(`[MCP] No client found for server: ${serverKey}`)
}
}
async stopServer(_: Electron.IpcMainInvokeEvent, server: MCPServer) {
const serverKey = this.getServerKey(server)
Logger.info(`[MCP] Stopping server: ${server.name}`)
await this.closeClient(serverKey)
}
async removeServer(_: Electron.IpcMainInvokeEvent, server: MCPServer) {
const serverKey = this.getServerKey(server)
const existingClient = this.clients.get(serverKey)
if (existingClient) {
await this.closeClient(serverKey)
}
}
async restartServer(_: Electron.IpcMainInvokeEvent, server: MCPServer) {
Logger.info(`[MCP] Restarting server: ${server.name}`)
const serverKey = this.getServerKey(server)
await this.closeClient(serverKey)
await this.initClient(server)
}
async listTools(_: Electron.IpcMainInvokeEvent, server: MCPServer) {
const client = await this.initClient(server)
const serverKey = this.getServerKey(server)
const cacheKey = `mcp:list_tool:${serverKey}`
if (CacheService.has(cacheKey)) {
Logger.info(`[MCP] Tools from ${server.name} loaded from cache`)
const cachedTools = CacheService.get<MCPTool[]>(cacheKey)
if (cachedTools && cachedTools.length > 0) {
return cachedTools
}
}
Logger.info(`[MCP] Listing tools for server: ${server.name}`)
const { tools } = await client.listTools()
const serverTools: MCPTool[] = []
tools.map((tool: any) => {
const serverTool: MCPTool = {
...tool,
id: `f${nanoid()}`,
serverId: server.id,
serverName: server.name
}
serverTools.push(serverTool)
})
CacheService.set(cacheKey, serverTools, 5 * 60 * 1000)
return serverTools
}
/**
* Call a tool on an MCP server
*/
public async callTool(
_: Electron.IpcMainInvokeEvent,
{ server, name, args }: { server: MCPServer; name: string; args: any }
): Promise<any> {
try {
Logger.info('[MCP] Calling:', server.name, name, args)
const client = await this.initClient(server)
const result = await client.callTool({ name, arguments: args })
return result
} catch (error) {
Logger.error(`[MCP] Error calling tool ${name} on ${server.name}:`, error)
throw error
}
}
public async getInstallInfo() {
const dir = path.join(os.homedir(), '.cherrystudio', 'bin')
const uvName = await getBinaryName('uv')
const bunName = await getBinaryName('bun')
const uvPath = path.join(dir, uvName)
const bunPath = path.join(dir, bunName)
return { dir, uvPath, bunPath }
}
/**
* Get enhanced PATH including common tool locations
*/
private getEnhancedPath(originalPath: string): string {
// 将原始 PATH 按分隔符分割成数组
const pathSeparator = process.platform === 'win32' ? ';' : ':'
const existingPaths = new Set(originalPath.split(pathSeparator).filter(Boolean))
const homeDir = process.env.HOME || process.env.USERPROFILE || ''
// 定义要添加的新路径
const newPaths: string[] = []
if (isMac) {
newPaths.push(
'/bin',
'/usr/bin',
'/usr/local/bin',
'/usr/local/sbin',
'/opt/homebrew/bin',
'/opt/homebrew/sbin',
'/usr/local/opt/node/bin',
`${homeDir}/.nvm/current/bin`,
`${homeDir}/.npm-global/bin`,
`${homeDir}/.yarn/bin`,
`${homeDir}/.cargo/bin`,
`${homeDir}/.cherrystudio/bin`,
'/opt/local/bin'
)
}
if (isLinux) {
newPaths.push(
'/bin',
'/usr/bin',
'/usr/local/bin',
`${homeDir}/.nvm/current/bin`,
`${homeDir}/.npm-global/bin`,
`${homeDir}/.yarn/bin`,
`${homeDir}/.cargo/bin`,
`${homeDir}/.cherrystudio/bin`,
'/snap/bin'
)
}
if (isWin) {
newPaths.push(
`${process.env.APPDATA}\\npm`,
`${homeDir}\\AppData\\Local\\Yarn\\bin`,
`${homeDir}\\.cargo\\bin`,
`${homeDir}\\.cherrystudio\\bin`
)
}
// 只添加不存在的路径
newPaths.forEach((path) => {
if (path && !existingPaths.has(path)) {
existingPaths.add(path)
}
})
// 转换回字符串
return Array.from(existingPaths).join(pathSeparator)
}
}
export default new McpService()

View File

@@ -1,134 +0,0 @@
import path from 'node:path'
import { NUTSTORE_HOST } from '@shared/config/nutstore'
import { XMLParser } from 'fast-xml-parser'
import { isNil, partial } from 'lodash'
import { type FileStat } from 'webdav'
interface OAuthResponse {
username: string
userid: string
access_token: string
}
interface WebDAVResponse {
multistatus: {
response: Array<{
href: string
propstat: {
prop: {
displayname: string
resourcetype: { collection?: any }
getlastmodified?: string
getcontentlength?: string
getcontenttype?: string
}
status: string
}
}>
}
}
export async function getNutstoreSSOUrl() {
const { createOAuthUrl } = await import('../integration/nutstore/sso/lib')
const url = createOAuthUrl({
app: 'cherrystudio'
})
return url
}
export async function decryptToken(token: string) {
const { decrypt } = await import('../integration/nutstore/sso/lib')
try {
const decrypted = decrypt('cherrystudio', token)
return JSON.parse(decrypted) as OAuthResponse
} catch (error) {
console.error('解密失败:', error)
return null
}
}
export async function getDirectoryContents(token: string, target: string): Promise<FileStat[]> {
const contents: FileStat[] = []
if (!target.startsWith('/')) {
target = '/' + target
}
let currentUrl = `${NUTSTORE_HOST}${target}`
while (true) {
const response = await fetch(currentUrl, {
method: 'PROPFIND',
headers: {
Authorization: `Basic ${token}`,
'Content-Type': 'application/xml',
Depth: '1'
},
body: `<?xml version="1.0" encoding="utf-8"?>
<propfind xmlns="DAV:">
<prop>
<displayname/>
<resourcetype/>
<getlastmodified/>
<getcontentlength/>
<getcontenttype/>
</prop>
</propfind>`
})
const text = await response.text()
const result = parseXml<WebDAVResponse>(text)
const items = Array.isArray(result.multistatus.response)
? result.multistatus.response
: [result.multistatus.response]
// 跳过第一个条目(当前目录)
contents.push(...items.slice(1).map(partial(convertToFileStat, '/dav')))
const linkHeader = response.headers['link'] || response.headers['Link']
if (!linkHeader) {
break
}
const nextLink = extractNextLink(linkHeader)
if (!nextLink) {
break
}
currentUrl = decodeURI(nextLink)
}
return contents
}
function extractNextLink(linkHeader: string): string | null {
const matches = linkHeader.match(/<([^>]+)>;\s*rel="next"/)
return matches ? matches[1] : null
}
function convertToFileStat(serverBase: string, item: WebDAVResponse['multistatus']['response'][number]): FileStat {
const props = item.propstat.prop
const isDir = !isNil(props.resourcetype?.collection)
const href = decodeURIComponent(item.href)
const filename = serverBase === '/' ? href : path.posix.join('/', href.replace(serverBase, ''))
return {
filename: filename.endsWith('/') ? filename.slice(0, -1) : filename,
basename: path.basename(filename),
lastmod: props.getlastmodified || '',
size: props.getcontentlength ? parseInt(props.getcontentlength, 10) : 0,
type: isDir ? 'directory' : 'file',
etag: null,
mime: props.getcontenttype
}
}
function parseXml<T>(xml: string) {
const parser = new XMLParser({
attributeNamePrefix: '',
removeNSPrefix: true
})
return parser.parse(xml) as T
}

View File

@@ -1,167 +0,0 @@
import { app } from 'electron'
import fs from 'fs'
import path from 'path'
interface VaultInfo {
path: string
name: string
}
interface FileInfo {
path: string
type: 'folder' | 'markdown'
name: string
}
class ObsidianVaultService {
private obsidianConfigPath: string
constructor() {
// 根据操作系统获取Obsidian配置文件路径
if (process.platform === 'win32') {
this.obsidianConfigPath = path.join(app.getPath('appData'), 'obsidian', 'obsidian.json')
} else if (process.platform === 'darwin') {
this.obsidianConfigPath = path.join(
app.getPath('home'),
'Library',
'Application Support',
'obsidian',
'obsidian.json'
)
} else {
// Linux
this.obsidianConfigPath = path.join(app.getPath('home'), '.config', 'obsidian', 'obsidian.json')
}
}
/**
* 获取所有的Obsidian Vault
*/
getVaults(): VaultInfo[] {
try {
if (!fs.existsSync(this.obsidianConfigPath)) {
return []
}
const configContent = fs.readFileSync(this.obsidianConfigPath, 'utf8')
const config = JSON.parse(configContent)
if (!config.vaults) {
return []
}
return Object.entries(config.vaults).map(([, vault]: [string, any]) => ({
path: vault.path,
name: vault.name || path.basename(vault.path)
}))
} catch (error) {
console.error('获取Obsidian Vault失败:', error)
return []
}
}
/**
* 获取Vault中的文件夹和Markdown文件结构
*/
getVaultStructure(vaultPath: string): FileInfo[] {
const results: FileInfo[] = []
try {
// 检查vault路径是否存在
if (!fs.existsSync(vaultPath)) {
console.error('Vault路径不存在:', vaultPath)
return []
}
// 检查是否是目录
const stats = fs.statSync(vaultPath)
if (!stats.isDirectory()) {
console.error('Vault路径不是一个目录:', vaultPath)
return []
}
this.traverseDirectory(vaultPath, '', results)
} catch (error) {
console.error('读取Vault文件夹结构失败:', error)
}
return results
}
/**
* 递归遍历目录获取所有文件夹和Markdown文件
*/
private traverseDirectory(dirPath: string, relativePath: string, results: FileInfo[]) {
try {
// 首先添加当前文件夹
if (relativePath) {
results.push({
path: relativePath,
type: 'folder',
name: path.basename(relativePath)
})
}
// 确保目录存在且可访问
if (!fs.existsSync(dirPath)) {
console.error('目录不存在:', dirPath)
return
}
let items
try {
items = fs.readdirSync(dirPath, { withFileTypes: true })
} catch (err) {
console.error(`无法读取目录 ${dirPath}:`, err)
return
}
for (const item of items) {
// 忽略以.开头的隐藏文件夹和文件
if (item.name.startsWith('.')) {
continue
}
const newRelativePath = relativePath ? `${relativePath}/${item.name}` : item.name
const fullPath = path.join(dirPath, item.name)
if (item.isDirectory()) {
this.traverseDirectory(fullPath, newRelativePath, results)
} else if (item.isFile() && item.name.endsWith('.md')) {
// 收集.md文件
results.push({
path: newRelativePath,
type: 'markdown',
name: item.name
})
}
}
} catch (error) {
console.error(`遍历目录出错 ${dirPath}:`, error)
}
}
/**
* 获取指定Vault的文件夹和Markdown文件结构
* @param vaultName vault名称
*/
getFilesByVaultName(vaultName: string): FileInfo[] {
try {
const vaults = this.getVaults()
const vault = vaults.find((v) => v.name === vaultName)
if (!vault) {
console.error('未找到指定名称的Vault:', vaultName)
return []
}
console.log('获取Vault文件结构:', vault.name, vault.path)
return this.getVaultStructure(vault.path)
} catch (error) {
console.error('获取Vault文件结构时发生错误:', error)
return []
}
}
}
export default ObsidianVaultService

View File

@@ -1,34 +0,0 @@
import { windowService } from './WindowService'
export const CHERRY_STUDIO_PROTOCOL = 'cherrystudio'
export function registerProtocolClient(app: Electron.App) {
if (process.defaultApp) {
if (process.argv.length >= 2) {
app.setAsDefaultProtocolClient(CHERRY_STUDIO_PROTOCOL, process.execPath, [process.argv[1]])
}
}
app.setAsDefaultProtocolClient('cherrystudio')
}
export function handleProtocolUrl(url: string) {
if (!url) return
// Process the URL that was used to open the app
// The url will be in the format: cherrystudio://data?param1=value1&param2=value2
console.log('Received URL:', url)
// Parse the URL and extract parameters
const urlObj = new URL(url)
const params = new URLSearchParams(urlObj.search)
// You can send the data to your renderer process
const mainWindow = windowService.getMainWindow()
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('protocol-data', {
url,
params: Object.fromEntries(params.entries())
})
}
}

View File

@@ -1,143 +0,0 @@
import { ProxyConfig as _ProxyConfig, session } from 'electron'
import { socksDispatcher } from 'fetch-socks'
import { ProxyAgent as GeneralProxyAgent } from 'proxy-agent'
import { ProxyAgent, setGlobalDispatcher } from 'undici'
type ProxyMode = 'system' | 'custom' | 'none'
export interface ProxyConfig {
mode: ProxyMode
url?: string
}
export class ProxyManager {
private config: ProxyConfig
private proxyAgent: GeneralProxyAgent | null = null
private systemProxyInterval: NodeJS.Timeout | null = null
constructor() {
this.config = {
mode: 'none'
}
}
private async setSessionsProxy(config: _ProxyConfig): Promise<void> {
const sessions = [session.defaultSession, session.fromPartition('persist:webview')]
await Promise.all(sessions.map((session) => session.setProxy(config)))
}
private async monitorSystemProxy(): Promise<void> {
// Clear any existing interval first
this.clearSystemProxyMonitor()
// Set new interval
this.systemProxyInterval = setInterval(async () => {
await this.setSystemProxy()
}, 10000)
}
private clearSystemProxyMonitor(): void {
if (this.systemProxyInterval) {
clearInterval(this.systemProxyInterval)
this.systemProxyInterval = null
}
}
async configureProxy(config: ProxyConfig): Promise<void> {
try {
this.config = config
this.clearSystemProxyMonitor()
if (this.config.mode === 'system') {
await this.setSystemProxy()
this.monitorSystemProxy()
} else if (this.config.mode === 'custom') {
await this.setCustomProxy()
} else {
await this.clearProxy()
}
} catch (error) {
console.error('Failed to config proxy:', error)
throw error
}
}
private setEnvironment(url: string): void {
process.env.grpc_proxy = url
process.env.HTTP_PROXY = url
process.env.HTTPS_PROXY = url
process.env.http_proxy = url
process.env.https_proxy = url
}
private async setSystemProxy(): Promise<void> {
try {
await this.setSessionsProxy({ mode: 'system' })
const proxyString = await session.defaultSession.resolveProxy('https://dummy.com')
const [protocol, address] = proxyString.split(';')[0].split(' ')
const url = protocol === 'PROXY' ? `http://${address}` : null
if (url && url !== this.config.url) {
this.config.url = url.toLowerCase()
this.setEnvironment(this.config.url)
this.proxyAgent = new GeneralProxyAgent()
}
} catch (error) {
console.error('Failed to set system proxy:', error)
throw error
}
}
private async setCustomProxy(): Promise<void> {
try {
if (this.config.url) {
this.setEnvironment(this.config.url)
this.proxyAgent = new GeneralProxyAgent()
await this.setSessionsProxy({ proxyRules: this.config.url })
}
} catch (error) {
console.error('Failed to set custom proxy:', error)
throw error
}
}
private clearEnvironment(): void {
delete process.env.HTTP_PROXY
delete process.env.HTTPS_PROXY
delete process.env.grpc_proxy
delete process.env.http_proxy
delete process.env.https_proxy
}
private async clearProxy(): Promise<void> {
this.clearEnvironment()
await this.setSessionsProxy({ mode: 'direct' })
this.config = { mode: 'none' }
this.proxyAgent = null
}
getProxyAgent(): GeneralProxyAgent | null {
return this.proxyAgent
}
getProxyUrl(): string {
return this.config.url || ''
}
setGlobalProxy() {
const proxyUrl = this.config.url
if (proxyUrl) {
const [protocol, address] = proxyUrl.split('://')
const [host, port] = address.split(':')
if (!protocol.includes('socks')) {
setGlobalDispatcher(new ProxyAgent(proxyUrl))
} else {
const dispatcher = socksDispatcher({
port: parseInt(port),
type: protocol === 'socks5' ? 5 : 4,
host: host
})
global[Symbol.for('undici.globalDispatcher.1')] = dispatcher
}
}
}
}
export const proxyManager = new ProxyManager()

View File

@@ -1,220 +0,0 @@
import { ipcMain } from 'electron'
import { EventEmitter } from 'events'
import { windowService } from './WindowService'
type StoreValue = any
type Unsubscribe = () => void
export class ReduxService extends EventEmitter {
private stateCache: any = {}
private isReady = false
constructor() {
super()
this.setupIpcHandlers()
}
private setupIpcHandlers() {
// 监听 store 就绪事件
ipcMain.handle('redux-store-ready', () => {
this.isReady = true
this.emit('ready')
})
// 监听 store 状态变化
ipcMain.on('redux-state-change', (_, newState) => {
this.stateCache = newState
this.emit('stateChange', newState)
})
}
private async waitForStoreReady(webContents: Electron.WebContents, timeout = 10000): Promise<void> {
if (this.isReady) return
const startTime = Date.now()
while (Date.now() - startTime < timeout) {
try {
const isReady = await webContents.executeJavaScript(`
!!window.store && typeof window.store.getState === 'function'
`)
if (isReady) {
this.isReady = true
return
}
} catch (error) {
// 忽略错误,继续等待
}
await new Promise((resolve) => setTimeout(resolve, 100))
}
throw new Error('Timeout waiting for Redux store to be ready')
}
// 添加同步获取状态的方法
getStateSync() {
return this.stateCache
}
// 添加同步选择器方法
selectSync<T = StoreValue>(selector: string): T | undefined {
try {
// 使用 Function 构造器来安全地执行选择器
const selectorFn = new Function('state', `return ${selector}`)
return selectorFn(this.stateCache)
} catch (error) {
console.error('Failed to select from cache:', error)
return undefined
}
}
// 修改 select 方法,优先使用缓存
async select<T = StoreValue>(selector: string): Promise<T> {
try {
// 如果已经准备就绪,先尝试从缓存中获取
if (this.isReady) {
const cachedValue = this.selectSync<T>(selector)
if (cachedValue !== undefined) {
return cachedValue
}
}
// 如果缓存中没有,再从渲染进程获取
const mainWindow = windowService.getMainWindow()
if (!mainWindow) {
throw new Error('Main window is not available')
}
await this.waitForStoreReady(mainWindow.webContents)
return await mainWindow.webContents.executeJavaScript(`
(() => {
const state = window.store.getState();
return ${selector};
})()
`)
} catch (error) {
console.error('Failed to select store value:', error)
throw error
}
}
// 派发 action
async dispatch(action: any): Promise<void> {
const mainWindow = windowService.getMainWindow()
if (!mainWindow) {
throw new Error('Main window is not available')
}
await this.waitForStoreReady(mainWindow.webContents)
try {
await mainWindow.webContents.executeJavaScript(`
window.store.dispatch(${JSON.stringify(action)})
`)
} catch (error) {
console.error('Failed to dispatch action:', error)
throw error
}
}
// 订阅状态变化
async subscribe(selector: string, callback: (newValue: any) => void): Promise<Unsubscribe> {
const mainWindow = windowService.getMainWindow()
if (!mainWindow) {
throw new Error('Main window is not available')
}
await this.waitForStoreReady(mainWindow.webContents)
// 在渲染进程中设置监听
await mainWindow.webContents.executeJavaScript(`
if (!window._storeSubscriptions) {
window._storeSubscriptions = new Set();
// 设置全局状态变化监听
const unsubscribe = window.store.subscribe(() => {
const state = window.store.getState();
window.electron.ipcRenderer.send('redux-state-change', state);
});
window._storeSubscriptions.add(unsubscribe);
}
`)
// 在主进程中处理回调
const handler = async () => {
try {
const newValue = await this.select(selector)
callback(newValue)
} catch (error) {
console.error('Error in subscription handler:', error)
}
}
this.on('stateChange', handler)
return () => {
this.off('stateChange', handler)
}
}
// 获取整个状态树
async getState(): Promise<any> {
const mainWindow = windowService.getMainWindow()
if (!mainWindow) {
throw new Error('Main window is not available')
}
await this.waitForStoreReady(mainWindow.webContents)
try {
return await mainWindow.webContents.executeJavaScript(`
window.store.getState()
`)
} catch (error) {
console.error('Failed to get state:', error)
throw error
}
}
// 批量执行 actions
async batch(actions: any[]): Promise<void> {
for (const action of actions) {
await this.dispatch(action)
}
}
}
export const reduxService = new ReduxService()
/** example
async function example() {
try {
// 读取状态
const settings = await reduxService.select('state.settings')
console.log('settings', settings)
// 派发 action
await reduxService.dispatch({
type: 'settings/updateApiKey',
payload: 'new-api-key'
})
// 订阅状态变化
const unsubscribe = await reduxService.subscribe('state.settings.apiKey', (newValue) => {
console.log('API key changed:', newValue)
})
// 批量执行 actions
await reduxService.batch([
{ type: 'action1', payload: 'data1' },
{ type: 'action2', payload: 'data2' }
])
// 同步方法虽然可能不是最新的数据,但响应更快
const apiKey = reduxService.selectSync('state.settings.apiKey')
console.log('apiKey', apiKey)
// 处理保证是最新的数据
const apiKey1 = await reduxService.select('state.settings.apiKey')
console.log('apiKey1', apiKey1)
// 取消订阅
unsubscribe()
} catch (error) {
console.error('Error:', error)
}
}
*/

View File

@@ -3,13 +3,8 @@ import { BrowserWindow, globalShortcut } from 'electron'
import Logger from 'electron-log'
import { configManager } from './ConfigManager'
import { windowService } from './WindowService'
let showAppAccelerator: string | null = null
let showMiniWindowAccelerator: string | null = null
// store the focus and blur handlers for each window to unregister them later
const windowOnHandlers = new Map<BrowserWindow, { onFocusHandler: () => void; onBlurHandler: () => void }>()
function getShortcutHandler(shortcut: Shortcut) {
switch (shortcut.key) {
@@ -23,12 +18,13 @@ function getShortcutHandler(shortcut: Shortcut) {
configManager.setZoomFactor(1)
}
case 'show_app':
return () => {
windowService.toggleMainWindow()
}
case 'mini_window':
return () => {
windowService.toggleMiniWindow()
return (window: BrowserWindow) => {
if (window.isVisible()) {
window.hide()
} else {
window.show()
window.focus()
}
}
default:
return null
@@ -41,8 +37,8 @@ function formatShortcutKey(shortcut: string[]): string {
function handleZoom(delta: number) {
return (window: BrowserWindow) => {
const currentZoom = configManager.getZoomFactor()
const newZoom = Number((currentZoom + delta).toFixed(1))
const currentZoom = window.webContents.getZoomFactor()
const newZoom = currentZoom + delta
if (newZoom >= 0.1 && newZoom <= 5.0) {
window.webContents.setZoomFactor(newZoom)
configManager.setZoomFactor(newZoom)
@@ -50,76 +46,10 @@ function handleZoom(delta: number) {
}
}
const convertShortcutRecordedByKeyboardEventKeyValueToElectronGlobalShortcutFormat = (
shortcut: string | string[]
): string => {
const accelerator = (() => {
if (Array.isArray(shortcut)) {
return shortcut
} else {
return shortcut.split('+').map((key) => key.trim())
}
})()
return accelerator
.map((key) => {
switch (key) {
case 'Command':
return 'CommandOrControl'
case 'Control':
return 'Control'
case 'Ctrl':
return 'Control'
case 'ArrowUp':
return 'Up'
case 'ArrowDown':
return 'Down'
case 'ArrowLeft':
return 'Left'
case 'ArrowRight':
return 'Right'
case 'AltGraph':
return 'Alt'
case 'Slash':
return '/'
case 'Semicolon':
return ';'
case 'BracketLeft':
return '['
case 'BracketRight':
return ']'
case 'Backslash':
return '\\'
case 'Quote':
return "'"
case 'Comma':
return ','
case 'Minus':
return '-'
case 'Equal':
return '='
default:
return key
}
})
.join('+')
}
export function registerShortcuts(window: BrowserWindow) {
window.once('ready-to-show', () => {
if (configManager.getLaunchToTray()) {
registerOnlyUniversalShortcuts()
}
})
window.webContents.setZoomFactor(configManager.getZoomFactor())
//only for clearer code
const registerOnlyUniversalShortcuts = () => {
register(true)
}
//onlyUniversalShortcuts is used to register shortcuts that are not window specific, like show_app & mini_window
//onlyUniversalShortcuts is needed when we launch to tray
const register = (onlyUniversalShortcuts: boolean = false) => {
const register = () => {
if (window.isDestroyed()) return
const shortcuts = configManager.getShortcuts()
@@ -131,55 +61,37 @@ export function registerShortcuts(window: BrowserWindow) {
return
}
//if not enabled, exit early from the process.
if (!shortcut.enabled) {
return
}
// only register universal shortcuts when needed
if (onlyUniversalShortcuts && !['show_app', 'mini_window'].includes(shortcut.key)) {
return
}
const handler = getShortcutHandler(shortcut)
if (!handler) {
return
}
switch (shortcut.key) {
case 'show_app':
showAppAccelerator = formatShortcutKey(shortcut.shortcut)
break
const accelerator = formatShortcutKey(shortcut.shortcut)
case 'mini_window':
//available only when QuickAssistant enabled
if (!configManager.getEnableQuickAssistant()) {
return
}
showMiniWindowAccelerator = formatShortcutKey(shortcut.shortcut)
break
//the following ZOOMs will register shortcuts seperately, so will return
case 'zoom_in':
globalShortcut.register('CommandOrControl+=', () => handler(window))
globalShortcut.register('CommandOrControl+numadd', () => handler(window))
return
case 'zoom_out':
globalShortcut.register('CommandOrControl+-', () => handler(window))
globalShortcut.register('CommandOrControl+numsub', () => handler(window))
return
case 'zoom_reset':
globalShortcut.register('CommandOrControl+0', () => handler(window))
return
if (shortcut.key === 'show_app') {
showAppAccelerator = accelerator
}
const accelerator = convertShortcutRecordedByKeyboardEventKeyValueToElectronGlobalShortcutFormat(
shortcut.shortcut
)
if (shortcut.key.includes('zoom')) {
switch (shortcut.key) {
case 'zoom_in':
globalShortcut.register('CommandOrControl+=', () => shortcut.enabled && handler(window))
globalShortcut.register('CommandOrControl+numadd', () => shortcut.enabled && handler(window))
return
case 'zoom_out':
globalShortcut.register('CommandOrControl+-', () => shortcut.enabled && handler(window))
globalShortcut.register('CommandOrControl+numsub', () => shortcut.enabled && handler(window))
return
case 'zoom_reset':
globalShortcut.register('CommandOrControl+0', () => shortcut.enabled && handler(window))
return
}
}
globalShortcut.register(accelerator, () => handler(window))
if (shortcut.enabled) {
globalShortcut.register(accelerator, () => handler(window))
}
} catch (error) {
Logger.error(`[ShortcutService] Failed to register shortcut ${shortcut.key}`)
}
@@ -194,32 +106,15 @@ export function registerShortcuts(window: BrowserWindow) {
if (showAppAccelerator) {
const handler = getShortcutHandler({ key: 'show_app' } as Shortcut)
const accelerator =
convertShortcutRecordedByKeyboardEventKeyValueToElectronGlobalShortcutFormat(showAppAccelerator)
handler && globalShortcut.register(accelerator, () => handler(window))
}
if (showMiniWindowAccelerator) {
const handler = getShortcutHandler({ key: 'mini_window' } as Shortcut)
const accelerator =
convertShortcutRecordedByKeyboardEventKeyValueToElectronGlobalShortcutFormat(showMiniWindowAccelerator)
handler && globalShortcut.register(accelerator, () => handler(window))
handler && globalShortcut.register(showAppAccelerator, () => handler(window))
}
} catch (error) {
Logger.error('[ShortcutService] Failed to unregister shortcuts')
}
}
// only register the event handlers once
if (undefined === windowOnHandlers.get(window)) {
// pass register() directly to listener, the func will receive Event as argument, it's not expected
const registerHandler = () => {
register()
}
window.on('focus', registerHandler)
window.on('blur', unregister)
windowOnHandlers.set(window, { onFocusHandler: registerHandler, onBlurHandler: unregister })
}
window.on('focus', () => register())
window.on('blur', () => unregister())
if (!window.isDestroyed() && window.isFocused()) {
register()
@@ -229,12 +124,6 @@ export function registerShortcuts(window: BrowserWindow) {
export function unregisterAllShortcuts() {
try {
showAppAccelerator = null
showMiniWindowAccelerator = null
windowOnHandlers.forEach((handlers, window) => {
window.off('focus', handlers.onFocusHandler)
window.off('blur', handlers.onBlurHandler)
})
windowOnHandlers.clear()
globalShortcut.unregisterAll()
} catch (error) {
Logger.error('[ShortcutService] Failed to unregister all shortcuts')

View File

@@ -1,6 +1,6 @@
import { isMac } from '@main/constant'
import { locales } from '@main/utils/locales'
import { app, Menu, MenuItemConstructorOptions, nativeImage, nativeTheme, Tray } from 'electron'
import { app, Menu, nativeImage, nativeTheme, Tray } from 'electron'
import icon from '../../../build/tray_icon.png?asset'
import iconDark from '../../../build/tray_icon_dark.png?asset'
@@ -9,22 +9,14 @@ import { configManager } from './ConfigManager'
import { windowService } from './WindowService'
export class TrayService {
private static instance: TrayService
private tray: Tray | null = null
constructor() {
this.updateTray()
this.watchTrayChanges()
TrayService.instance = this
}
public static getInstance() {
return TrayService.instance
}
private createTray() {
this.destroyTray()
const iconPath = isMac ? (nativeTheme.shouldUseDarkColors ? iconLight : iconDark) : icon
const tray = new Tray(iconPath)
@@ -46,25 +38,17 @@ export class TrayService {
const locale = locales[configManager.getLanguage()]
const { tray: trayLocale } = locale.translation
const enableQuickAssistant = configManager.getEnableQuickAssistant()
const template = [
const contextMenu = Menu.buildFromTemplate([
{
label: trayLocale.show_window,
click: () => windowService.showMainWindow()
},
enableQuickAssistant && {
label: trayLocale.show_mini_window,
click: () => windowService.showMiniWindow()
},
{ type: 'separator' },
{
label: trayLocale.quit,
click: () => this.quit()
}
].filter(Boolean) as MenuItemConstructorOptions[]
const contextMenu = Menu.buildFromTemplate(template)
])
if (process.platform === 'linux') {
this.tray.setContextMenu(contextMenu)
@@ -77,30 +61,18 @@ export class TrayService {
})
this.tray.on('click', () => {
if (enableQuickAssistant && configManager.getClickTrayToShowQuickAssistant()) {
windowService.showMiniWindow()
} else {
windowService.showMainWindow()
}
windowService.showMainWindow()
})
}
private updateTray() {
const showTray = configManager.getTray()
if (showTray) {
if (configManager.isTray()) {
this.createTray()
} else {
this.destroyTray()
}
}
public restartTray() {
if (configManager.getTray()) {
this.destroyTray()
this.createTray()
}
}
private destroyTray() {
if (this.tray) {
this.tray.destroy()

View File

@@ -1,14 +1,8 @@
import { WebDavConfig } from '@types'
import Logger from 'electron-log'
import Stream from 'stream'
import {
BufferLike,
createClient,
CreateDirectoryOptions,
GetFileContentsOptions,
PutFileContentsOptions,
WebDAVClient
} from 'webdav'
import { BufferLike, createClient, GetFileContentsOptions, PutFileContentsOptions, WebDAVClient } from 'webdav'
export default class WebDav {
public instance: WebDAVClient | undefined
private webdavPath: string
@@ -25,7 +19,6 @@ export default class WebDav {
this.putFileContents = this.putFileContents.bind(this)
this.getFileContents = this.getFileContents.bind(this)
this.createDirectory = this.createDirectory.bind(this)
}
public putFileContents = async (
@@ -72,30 +65,4 @@ export default class WebDav {
throw error
}
}
public checkConnection = async () => {
if (!this.instance) {
throw new Error('WebDAV client not initialized')
}
try {
return await this.instance.exists('/')
} catch (error) {
Logger.error('[WebDAV] Error checking connection:', error)
throw error
}
}
public createDirectory = async (path: string, options?: CreateDirectoryOptions) => {
if (!this.instance) {
throw new Error('WebDAV client not initialized')
}
try {
return await this.instance.createDirectory(path, options)
} catch (error) {
Logger.error('[WebDAV] Error creating directory on WebDAV:', error)
throw error
}
}
}

View File

@@ -1,10 +1,9 @@
import { is } from '@electron-toolkit/utils'
import { isDev, isLinux, isMac, isWin } from '@main/constant'
import { getFilesDir } from '@main/utils/file'
import { app, BrowserWindow, ipcMain, Menu, MenuItem, shell } from 'electron'
import { isLinux, isWin } from '@main/constant'
import { app, BrowserWindow, Menu, MenuItem, shell } from 'electron'
import Logger from 'electron-log'
import windowStateKeeper from 'electron-window-state'
import { join } from 'path'
import path, { join } from 'path'
import icon from '../../../build/icon.png?asset'
import { titleBarOverlayDark, titleBarOverlayLight } from '../config'
@@ -14,15 +13,6 @@ import { configManager } from './ConfigManager'
export class WindowService {
private static instance: WindowService | null = null
private mainWindow: BrowserWindow | null = null
private miniWindow: BrowserWindow | null = null
private isPinnedMiniWindow: boolean = false
private wasFullScreen: boolean = false
//hacky-fix: store the focused status of mainWindow before miniWindow shows
//to restore the focus status when miniWindow hides
private wasMainWindowFocused: boolean = false
private selectionMenuWindow: BrowserWindow | null = null
private lastSelectedText: string = ''
private contextMenu: Menu | null = null
public static getInstance(): WindowService {
if (!WindowService.instance) {
@@ -33,8 +23,6 @@ export class WindowService {
public createMainWindow(): BrowserWindow {
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.show()
this.mainWindow.focus()
return this.mainWindow
}
@@ -44,6 +32,8 @@ export class WindowService {
})
const theme = configManager.getTheme()
const isMac = process.platform === 'darwin'
const isLinux = process.platform === 'linux'
this.mainWindow = new BrowserWindow({
x: mainWindowState.x,
@@ -52,33 +42,25 @@ export class WindowService {
height: mainWindowState.height,
minWidth: 1080,
minHeight: 600,
show: false, // 初始不显示
show: true,
autoHideMenuBar: true,
transparent: isMac,
vibrancy: 'sidebar',
vibrancy: 'under-window',
visualEffectState: 'active',
titleBarStyle: isLinux ? 'default' : 'hidden',
titleBarOverlay: theme === 'dark' ? titleBarOverlayDark : titleBarOverlayLight,
backgroundColor: isMac ? undefined : theme === 'dark' ? '#181818' : '#FFFFFF',
trafficLightPosition: { x: 8, y: 12 },
...(isLinux ? { icon } : {}),
...(process.platform === 'linux' ? { icon } : {}),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
webSecurity: false,
webviewTag: true,
allowRunningInsecureContent: true
webviewTag: true
}
})
this.setupMainWindow(this.mainWindow, mainWindowState)
//preload miniWindow to resolve series of issues about miniWindow in Mac
const enableQuickAssistant = configManager.getEnableQuickAssistant()
if (enableQuickAssistant && !this.miniWindow) {
this.miniWindow = this.createMiniWindow(true)
}
return this.mainWindow
}
@@ -123,87 +105,21 @@ export class WindowService {
}
private setupContextMenu(mainWindow: BrowserWindow) {
if (!this.contextMenu) {
mainWindow.webContents.on('context-menu', () => {
const locale = locales[configManager.getLanguage()]
const { common } = locale.translation
this.contextMenu = new Menu()
this.contextMenu.append(new MenuItem({ label: common.copy, role: 'copy' }))
this.contextMenu.append(new MenuItem({ label: common.paste, role: 'paste' }))
this.contextMenu.append(new MenuItem({ label: common.cut, role: 'cut' }))
}
mainWindow.webContents.on('context-menu', () => {
this.contextMenu?.popup()
})
// Dangerous API
if (isDev) {
mainWindow.webContents.on('will-attach-webview', (_, webPreferences) => {
webPreferences.preload = join(__dirname, '../preload/index.js')
})
}
// Handle webview context menu
mainWindow.webContents.on('did-attach-webview', (_, webContents) => {
webContents.on('context-menu', () => {
this.contextMenu?.popup()
})
const menu = new Menu()
menu.append(new MenuItem({ label: common.copy, role: 'copy' }))
menu.append(new MenuItem({ label: common.paste, role: 'paste' }))
menu.append(new MenuItem({ label: common.cut, role: 'cut' }))
menu.popup()
})
}
private setupWindowEvents(mainWindow: BrowserWindow) {
mainWindow.once('ready-to-show', () => {
mainWindow.webContents.setZoomFactor(configManager.getZoomFactor())
// show window only when laucn to tray not set
const isLaunchToTray = configManager.getLaunchToTray()
if (!isLaunchToTray) {
//[mac]hacky-fix: miniWindow set visibleOnFullScreen:true will cause dock icon disappeared
app.dock?.show()
mainWindow.show()
}
})
// 处理全屏相关事件
mainWindow.on('enter-full-screen', () => {
this.wasFullScreen = true
mainWindow.webContents.send('fullscreen-status-changed', true)
})
mainWindow.on('leave-full-screen', () => {
this.wasFullScreen = false
mainWindow.webContents.send('fullscreen-status-changed', false)
})
// set the zoom factor again when the window is going to resize
//
// this is a workaround for the known bug that
// the zoom factor is reset to cached value when window is resized after routing to other page
// see: https://github.com/electron/electron/issues/10572
//
mainWindow.on('will-resize', () => {
mainWindow.webContents.setZoomFactor(configManager.getZoomFactor())
})
// ARCH: as `will-resize` is only for Win & Mac,
// linux has the same problem, use `resize` listener instead
// but `resize` will fliker the ui
if (isLinux) {
mainWindow.on('resize', () => {
mainWindow.webContents.setZoomFactor(configManager.getZoomFactor())
})
}
// 添加Escape键退出全屏的支持
mainWindow.webContents.on('before-input-event', (event, input) => {
// 当按下Escape键且窗口处于全屏状态时退出全屏
if (input.key === 'Escape' && !input.alt && !input.control && !input.meta && !input.shift) {
if (mainWindow.isFullScreen()) {
event.preventDefault()
mainWindow.setFullScreen(false)
}
}
mainWindow.on('ready-to-show', () => {
mainWindow.show()
})
}
@@ -220,27 +136,9 @@ export class WindowService {
mainWindow.webContents.setWindowOpenHandler((details) => {
const { url } = details
const oauthProviderUrls = [
'https://account.siliconflow.cn/oauth',
'https://cloud.siliconflow.cn/expensebill',
'https://aihubmix.com/token',
'https://aihubmix.com/topup'
]
if (oauthProviderUrls.some((link) => url.startsWith(link))) {
return {
action: 'allow',
overrideBrowserWindowOptions: {
webPreferences: {
partition: 'persist:webview'
}
}
}
}
if (url.includes('http://file/')) {
const fileName = url.replace('http://file/', '')
const storageDir = getFilesDir()
const storageDir = path.join(app.getPath('userData'), 'Data', 'Files')
const filePath = storageDir + '/' + fileName
shell.openPath(filePath).catch((err) => Logger.error('Failed to open file:', err))
} else {
@@ -285,302 +183,32 @@ export class WindowService {
private setupWindowLifecycleEvents(mainWindow: BrowserWindow) {
mainWindow.on('close', (event) => {
// 如果已经触发退出,直接退出
if (app.isQuitting) {
const notInTray = !configManager.isTray()
// Windows and Linux
if ((isWin || isLinux) && notInTray) {
return app.quit()
}
// 托盘及关闭行为设置
const isShowTray = configManager.getTray()
const isTrayOnClose = configManager.getTrayOnClose()
// 没有开启托盘,或者开启了托盘,但设置了直接关闭,应执行直接退出
if (!isShowTray || (isShowTray && !isTrayOnClose)) {
// 如果是Windows或Linux直接退出
// mac按照系统默认行为不退出
if (isWin || isLinux) {
return app.quit()
}
}
//上述逻辑以下,是“开启托盘+设置关闭时最小化到托盘”的情况
// 如果是Windows或Linux且处于全屏状态则退出应用
if (this.wasFullScreen) {
if (isWin || isLinux) {
return app.quit()
} else {
event.preventDefault()
mainWindow.setFullScreen(false)
return
}
}
event.preventDefault()
mainWindow.hide()
//for mac users, should hide dock icon if close to tray
app.dock?.hide()
})
mainWindow.on('closed', () => {
this.mainWindow = null
})
mainWindow.on('show', () => {
if (this.miniWindow && !this.miniWindow.isDestroyed()) {
this.miniWindow.hide()
// Mac
if (!app.isQuitting) {
event.preventDefault()
mainWindow.hide()
}
})
}
public showMainWindow() {
if (this.miniWindow && !this.miniWindow.isDestroyed()) {
this.miniWindow.hide()
}
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
if (this.mainWindow) {
if (this.mainWindow.isMinimized()) {
this.mainWindow.restore()
return
return this.mainWindow.restore()
}
//[macOS] Known Issue
// setVisibleOnAllWorkspaces true/false will NOT bring window to current desktop in Mac (works fine with Windows)
// AppleScript may be a solution, but it's not worth
this.mainWindow.setVisibleOnAllWorkspaces(true)
this.mainWindow.show()
this.mainWindow.focus()
this.mainWindow.setVisibleOnAllWorkspaces(false)
} else {
this.mainWindow = this.createMainWindow()
this.createMainWindow()
}
}
public toggleMainWindow() {
// should not toggle main window when in full screen
if (this.wasFullScreen) {
return
}
if (this.mainWindow && !this.mainWindow.isDestroyed() && this.mainWindow.isVisible()) {
if (this.mainWindow.isFocused()) {
// if tray is enabled, hide the main window, else do nothing
if (configManager.getTray()) {
this.mainWindow.hide()
app.dock?.hide()
}
} else {
this.mainWindow.focus()
}
return
}
this.showMainWindow()
}
public createMiniWindow(isPreload: boolean = false): BrowserWindow {
this.miniWindow = new BrowserWindow({
width: 550,
height: 400,
minWidth: 350,
minHeight: 380,
maxWidth: 1024,
maxHeight: 768,
show: false,
autoHideMenuBar: true,
transparent: isMac,
vibrancy: 'under-window',
visualEffectState: 'followWindow',
center: true,
frame: false,
alwaysOnTop: true,
resizable: true,
useContentSize: true,
...(isMac ? { type: 'panel' } : {}),
skipTaskbar: true,
minimizable: false,
maximizable: false,
fullscreenable: false,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
webSecurity: false,
webviewTag: true
}
})
//miniWindow should show in current desktop
this.miniWindow?.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })
//make miniWindow always on top of fullscreen apps with level set
this.miniWindow.setAlwaysOnTop(true, 'screen-saver', 1)
this.miniWindow.on('ready-to-show', () => {
if (isPreload) {
return
}
this.wasMainWindowFocused = this.mainWindow?.isFocused() || false
this.miniWindow?.center()
this.miniWindow?.show()
})
this.miniWindow.on('blur', () => {
if (!this.isPinnedMiniWindow) {
this.hideMiniWindow()
}
})
this.miniWindow.on('closed', () => {
this.miniWindow = null
})
this.miniWindow.on('hide', () => {
this.miniWindow?.webContents.send('hide-mini-window')
})
this.miniWindow.on('show', () => {
this.miniWindow?.webContents.send('show-mini-window')
})
ipcMain.on('miniwindow-reload', () => {
this.miniWindow?.reload()
})
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
this.miniWindow.loadURL(process.env['ELECTRON_RENDERER_URL'] + '#/mini')
} else {
this.miniWindow.loadFile(join(__dirname, '../renderer/index.html'), {
hash: '#/mini'
})
}
return this.miniWindow
}
public showMiniWindow() {
const enableQuickAssistant = configManager.getEnableQuickAssistant()
if (!enableQuickAssistant) {
return
}
if (this.selectionMenuWindow && !this.selectionMenuWindow.isDestroyed()) {
this.selectionMenuWindow.hide()
}
if (this.miniWindow && !this.miniWindow.isDestroyed()) {
this.wasMainWindowFocused = this.mainWindow?.isFocused() || false
if (this.miniWindow.isMinimized()) {
this.miniWindow.restore()
}
this.miniWindow.show()
return
}
this.miniWindow = this.createMiniWindow()
}
public hideMiniWindow() {
//hacky-fix:[mac/win] previous window(not self-app) should be focused again after miniWindow hide
if (isWin) {
this.miniWindow?.minimize()
this.miniWindow?.hide()
return
} else if (isMac) {
this.miniWindow?.hide()
if (!this.wasMainWindowFocused) {
app.hide()
}
return
}
this.miniWindow?.hide()
}
public closeMiniWindow() {
this.miniWindow?.close()
}
public toggleMiniWindow() {
if (this.miniWindow && !this.miniWindow.isDestroyed() && this.miniWindow.isVisible()) {
this.hideMiniWindow()
return
}
this.showMiniWindow()
}
public setPinMiniWindow(isPinned) {
this.isPinnedMiniWindow = isPinned
}
public showSelectionMenu(bounds: { x: number; y: number }) {
if (this.selectionMenuWindow && !this.selectionMenuWindow.isDestroyed()) {
this.selectionMenuWindow.setPosition(bounds.x, bounds.y)
this.selectionMenuWindow.show()
return
}
const theme = configManager.getTheme()
this.selectionMenuWindow = new BrowserWindow({
width: 280,
height: 40,
x: bounds.x,
y: bounds.y,
show: true,
autoHideMenuBar: true,
transparent: true,
frame: false,
alwaysOnTop: false,
skipTaskbar: true,
backgroundColor: isMac ? undefined : theme === 'dark' ? '#181818' : '#FFFFFF',
resizable: false,
vibrancy: 'popover',
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
webSecurity: false
}
})
// 点击其他地方时隐藏窗口
this.selectionMenuWindow.on('blur', () => {
this.selectionMenuWindow?.hide()
this.miniWindow?.webContents.send('selection-action', {
action: 'home',
selectedText: this.lastSelectedText
})
})
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
this.selectionMenuWindow.loadURL(process.env['ELECTRON_RENDERER_URL'] + '/src/windows/menu/menu.html')
} else {
this.selectionMenuWindow.loadFile(join(__dirname, '../renderer/src/windows/menu/menu.html'))
}
this.setupSelectionMenuEvents()
}
private setupSelectionMenuEvents() {
if (!this.selectionMenuWindow) return
ipcMain.removeHandler('selection-menu:action')
ipcMain.handle('selection-menu:action', (_, action) => {
this.selectionMenuWindow?.hide()
this.showMiniWindow()
setTimeout(() => {
this.miniWindow?.webContents.send('selection-action', {
action,
selectedText: this.lastSelectedText
})
}, 100)
})
}
public setLastSelectedText(text: string) {
this.lastSelectedText = text
}
}
export const windowService = WindowService.getInstance()

View File

@@ -1,19 +1,22 @@
import * as crypto from 'crypto'
// 定义密钥和初始化向量IV
const secretKey = 'kDQvWz5slot3syfucoo53X6KKsEUJoeFikpiUWRJTLIo3zcUPpFvEa009kK13KCr'
const iv = Buffer.from('Cherry Studio', 'hex')
// 加密函数
export function encrypt(text: string, secretKey: string, iv: string): { iv: string; encryptedData: string } {
const _iv = Buffer.from(iv, 'hex')
const cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(secretKey), _iv)
export function encrypt(text: string): { iv: string; encryptedData: string } {
const cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(secretKey), iv)
let encrypted = cipher.update(text, 'utf8', 'hex')
encrypted += cipher.final('hex')
return {
iv: _iv.toString('hex'),
iv: iv.toString('hex'),
encryptedData: encrypted
}
}
// 解密函数
export function decrypt(encryptedData: string, iv: string, secretKey: string): string {
export function decrypt(encryptedData: string, iv: string): string {
const decipher = crypto.createDecipheriv('aes-256-cbc', Buffer.from(secretKey), Buffer.from(iv, 'hex'))
let decrypted = decipher.update(encryptedData, 'hex', 'utf8')
decrypted += decipher.final('utf8')

View File

@@ -1,76 +1,12 @@
import * as fs from 'node:fs'
import path from 'node:path'
import { audioExts, documentExts, imageExts, textExts, videoExts } from '@shared/config/constant'
import { FileType, FileTypes } from '@types'
import { app } from 'electron'
import { v4 as uuidv4 } from 'uuid'
// 创建文件类型映射表,提高查找效率
const fileTypeMap = new Map<string, FileTypes>()
// 初始化映射表
function initFileTypeMap() {
imageExts.forEach((ext) => fileTypeMap.set(ext, FileTypes.IMAGE))
videoExts.forEach((ext) => fileTypeMap.set(ext, FileTypes.VIDEO))
audioExts.forEach((ext) => fileTypeMap.set(ext, FileTypes.AUDIO))
textExts.forEach((ext) => fileTypeMap.set(ext, FileTypes.TEXT))
documentExts.forEach((ext) => fileTypeMap.set(ext, FileTypes.DOCUMENT))
}
// 初始化映射表
initFileTypeMap()
import { FileTypes } from '@types'
export function getFileType(ext: string): FileTypes {
ext = ext.toLowerCase()
return fileTypeMap.get(ext) || FileTypes.OTHER
}
export function getAllFiles(dirPath: string, arrayOfFiles: FileType[] = []): FileType[] {
const files = fs.readdirSync(dirPath)
files.forEach((file) => {
if (file.startsWith('.')) {
return
}
const fullPath = path.join(dirPath, file)
if (fs.statSync(fullPath).isDirectory()) {
arrayOfFiles = getAllFiles(fullPath, arrayOfFiles)
} else {
const ext = path.extname(file)
const fileType = getFileType(ext)
if ([FileTypes.OTHER, FileTypes.IMAGE, FileTypes.VIDEO, FileTypes.AUDIO].includes(fileType)) {
return
}
const name = path.basename(file)
const size = fs.statSync(fullPath).size
const fileItem: FileType = {
id: uuidv4(),
name,
path: fullPath,
size,
ext,
count: 1,
origin_name: name,
type: fileType,
created_at: new Date().toISOString()
}
arrayOfFiles.push(fileItem)
}
})
return arrayOfFiles
}
export function getTempDir() {
return path.join(app.getPath('temp'), 'CherryStudio')
}
export function getFilesDir() {
return path.join(app.getPath('userData'), 'Data', 'Files')
if (imageExts.includes(ext)) return FileTypes.IMAGE
if (videoExts.includes(ext)) return FileTypes.VIDEO
if (audioExts.includes(ext)) return FileTypes.AUDIO
if (textExts.includes(ext)) return FileTypes.TEXT
if (documentExts.includes(ext)) return FileTypes.DOCUMENT
return FileTypes.OTHER
}

Some files were not shown because too many files have changed in this diff Show More