Compare commits
209 Commits
hlink/OMO1
...
feat/auto-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d98c7e7405 | ||
|
|
779d4c4787 | ||
|
|
32f160444b | ||
|
|
a546c265ee | ||
|
|
ea89a37b1d | ||
|
|
c288b4a8d0 | ||
|
|
e8384db91a | ||
|
|
36c87451d9 | ||
|
|
eaa37fe674 | ||
|
|
308ad9f68f | ||
|
|
62b6584d65 | ||
|
|
5a44f6aca8 | ||
|
|
4c6a904929 | ||
|
|
be4ef2990f | ||
|
|
2807e71f1a | ||
|
|
a5f8ac8587 | ||
|
|
4bd50251ff | ||
|
|
f0d60052c4 | ||
|
|
84f4b565f3 | ||
|
|
ebdacdde3e | ||
|
|
aeb66195a0 | ||
|
|
53ef8b0f32 | ||
|
|
794c23f296 | ||
|
|
62440cbfa1 | ||
|
|
39b723f143 | ||
|
|
bdc75f2f4e | ||
|
|
eb3f136997 | ||
|
|
6ba5768650 | ||
|
|
1f588d242e | ||
|
|
25b1e309ed | ||
|
|
7a7b24fe2f | ||
|
|
0686b2d813 | ||
|
|
4a027892b9 | ||
|
|
be323b6304 | ||
|
|
2f5cfc0162 | ||
|
|
4a00eb57ad | ||
|
|
784b02e62e | ||
|
|
4c2c026f6d | ||
|
|
07b2c6f169 | ||
|
|
aafd04090e | ||
|
|
af10ae3f37 | ||
|
|
3df4680c7b | ||
|
|
5d04ef2508 | ||
|
|
7fb6eb1949 | ||
|
|
4fe99cddce | ||
|
|
a84763def6 | ||
|
|
8125fac309 | ||
|
|
6c6b2f0b9e | ||
|
|
314be9b198 | ||
|
|
409e0096d8 | ||
|
|
a1ffabae41 | ||
|
|
0fa10627bc | ||
|
|
80618b2331 | ||
|
|
bf8baedfcf | ||
|
|
98f2c8a0b6 | ||
|
|
3887cf2a6f | ||
|
|
eb89c6ea21 | ||
|
|
fd09edc2b9 | ||
|
|
55a9447a7b | ||
|
|
c576aa5cb4 | ||
|
|
ca553a2454 | ||
|
|
ef9c8fd037 | ||
|
|
234a5e085f | ||
|
|
cb22b80ead | ||
|
|
a6d9ad6716 | ||
|
|
185900ada6 | ||
|
|
288ebe5222 | ||
|
|
6e91066e5d | ||
|
|
49a7b2dc8b | ||
|
|
4789ba3e8f | ||
|
|
cc18f0f0c3 | ||
|
|
9bb96c212d | ||
|
|
81eab1179b | ||
|
|
24c9a8e8f1 | ||
|
|
c4d0f8e950 | ||
|
|
3bdf0be4ad | ||
|
|
9e4ebf7c6f | ||
|
|
2408566d34 | ||
|
|
cf61ae927c | ||
|
|
60680936d3 | ||
|
|
5bfa13112a | ||
|
|
7f7db748a7 | ||
|
|
55aac1cb9b | ||
|
|
4663794ba6 | ||
|
|
e4de5331e0 | ||
|
|
c7ed15684a | ||
|
|
1bb27ee3f9 | ||
|
|
579d7d1e5d | ||
|
|
36aa13c4f1 | ||
|
|
c5161b9da4 | ||
|
|
32c96daf1f | ||
|
|
30309c29ff | ||
|
|
8b462935b4 | ||
|
|
d907344ca7 | ||
|
|
9b21c334cc | ||
|
|
3e1e814004 | ||
|
|
1c5adc1329 | ||
|
|
3360905275 | ||
|
|
0a28df132d | ||
|
|
fd3d9f17b8 | ||
|
|
73f8148a94 | ||
|
|
456f0657a6 | ||
|
|
53f74725ed | ||
|
|
4fa04a801a | ||
|
|
c5580f5b71 | ||
|
|
f8f808c9f4 | ||
|
|
dbbd539207 | ||
|
|
703eae5777 | ||
|
|
9438c8e6ff | ||
|
|
75f986087a | ||
|
|
7ac8f480bb | ||
|
|
676ac21804 | ||
|
|
24ddd69cd5 | ||
|
|
35c50b54a8 | ||
|
|
ac0fe75078 | ||
|
|
f0c25f8108 | ||
|
|
c10e5a9ca4 | ||
|
|
444abc9b88 | ||
|
|
2d130a8526 | ||
|
|
5061ee5c4d | ||
|
|
9e913f531c | ||
|
|
98130de8ac | ||
|
|
b339b7b6d4 | ||
|
|
eb8ee5ec02 | ||
|
|
a34e10cb0d | ||
|
|
fcae5b097b | ||
|
|
3cb339e480 | ||
|
|
35224f5213 | ||
|
|
b8b37fcd11 | ||
|
|
615fda0547 | ||
|
|
0585d28312 | ||
|
|
9ad40b9219 | ||
|
|
18e99dee67 | ||
|
|
43ef1d6815 | ||
|
|
141904e61a | ||
|
|
247501c26c | ||
|
|
748252febc | ||
|
|
8ac4d07d6b | ||
|
|
5fbff8c1fe | ||
|
|
f0f44d5768 | ||
|
|
8c20bd6d8f | ||
|
|
d1c2bbed1b | ||
|
|
f372ebe485 | ||
|
|
833873b95e | ||
|
|
1d211ee9f7 | ||
|
|
c7ef2c5791 | ||
|
|
6e66721688 | ||
|
|
ffc8a33ccf | ||
|
|
81538a5446 | ||
|
|
e6b325dd88 | ||
|
|
0e8c053cee | ||
|
|
24e46efa0c | ||
|
|
64200b00a9 | ||
|
|
ab4fb7d1d6 | ||
|
|
352731827c | ||
|
|
e13a43d82a | ||
|
|
e51de5b492 | ||
|
|
a54360cc69 | ||
|
|
88cbb27557 | ||
|
|
e22d076d67 | ||
|
|
de7f806bbc | ||
|
|
5f7d8652bc | ||
|
|
39c614e4db | ||
|
|
412e8b03fc | ||
|
|
486ccc1a15 | ||
|
|
c6ab7b9326 | ||
|
|
41981acd77 | ||
|
|
97ef7016d3 | ||
|
|
e4514bd04c | ||
|
|
f39bb9869b | ||
|
|
fc884d72af | ||
|
|
883bdd6283 | ||
|
|
fa19f41385 | ||
|
|
8b95a131ec | ||
|
|
72e18fbcc1 | ||
|
|
b62c59eb52 | ||
|
|
ffe7702c1c | ||
|
|
1ed6320caf | ||
|
|
315271ac35 | ||
|
|
0bd24f652d | ||
|
|
0e7c4e4bdd | ||
|
|
d4bf8da225 | ||
|
|
8eb6632620 | ||
|
|
10225512f4 | ||
|
|
76058bd749 | ||
|
|
a692ae7e9d | ||
|
|
a70ca190ba | ||
|
|
7c39116351 | ||
|
|
04333535dd | ||
|
|
a1dba93d27 | ||
|
|
0842b7e84d | ||
|
|
24d6d146c0 | ||
|
|
978c3ea3cf | ||
|
|
a9eb235c43 | ||
|
|
e0a47de8f7 | ||
|
|
78a4696327 | ||
|
|
57fa0aad38 | ||
|
|
2e0251aed7 | ||
|
|
afd1381d7f | ||
|
|
c3b5cbee8f | ||
|
|
e1f255048e | ||
|
|
8a579be4c1 | ||
|
|
efcffbaa30 | ||
|
|
f9c6bddae5 | ||
|
|
5e086a1686 | ||
|
|
0db4c8b475 | ||
|
|
d5fcef39d3 | ||
|
|
5c44f71684 | ||
|
|
3462be2a2a |
14
.github/ISSUE_TEMPLATE/#0_bug_report.yml
vendored
14
.github/ISSUE_TEMPLATE/#0_bug_report.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: 🐛 错误报告 (中文)
|
||||
description: 创建一个报告以帮助我们改进
|
||||
title: '[错误]: '
|
||||
labels: ['bug']
|
||||
labels: ['kind/bug']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
@@ -18,7 +18,9 @@ body:
|
||||
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),没有找到类似的问题。
|
||||
- label: 我的问题不是 [常见问题](https://github.com/CherryHQ/cherry-studio/issues/3881) 中的内容。
|
||||
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
|
||||
@@ -48,8 +50,8 @@ body:
|
||||
id: description
|
||||
attributes:
|
||||
label: 错误描述
|
||||
description: 描述问题时请尽可能详细
|
||||
placeholder: 告诉我们发生了什么...
|
||||
description: 描述问题时请尽可能详细。请尽可能提供截图或屏幕录制,以帮助我们更好地理解问题。
|
||||
placeholder: 告诉我们发生了什么...(记得附上截图/录屏,如果适用)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -57,12 +59,14 @@ body:
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: 重现步骤
|
||||
description: 提供详细的重现步骤,以便于我们可以准确地重现问题
|
||||
description: 提供详细的重现步骤,以便于我们的开发人员可以准确地重现问题。请尽可能为每个步骤提供截图或屏幕录制。
|
||||
placeholder: |
|
||||
1. 转到 '...'
|
||||
2. 点击 '....'
|
||||
3. 向下滚动到 '....'
|
||||
4. 看到错误
|
||||
|
||||
记得尽可能为每个步骤附上截图/录屏!
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: 💡 功能建议 (中文)
|
||||
description: 为项目提出新的想法
|
||||
title: '[功能]: '
|
||||
labels: ['enhancement']
|
||||
labels: ['kind/enhancement']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/#2_question.yml
vendored
2
.github/ISSUE_TEMPLATE/#2_question.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: ❓ 讨论 & 提问 (中文)
|
||||
description: 寻求帮助、讨论问题、提出疑问等...
|
||||
title: '[讨论]: '
|
||||
labels: ['question']
|
||||
labels: ['kind/question']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
|
||||
6
.github/ISSUE_TEMPLATE/0_bug_report.yml
vendored
6
.github/ISSUE_TEMPLATE/0_bug_report.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: 🐛 Bug Report (English)
|
||||
description: Create a report to help us improve
|
||||
title: '[Bug]: '
|
||||
labels: ['bug']
|
||||
labels: ['kind/bug']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
@@ -18,7 +18,9 @@ body:
|
||||
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.
|
||||
- label: My issue is not listed in the [FAQ](https://github.com/CherryHQ/cherry-studio/issues/3881).
|
||||
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
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/1_feature_request.yml
vendored
2
.github/ISSUE_TEMPLATE/1_feature_request.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: 💡 Feature Request (English)
|
||||
description: Suggest an idea for this project
|
||||
title: '[Feature]: '
|
||||
labels: ['enhancement']
|
||||
labels: ['kind/enhancement']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/2_question.yml
vendored
2
.github/ISSUE_TEMPLATE/2_question.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: ❓ Discussion & Questions
|
||||
description: Seeking help, discussing issues, asking questions, etc...
|
||||
title: '[Discussion]: '
|
||||
labels: ['question']
|
||||
labels: ['kind/question']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
|
||||
54
.github/pull_request_template.md
vendored
Normal file
54
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
<!-- Template from https://github.com/kubevirt/kubevirt/blob/main/.github/PULL_REQUEST_TEMPLATE.md?-->
|
||||
<!-- Thanks for sending a pull request! Here are some tips for you:
|
||||
1. Consider creating this PR as draft: https://github.com/CherryHQ/cherry-studio/blob/main/CONTRIBUTING.md
|
||||
-->
|
||||
|
||||
### What this PR does
|
||||
|
||||
Before this PR:
|
||||
|
||||
After this PR:
|
||||
|
||||
<!-- (optional, in `fixes #<issue number>(, fixes #<issue_number>, ...)` format, will close the issue(s) when PR gets merged)*: -->
|
||||
|
||||
Fixes #
|
||||
|
||||
### Why we need it and why it was done in this way
|
||||
|
||||
The following tradeoffs were made:
|
||||
|
||||
The following alternatives were considered:
|
||||
|
||||
Links to places where the discussion took place: <!-- optional: slack, other GH issue, mailinglist, ... -->
|
||||
|
||||
### Breaking changes
|
||||
|
||||
<!-- optional -->
|
||||
|
||||
If this PR introduces breaking changes, please describe the changes and the impact on users.
|
||||
|
||||
### Special notes for your reviewer
|
||||
|
||||
<!-- optional -->
|
||||
|
||||
### Checklist
|
||||
|
||||
This checklist is not enforcing, but it's a reminder of items that could be relevant to every PR.
|
||||
Approvers are expected to review this list.
|
||||
|
||||
- [ ] PR: The PR description is expressive enough and will help future contributors
|
||||
- [ ] Code: [Write code that humans can understand](https://en.wikiquote.org/wiki/Martin_Fowler#code-for-humans) and [Keep it simple](https://en.wikipedia.org/wiki/KISS_principle)
|
||||
- [ ] Refactor: You have [left the code cleaner than you found it (Boy Scout Rule)](https://learning.oreilly.com/library/view/97-things-every/9780596809515/ch08.html)
|
||||
- [ ] Upgrade: Impact of this change on upgrade flows was considered and addressed if required
|
||||
- [ ] Documentation: A [user-guide update](https://docs.cherry-ai.com) was considered and is present (link) or not required. You want a user-guide update if it's a user facing feature.
|
||||
|
||||
### Release note
|
||||
|
||||
<!-- Write your release note:
|
||||
1. Enter your extended release note in the below block. If the PR requires additional action from users switching to the new release, include the string "action required".
|
||||
2. If no release note is required, just write "NONE".
|
||||
-->
|
||||
|
||||
```release-note
|
||||
|
||||
```
|
||||
39
.github/workflows/issue-management.yml
vendored
Normal file
39
.github/workflows/issue-management.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
name: "Stale Issue Management"
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 0 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
daysBeforeStale: 30 # Number of days of inactivity before marking as stale
|
||||
daysBeforeClose: 30 # Number of days to wait after marking as stale before closing
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
if: github.repository_owner == 'CherryHQ'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write # Workaround for https://github.com/actions/stale/issues/1090
|
||||
issues: write
|
||||
# Completely disable stalling for PRs
|
||||
pull-requests: none
|
||||
contents: none
|
||||
steps:
|
||||
- name: Close inactive issues
|
||||
uses: actions/stale@v9
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: ${{ env.daysBeforeStale }}
|
||||
days-before-close: ${{ env.daysBeforeClose }}
|
||||
stale-issue-label: "inactive"
|
||||
stale-issue-message: |
|
||||
This issue has been inactive for a prolonged period and will be closed automatically in ${{ env.daysBeforeClose }} days.
|
||||
该问题已长时间处于闲置状态,${{ env.daysBeforeClose }} 天后将自动关闭。
|
||||
exempt-issue-labels: "pending, Dev Team, enhancement"
|
||||
days-before-pr-stale: -1 # Completely disable stalling for PRs
|
||||
days-before-pr-close: -1 # Completely disable closing for PRs
|
||||
|
||||
# Temporary to reduce the huge issues number
|
||||
operations-per-run: 100
|
||||
debug-only: false
|
||||
75
.github/workflows/nightly-build.yml
vendored
75
.github/workflows/nightly-build.yml
vendored
@@ -7,9 +7,41 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write # Required for deleting artifacts
|
||||
|
||||
jobs:
|
||||
cleanup-artifacts:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Delete old artifacts
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
# Calculate the date 14 days ago
|
||||
cutoff_date=$(date -d "14 days ago" +%Y-%m-%d)
|
||||
|
||||
# List and delete artifacts older than cutoff date
|
||||
gh api repos/$REPO/actions/artifacts --paginate | \
|
||||
jq -r '.artifacts[] | select(.name | startswith("cherry-studio-nightly-")) | select(.created_at < "'$cutoff_date'") | .id' | \
|
||||
while read artifact_id; do
|
||||
echo "Deleting artifact $artifact_id"
|
||||
gh api repos/$REPO/actions/artifacts/$artifact_id -X DELETE
|
||||
done
|
||||
|
||||
check-repository:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
should_run: ${{ github.repository == 'CherryHQ/cherry-studio' }}
|
||||
steps:
|
||||
- name: Check if running in main repository
|
||||
run: |
|
||||
echo "Running in repository: ${{ github.repository }}"
|
||||
echo "Should run: ${{ github.repository == 'CherryHQ/cherry-studio' }}"
|
||||
|
||||
nightly-build:
|
||||
needs: check-repository
|
||||
if: needs.check-repository.outputs.should_run == 'true'
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
strategy:
|
||||
@@ -26,6 +58,11 @@ jobs:
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: macos-latest dependencies fix
|
||||
if: matrix.os == 'macos-latest'
|
||||
run: |
|
||||
brew install python-setuptools
|
||||
|
||||
- name: Install corepack
|
||||
run: corepack enable && corepack prepare yarn@4.6.0 --activate
|
||||
|
||||
@@ -59,6 +96,7 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
|
||||
- name: Build Mac
|
||||
if: matrix.os == 'macos-latest'
|
||||
@@ -73,19 +111,17 @@ jobs:
|
||||
APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
|
||||
- name: Build Windows
|
||||
if: matrix.os == 'windows-latest'
|
||||
run: |
|
||||
yarn build:npm windows
|
||||
yarn build:win:x64
|
||||
yarn build:win:arm64
|
||||
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
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
|
||||
- name: Rename artifacts with nightly format
|
||||
shell: bash
|
||||
@@ -96,39 +132,24 @@ jobs:
|
||||
# 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 \;
|
||||
find dist -name "*-x64-setup.exe" -exec cp {} renamed-artifacts/cherry-studio-nightly-${DATE}-x64-setup.exe \;
|
||||
find dist -name "*-arm64-setup.exe" -exec cp {} renamed-artifacts/cherry-studio-nightly-${DATE}-arm64-setup.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
|
||||
# Portable exe
|
||||
find dist -name "*-x64-portable.exe" -exec cp {} renamed-artifacts/cherry-studio-nightly-${DATE}-x64-portable.exe \;
|
||||
find dist -name "*-arm64-portable.exe" -exec cp {} renamed-artifacts/cherry-studio-nightly-${DATE}-arm64-portable.exe \;
|
||||
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 \;
|
||||
find dist -name "*-x86_64.AppImage" -exec cp {} renamed-artifacts/cherry-studio-nightly-${DATE}-x86_64.AppImage \;
|
||||
find dist -name "*-arm64.AppImage" -exec cp {} renamed-artifacts/cherry-studio-nightly-${DATE}-arm64.AppImage \;
|
||||
fi
|
||||
|
||||
# Copy update files
|
||||
|
||||
14
.github/workflows/release.yml
vendored
14
.github/workflows/release.yml
vendored
@@ -6,7 +6,7 @@ on:
|
||||
tag:
|
||||
description: 'Release tag (e.g. v1.0.0)'
|
||||
required: true
|
||||
default: 'v0.9.18'
|
||||
default: 'v1.0.0'
|
||||
push:
|
||||
tags:
|
||||
- v*.*.*
|
||||
@@ -42,6 +42,11 @@ jobs:
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: macos-latest dependencies fix
|
||||
if: matrix.os == 'macos-latest'
|
||||
run: |
|
||||
brew install python-setuptools
|
||||
|
||||
- name: Install corepack
|
||||
run: corepack enable && corepack prepare yarn@4.6.0 --activate
|
||||
|
||||
@@ -71,10 +76,12 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
|
||||
- name: Build Mac
|
||||
if: matrix.os == 'macos-latest'
|
||||
run: |
|
||||
sudo -H pip install setuptools
|
||||
yarn build:npm mac
|
||||
yarn build:mac
|
||||
env:
|
||||
@@ -85,6 +92,7 @@ jobs:
|
||||
APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
|
||||
- name: Build Windows
|
||||
if: matrix.os == 'windows-latest'
|
||||
@@ -94,9 +102,7 @@ jobs:
|
||||
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
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
|
||||
- name: Release
|
||||
uses: ncipollo/release-action@v1
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -46,3 +46,8 @@ local
|
||||
.aider*
|
||||
.cursorrules
|
||||
.cursor/rules
|
||||
|
||||
# test
|
||||
coverage
|
||||
.vitest-cache
|
||||
vitest.config.*.timestamp-*
|
||||
|
||||
10
.vscode/settings.json
vendored
10
.vscode/settings.json
vendored
@@ -31,5 +31,13 @@
|
||||
"[markdown]": {
|
||||
"files.trimTrailingWhitespace": false
|
||||
},
|
||||
"i18n-ally.localesPaths": ["src/renderer/src/i18n"]
|
||||
"i18n-ally.localesPaths": ["src/renderer/src/i18n/locales"],
|
||||
"i18n-ally.enabledFrameworks": ["react-i18next", "i18next"],
|
||||
"i18n-ally.keystyle": "nested", // 翻译路径格式
|
||||
"i18n-ally.sortKeys": true, // 排序
|
||||
"i18n-ally.namespace": true, // 开启命名空间
|
||||
"i18n-ally.enabledParsers": ["ts", "js", "json"], // 解析语言
|
||||
"i18n-ally.sourceLanguage": "en-us", // 翻译源语言
|
||||
"i18n-ally.displayLanguage": "zh-cn",
|
||||
"i18n-ally.fullReloadOnChanged": true // 界面显示语言
|
||||
}
|
||||
|
||||
92
.yarn/patches/app-builder-lib-npm-26.0.13-a064c9e1d0.patch
vendored
Normal file
92
.yarn/patches/app-builder-lib-npm-26.0.13-a064c9e1d0.patch
vendored
Normal file
@@ -0,0 +1,92 @@
|
||||
diff --git a/out/electron/ElectronFramework.js b/out/electron/ElectronFramework.js
|
||||
index 5a4b4546870ee9e770d5a50d79790d39baabd268..3f0ac05dfd6bbaeaf5f834341a823718bd10f55c 100644
|
||||
--- a/out/electron/ElectronFramework.js
|
||||
+++ b/out/electron/ElectronFramework.js
|
||||
@@ -55,26 +55,27 @@ async function removeUnusedLanguagesIfNeeded(options) {
|
||||
if (!wantedLanguages.length) {
|
||||
return;
|
||||
}
|
||||
- const { dir, langFileExt } = getLocalesConfig(options);
|
||||
+ const { dirs, langFileExt } = getLocalesConfig(options);
|
||||
// noinspection SpellCheckingInspection
|
||||
- await (0, tiny_async_pool_1.default)(builder_util_1.MAX_FILE_REQUESTS, await (0, fs_extra_1.readdir)(dir), async (file) => {
|
||||
- if (!file.endsWith(langFileExt)) {
|
||||
+ const deletedFiles = async (dir) => {
|
||||
+ await (0, tiny_async_pool_1.default)(builder_util_1.MAX_FILE_REQUESTS, await (0, fs_extra_1.readdir)(dir), async (file) => {
|
||||
+ if (!file.endsWith(langFileExt)) {
|
||||
+ return;
|
||||
+ }
|
||||
+ const language = file.substring(0, file.length - langFileExt.length);
|
||||
+ if (!wantedLanguages.includes(language)) {
|
||||
+ return fs.rm(path.join(dir, file), { recursive: true, force: true });
|
||||
+ }
|
||||
return;
|
||||
- }
|
||||
- const language = file.substring(0, file.length - langFileExt.length);
|
||||
- if (!wantedLanguages.includes(language)) {
|
||||
- return fs.rm(path.join(dir, file), { recursive: true, force: true });
|
||||
- }
|
||||
- return;
|
||||
- });
|
||||
+ });
|
||||
+ };
|
||||
+ await Promise.all(dirs.map(deletedFiles));
|
||||
function getLocalesConfig(options) {
|
||||
const { appOutDir, packager } = options;
|
||||
if (packager.platform === index_1.Platform.MAC) {
|
||||
- return { dir: packager.getResourcesDir(appOutDir), langFileExt: ".lproj" };
|
||||
- }
|
||||
- else {
|
||||
- return { dir: path.join(packager.getResourcesDir(appOutDir), "..", "locales"), langFileExt: ".pak" };
|
||||
+ return { dirs: [packager.getResourcesDir(appOutDir), packager.getMacOsElectronFrameworkResourcesDir(appOutDir)], langFileExt: ".lproj" };
|
||||
}
|
||||
+ return { dirs: [path.join(packager.getResourcesDir(appOutDir), "..", "locales")], langFileExt: ".pak" };
|
||||
}
|
||||
}
|
||||
class ElectronFramework {
|
||||
diff --git a/out/node-module-collector/index.d.ts b/out/node-module-collector/index.d.ts
|
||||
index 8e808be0fa0d5971b9f9605c8eb88f71630e34b7..1b97dccd8a150a67c4312d2ba4757960e624045b 100644
|
||||
--- a/out/node-module-collector/index.d.ts
|
||||
+++ b/out/node-module-collector/index.d.ts
|
||||
@@ -2,6 +2,6 @@ import { NpmNodeModulesCollector } from "./npmNodeModulesCollector";
|
||||
import { PnpmNodeModulesCollector } from "./pnpmNodeModulesCollector";
|
||||
import { detect, PM, getPackageManagerVersion } from "./packageManager";
|
||||
import { NodeModuleInfo } from "./types";
|
||||
-export declare function getCollectorByPackageManager(rootDir: string): Promise<NpmNodeModulesCollector | PnpmNodeModulesCollector>;
|
||||
+export declare function getCollectorByPackageManager(rootDir: string): Promise<PnpmNodeModulesCollector | NpmNodeModulesCollector>;
|
||||
export declare function getNodeModules(rootDir: string): Promise<NodeModuleInfo[]>;
|
||||
export { detect, getPackageManagerVersion, PM };
|
||||
diff --git a/out/platformPackager.d.ts b/out/platformPackager.d.ts
|
||||
index 2df1ba2725c54c7b0e8fed67ab52e94f0cdb17bc..c7ff756564cfd216d2c7d8f72f367527010c06f9 100644
|
||||
--- a/out/platformPackager.d.ts
|
||||
+++ b/out/platformPackager.d.ts
|
||||
@@ -67,6 +67,7 @@ export declare abstract class PlatformPackager<DC extends PlatformSpecificBuildO
|
||||
getElectronSrcDir(dist: string): string;
|
||||
getElectronDestinationDir(appOutDir: string): string;
|
||||
getResourcesDir(appOutDir: string): string;
|
||||
+ getMacOsElectronFrameworkResourcesDir(appOutDir: string): string;
|
||||
getMacOsResourcesDir(appOutDir: string): string;
|
||||
private checkFileInPackage;
|
||||
private sanityCheckPackage;
|
||||
diff --git a/out/platformPackager.js b/out/platformPackager.js
|
||||
index 6f799ce0d1cdb5f0b18a9c8187b2db84b3567aa9..879248e6c6786d3473e1a80e3930d3a8d0190aab 100644
|
||||
--- a/out/platformPackager.js
|
||||
+++ b/out/platformPackager.js
|
||||
@@ -465,12 +465,13 @@ class PlatformPackager {
|
||||
if (this.platform === index_1.Platform.MAC) {
|
||||
return this.getMacOsResourcesDir(appOutDir);
|
||||
}
|
||||
- else if ((0, Framework_1.isElectronBased)(this.info.framework)) {
|
||||
+ if ((0, Framework_1.isElectronBased)(this.info.framework)) {
|
||||
return path.join(appOutDir, "resources");
|
||||
}
|
||||
- else {
|
||||
- return appOutDir;
|
||||
- }
|
||||
+ return appOutDir;
|
||||
+ }
|
||||
+ getMacOsElectronFrameworkResourcesDir(appOutDir) {
|
||||
+ return path.join(appOutDir, `${this.appInfo.productFilename}.app`, "Contents", "Frameworks", "Electron Framework.framework", "Resources");
|
||||
}
|
||||
getMacOsResourcesDir(appOutDir) {
|
||||
return path.join(appOutDir, `${this.appInfo.productFilename}.app`, "Contents", "Resources");
|
||||
38
.yarn/patches/electron-updater-npm-6.6.3-9269dbaf84.patch
vendored
Normal file
38
.yarn/patches/electron-updater-npm-6.6.3-9269dbaf84.patch
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
diff --git a/out/MacUpdater.js b/out/MacUpdater.js
|
||||
index 8f18dc5416c91835ded4e47f2358fba680c129ac..a3fb43c2450dc3484bf099b5ea79a362a3b372cc 100644
|
||||
--- a/out/MacUpdater.js
|
||||
+++ b/out/MacUpdater.js
|
||||
@@ -74,7 +74,7 @@ class MacUpdater extends AppUpdater_1.AppUpdater {
|
||||
else {
|
||||
files = files.filter(file => !isArm64(file));
|
||||
}
|
||||
- const zipFileInfo = (0, Provider_1.findFile)(files, "zip", ["pkg", "dmg"]);
|
||||
+ const zipFileInfo = (0, Provider_1.findFile)(files, "zip", ["pkg", "dmg"], false /*has been filtered by myself*/);
|
||||
if (zipFileInfo == null) {
|
||||
throw (0, builder_util_runtime_1.newError)(`ZIP file not provided: ${(0, builder_util_runtime_1.safeStringifyJson)(files)}`, "ERR_UPDATER_ZIP_FILE_NOT_FOUND");
|
||||
}
|
||||
diff --git a/out/providers/Provider.js b/out/providers/Provider.js
|
||||
index 9829dff7e95aa9baa0bfdf29f52e6f761c9b7243..6ecaade9e294c87c03bb42e77ff5463f2782cb3c 100644
|
||||
--- a/out/providers/Provider.js
|
||||
+++ b/out/providers/Provider.js
|
||||
@@ -61,11 +61,18 @@ class Provider {
|
||||
}
|
||||
}
|
||||
exports.Provider = Provider;
|
||||
-function findFile(files, extension, not) {
|
||||
+function findFile(files, extension, not, filterByArch = true) {
|
||||
if (files.length === 0) {
|
||||
throw (0, builder_util_runtime_1.newError)("No files provided", "ERR_UPDATER_NO_FILES_PROVIDED");
|
||||
}
|
||||
- const result = files.find(it => it.url.pathname.toLowerCase().endsWith(`.${extension.toLowerCase()}`));
|
||||
+ const result = files
|
||||
+ .filter(file => {
|
||||
+ if (!filterByArch) {
|
||||
+ return true;
|
||||
+ }
|
||||
+ return (process.arch == "arm64") === (file.url.pathname.includes("arm64") || file.info.url.includes("arm64"));
|
||||
+ })
|
||||
+ .find(it => it.url.pathname.toLowerCase().endsWith(`.${extension.toLowerCase()}`));
|
||||
if (result != null) {
|
||||
return result;
|
||||
}
|
||||
@@ -1,45 +1,73 @@
|
||||
# Cherry Studio 贡献者指南
|
||||
[中文](./docs/CONTRIBUTING.zh.md) | [English](./CONTRIBUTING.md)
|
||||
|
||||
欢迎来到 Cherry Studio 的贡献者社区!我们致力于将 Cherry Studio 打造成一个长期提供价值的项目,并希望邀请更多的开发者加入我们的行列。无论您是经验丰富的开发者还是刚刚起步的初学者,您的贡献都将帮助我们更好地服务用户,提升软件质量。
|
||||
# Cherry Studio Contributor Guide
|
||||
|
||||
## 如何贡献
|
||||
Welcome to the Cherry Studio contributor community! We are committed to making Cherry Studio a project that provides long-term value and hope to invite more developers to join us. Whether you are an experienced developer or a beginner just starting out, your contributions will help us better serve users and improve software quality.
|
||||
|
||||
以下是您可以参与的几种方式:
|
||||
## How to Contribute
|
||||
|
||||
1. **贡献代码**:帮助我们开发新功能或优化现有代码。请确保您的代码符合我们的编码标准,并通过所有测试。
|
||||
Here are several ways you can participate:
|
||||
|
||||
2. **修复 BUG**:如果您发现了 BUG,欢迎提交修复方案。请在提交前确认问题已被解决,并附上相关测试。
|
||||
1. **Contribute Code**: Help us develop new features or optimize existing code. Please ensure your code adheres to our coding standards and passes all tests.
|
||||
|
||||
3. **维护 Issue**:协助我们管理 GitHub 上的 issue,帮助标记、分类和解决问题。
|
||||
2. **Fix Bugs**: If you find a bug, you are welcome to submit a fix. Please confirm the issue is resolved before submitting and include relevant tests.
|
||||
|
||||
4. **产品设计**:参与产品设计讨论,帮助我们改进用户体验和界面设计。
|
||||
3. **Maintain Issues**: Help us manage issues on GitHub by assisting with tagging, classifying, and resolving problems.
|
||||
|
||||
5. **编写文档**:帮助我们完善用户手册、API 文档和开发者指南。
|
||||
4. **Product Design**: Participate in product design discussions to help us improve user experience and interface design.
|
||||
|
||||
6. **社区维护**:参与社区讨论,帮助解答用户问题,促进社区活跃。
|
||||
5. **Write Documentation**: Help us improve the user manual, API documentation, and developer guides.
|
||||
|
||||
7. **推广使用**:通过博客、社交媒体等渠道推广 Cherry Studio,吸引更多用户和开发者。
|
||||
6. **Community Maintenance**: Participate in community discussions, help answer user questions, and promote community activity.
|
||||
|
||||
## 开始贡献
|
||||
7. **Promote Usage**: Promote Cherry Studio through blogs, social media, and other channels to attract more users and developers.
|
||||
|
||||
1. **Fork 仓库**:在 GitHub 上 fork 我们的仓库,并将其克隆到本地。
|
||||
## Before You Start
|
||||
|
||||
2. **创建分支**:为您要进行的更改创建一个新的分支。
|
||||
Please make sure you have read the [Code of Conduct](CODE_OF_CONDUCT.md) and the [LICENSE](LICENSE).
|
||||
|
||||
3. **提交更改**:在本地进行更改并提交。请确保您的提交信息清晰明了。
|
||||
## Getting Started
|
||||
|
||||
4. **发起 Pull Request**:将您的更改推送到 GitHub,并发起 Pull Request。请描述您的更改内容和原因。
|
||||
To help you get familiar with the codebase, we recommend tackling issues tagged with one or more of the following labels: [good-first-issue](https://github.com/CherryHQ/cherry-studio/labels/good%20first%20issue), [help-wanted](https://github.com/CherryHQ/cherry-studio/labels/help%20wanted), or [kind/bug](https://github.com/CherryHQ/cherry-studio/labels/kind%2Fbug). Any help is welcome.
|
||||
|
||||
### 其他建议
|
||||
### Testing
|
||||
|
||||
- **联系开发者**:在提交 PR 之前,您可以先和开发者进行联系,共同探讨或者获取帮助。
|
||||
- **成为核心开发者**:如果您能够稳定为项目贡献,恭喜您可以成为项目核心开发者,获取到项目成员身份。
|
||||
Features without tests are considered non-existent. To ensure code is truly effective, relevant processes should be covered by unit tests and functional tests. Therefore, when considering contributions, please also consider testability. All tests can be run locally without dependency on CI. Please refer to the "Testing" section in the [Developer Guide](docs/dev.md).
|
||||
|
||||
## 联系我们
|
||||
### Automated Testing for Pull Requests
|
||||
|
||||
如果您有任何问题或建议,欢迎通过以下方式联系我们:
|
||||
Automated tests are triggered on pull requests (PRs) opened by members of the Cherry Studio organization, except for draft PRs. PRs opened by new contributors will initially be marked with the `needs-ok-to-test` label and will not be automatically tested. Once a Cherry Studio organization member adds `/ok-to-test` to the PR, the test pipeline will be created.
|
||||
|
||||
- 微信:kangfenmao
|
||||
### Consider Opening Your Pull Request as a Draft
|
||||
|
||||
Not all pull requests are ready for review when created. This might be because the author wants to start a discussion, they are not entirely sure if the changes are heading in the right direction, or the changes are not yet complete. Please consider creating these PRs as [draft pull requests](https://github.blog/2019-02-14-introducing-draft-pull-requests/). Draft PRs are skipped by CI, thus saving CI resources. This also means reviewers will not be automatically assigned, and the community will understand that this PR is not yet ready for review.
|
||||
Reviewers will be assigned after you mark the draft pull request as ready for review.
|
||||
|
||||
### Contributor Compliance with Project Terms
|
||||
|
||||
We require every contributor to certify that they have the right to legally contribute to our project. Contributors express this by consciously signing their commits, thereby indicating their compliance with the [LICENSE](LICENSE).
|
||||
A signed commit is one where the commit message includes the following:
|
||||
|
||||
You can generate a signed commit using the following command [git commit --signoff](https://git-scm.com/docs/git-commit#Documentation/git-commit.txt---signoff):
|
||||
|
||||
```
|
||||
git commit --signoff -m "Your commit message"
|
||||
```
|
||||
|
||||
### Getting Code Reviewed/Merged
|
||||
|
||||
Maintainers are here to help you implement your use case within a reasonable timeframe. They will do their best to review your code and provide constructive feedback promptly. However, if you get stuck during the review process or feel your Pull Request is not receiving the attention it deserves, please contact us via comments in the Issue or through the [Community](README.md#-community).
|
||||
|
||||
### Other Suggestions
|
||||
|
||||
- **Contact Developers**: Before submitting a PR, you can contact the developers first to discuss or get help.
|
||||
- **Become a Core Developer**: If you contribute to the project consistently, congratulations, you can become a core developer and gain project membership status. Please check our [Membership Guide](https://github.com/CherryHQ/community/blob/main/docs/membership.en.md).
|
||||
|
||||
## Contact Us
|
||||
|
||||
If you have any questions or suggestions, feel free to contact us through the following ways:
|
||||
|
||||
- WeChat: kangfenmao
|
||||
- [GitHub Issues](https://github.com/CherryHQ/cherry-studio/issues)
|
||||
|
||||
感谢您的支持和贡献!我们期待与您一起将 Cherry Studio 打造成更好的产品。
|
||||
Thank you for your support and contributions! We look forward to working with you to make Cherry Studio a better product.
|
||||
|
||||
111
LICENSE
111
LICENSE
@@ -1,62 +1,87 @@
|
||||
**许可协议**
|
||||
**许可协议 (Licensing)**
|
||||
|
||||
本软件采用 Apache License 2.0 许可。除 Apache License 2.0 规定的条款外,您在使用 Cherry Studio 时还应遵守以下附加条款:
|
||||
本项目采用**区分用户的双重许可 (User-Segmented Dual Licensing)** 模式。
|
||||
|
||||
**一. 商用许可**
|
||||
**核心原则:**
|
||||
|
||||
在以下任何一种情况下,您需要联系我们并获得明确的书面商业授权后,方可继续使用 Cherry Studio 材料:
|
||||
* **个人用户 和 10人及以下企业/组织:** 默认适用 **GNU Affero 通用公共许可证 v3.0 (AGPLv3)**。
|
||||
* **超过10人的企业/组织:** **必须** 获取 **商业许可证 (Commercial License)**。
|
||||
|
||||
1. **修改与衍生**: 您对 Cherry Studio 材料进行修改或基于其进行衍生开发(包括但不限于修改应用名称、Logo、代码、功能、界面,数据等)。
|
||||
2. **企业服务**: 在您的企业内部,或为企业客户提供基于 CherryStudio 的服务,且该服务支持 10 人及以上累计用户使用。
|
||||
3. **硬件捆绑销售**: 您将 Cherry Studio 预装或集成到硬件设备或产品中进行捆绑销售。
|
||||
4. **政府或教育机构大规模采购**: 您的使用场景属于政府或教育机构的大规模采购项目,特别是涉及安全、数据隐私等敏感需求时。
|
||||
5. **面向公众的公有云服务**:基于 Cherry Studio,提供面向公众的公有云服务。
|
||||
|
||||
**二. 贡献者协议**
|
||||
|
||||
作为 Cherry Studio 的贡献者,您应当同意以下条款:
|
||||
|
||||
1. **许可调整**:生产者有权根据需要对开源协议进行调整,使其更加严格或宽松。
|
||||
2. **商业用途**:您贡献的代码可能会被用于商业用途,包括但不限于云业务运营。
|
||||
|
||||
**三. 其他条款**
|
||||
|
||||
1. 本协议条款的解释权归 Cherry Studio 开发者所有。
|
||||
2. 本协议可能根据实际情况进行更新,更新时将通过本软件通知用户。
|
||||
|
||||
如有任何问题或需申请商业授权,请联系 Cherry Studio 开发团队。
|
||||
|
||||
除上述特定条件外,其他所有权利和限制均遵循 Apache License 2.0。有关 Apache License 2.0 的详细信息,请访问 http://www.apache.org/licenses/LICENSE-2.0。
|
||||
定义:“10人及以下”
|
||||
指在您的组织(包括公司、非营利组织、政府机构、教育机构等任何实体)中,能够访问、使用或以任何方式直接或间接受益于本软件(Cherry Studio)功能的个人总数不超过10人。这包括但不限于开发者、测试人员、运营人员、最终用户、通过集成系统间接使用者等。
|
||||
|
||||
---
|
||||
|
||||
**1. 开源许可证 (Open Source License): AGPLv3 - 适用于个人及10人及以下组织**
|
||||
|
||||
**License Agreement**
|
||||
* 如果您是个人用户,或者您的组织满足上述“10人及以下”的定义,您可以在 **AGPLv3** 的条款下自由使用、修改和分发 Cherry Studio。AGPLv3 的完整文本可以访问 [https://www.gnu.org/licenses/agpl-3.0.html](https://www.gnu.org/licenses/agpl-3.0.html) 获取。
|
||||
* **核心义务:** AGPLv3 的一个关键要求是,如果您修改了 Cherry Studio 并通过网络提供服务,或者分发了修改后的版本,您必须以 AGPLv3 许可证向接收者提供相应的**完整源代码**。即使您符合“10人及以下”的标准,如果您希望避免此源代码公开义务,您也需要考虑获取商业许可证(见下文)。
|
||||
* 使用前请务必仔细阅读并理解 AGPLv3 的所有条款。
|
||||
|
||||
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:
|
||||
**2. 商业许可证 (Commercial License) - 适用于超过10人的组织,或希望规避 AGPLv3 义务的用户**
|
||||
|
||||
**I. Commercial Licensing**
|
||||
* **强制要求:** 如果您的组织**不**满足上述“10人及以下”的定义(即有11人或更多人可以访问、使用或受益于本软件),您**必须**联系我们获取并签署一份商业许可证才能使用 Cherry Studio。
|
||||
* **自愿选择:** 即使您的组织满足“10人及以下”的条件,但如果您的使用场景**无法满足 AGPLv3 的条款要求**(特别是关于**源代码公开**的义务),或者您需要 AGPLv3 **未提供**的特定商业条款(如保证、赔偿、无 Copyleft 限制等),您也**必须**联系我们获取并签署一份商业许可证。
|
||||
* **需要商业许可证的常见情况包括(但不限于):**
|
||||
* 您的组织规模超过10人。
|
||||
* (无论组织规模)您希望分发修改过的 Cherry Studio 版本,但**不希望**根据 AGPLv3 公开您修改部分的源代码。
|
||||
* (无论组织规模)您希望基于修改过的 Cherry Studio 提供网络服务(SaaS),但**不希望**根据 AGPLv3 向服务使用者提供修改后的源代码。
|
||||
* (无论组织规模)您的公司政策、客户合同或项目要求不允许使用 AGPLv3 许可的软件,或要求闭源分发及保密。
|
||||
* 商业许可证将为您提供豁免 AGPLv3 义务(如源代码公开)的权利,并可能包含额外的商业保障条款。
|
||||
* **获取商业许可:** 请通过邮箱 **bd@cherry-ai.com** 联系 Cherry Studio 开发团队洽谈商业授权事宜。
|
||||
|
||||
You must contact us and obtain explicit written commercial authorization to continue using Cherry Studio materials under any of the following circumstances:
|
||||
**3. 贡献 (Contributions)**
|
||||
|
||||
1. **Modifications and Derivatives:** You modify Cherry Studio materials or perform derivative development based on them (including but not limited to changing the application’s 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.
|
||||
* 我们欢迎社区对 Cherry Studio 的贡献。所有向本项目提交的贡献都将被视为在 **AGPLv3** 许可证下提供。
|
||||
* 通过向本项目提交贡献(例如通过 Pull Request),即表示您同意您的代码以 AGPLv3 许可证授权给本项目及所有后续使用者(无论这些使用者最终遵循 AGPLv3 还是商业许可)。
|
||||
* 您也理解并同意,您的贡献可能会被包含在根据商业许可证分发的 Cherry Studio 版本中。
|
||||
|
||||
**II. Contributor Agreement**
|
||||
**4. 其他条款 (Other Terms)**
|
||||
|
||||
As a contributor to Cherry Studio, you must agree to the following terms:
|
||||
* 关于商业许可证的具体条款和条件,以双方签署的正式商业许可协议为准。
|
||||
* 项目维护者保留根据需要更新本许可政策(包括用户规模定义和阈值)的权利。相关更新将通过项目官方渠道(如代码仓库、官方网站)进行通知。
|
||||
|
||||
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.
|
||||
---
|
||||
|
||||
**III. Other Terms**
|
||||
**Licensing**
|
||||
|
||||
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.
|
||||
This project employs a **User-Segmented Dual Licensing** model.
|
||||
|
||||
If you have any questions or need to apply for commercial authorization, please contact the Cherry Studio development team.
|
||||
**Core Principle:**
|
||||
|
||||
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.
|
||||
* **Individual Users and Organizations with 10 or Fewer Individuals:** Governed by default under the **GNU Affero General Public License v3.0 (AGPLv3)**.
|
||||
* **Organizations with More Than 10 Individuals:** **Must** obtain a **Commercial License**.
|
||||
|
||||
Definition: "10 or Fewer Individuals"
|
||||
Refers to any organization (including companies, non-profits, government agencies, educational institutions, etc.) where the total number of individuals who can access, use, or in any way directly or indirectly benefit from the functionality of this software (Cherry Studio) does not exceed 10. This includes, but is not limited to, developers, testers, operations staff, end-users, and indirect users via integrated systems.
|
||||
|
||||
---
|
||||
|
||||
**1. Open Source License: AGPLv3 - For Individuals and Organizations of 10 or Fewer**
|
||||
|
||||
* If you are an individual user, or if your organization meets the "10 or Fewer Individuals" definition above, you are free to use, modify, and distribute Cherry Studio under the terms of the **AGPLv3**. The full text of the AGPLv3 can be found in the LICENSE file at [https://www.gnu.org/licenses/agpl-3.0.html](https://www.gnu.org/licenses/agpl-3.0.html).
|
||||
* **Core Obligation:** A key requirement of the AGPLv3 is that if you modify Cherry Studio and make it available over a network, or distribute the modified version, you must provide the **complete corresponding source code** under the AGPLv3 license to the recipients. Even if you qualify under the "10 or Fewer Individuals" rule, if you wish to avoid this source code disclosure obligation, you will need to obtain a Commercial License (see below).
|
||||
* Please read and understand the full terms of the AGPLv3 carefully before use.
|
||||
|
||||
**2. Commercial License - For Organizations with More Than 10 Individuals, or Users Needing to Avoid AGPLv3 Obligations**
|
||||
|
||||
* **Mandatory Requirement:** If your organization does **not** meet the "10 or Fewer Individuals" definition above (i.e., 11 or more individuals can access, use, or benefit from the software), you **must** contact us to obtain and execute a Commercial License to use Cherry Studio.
|
||||
* **Voluntary Option:** Even if your organization meets the "10 or Fewer Individuals" condition, if your intended use case **cannot comply with the terms of the AGPLv3** (particularly the obligations regarding **source code disclosure**), or if you require specific commercial terms **not offered** by the AGPLv3 (such as warranties, indemnities, or freedom from copyleft restrictions), you also **must** contact us to obtain and execute a Commercial License.
|
||||
* **Common scenarios requiring a Commercial License include (but are not limited to):**
|
||||
* Your organization has more than 10 individuals who can access, use, or benefit from the software.
|
||||
* (Regardless of organization size) You wish to distribute a modified version of Cherry Studio but **do not want** to disclose the source code of your modifications under AGPLv3.
|
||||
* (Regardless of organization size) You wish to provide a network service (SaaS) based on a modified version of Cherry Studio but **do not want** to provide the modified source code to users of the service under AGPLv3.
|
||||
* (Regardless of organization size) Your corporate policies, client contracts, or project requirements prohibit the use of AGPLv3-licensed software or mandate closed-source distribution and confidentiality.
|
||||
* The Commercial License grants you rights exempting you from AGPLv3 obligations (like source code disclosure) and may include additional commercial assurances.
|
||||
* **Obtaining a Commercial License:** Please contact the Cherry Studio development team via email at **bd@cherry-ai.com** to discuss commercial licensing options.
|
||||
|
||||
**3. Contributions**
|
||||
|
||||
* We welcome community contributions to Cherry Studio. All contributions submitted to this project are considered to be offered under the **AGPLv3** license.
|
||||
* By submitting a contribution to this project (e.g., via a Pull Request), you agree to license your code under the AGPLv3 to the project and all its downstream users (regardless of whether those users ultimately operate under AGPLv3 or a Commercial License).
|
||||
* You also understand and agree that your contribution may be included in distributions of Cherry Studio offered under our commercial license.
|
||||
|
||||
**4. Other Terms**
|
||||
|
||||
* The specific terms and conditions of the Commercial License are governed by the formal commercial license agreement signed by both parties.
|
||||
* The project maintainers reserve the right to update this licensing policy (including the definition and threshold for user count) as needed. Updates will be communicated through official project channels (e.g., code repository, official website).
|
||||
|
||||
12
README.md
12
README.md
@@ -13,7 +13,7 @@
|
||||
|
||||
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)
|
||||
👏 Join [Telegram Group](https://t.me/CherryStudioAI)|[Discord](https://discord.gg/wez8HtpxqQ) | [QQ Group(575014769)](https://qm.qq.com/q/lo0D4qVZKi)
|
||||
|
||||
❤️ Like Cherry Studio? Give it a star 🌟 or [Sponsor](docs/sponsor.md) to support the development!
|
||||
|
||||
@@ -23,14 +23,12 @@ https://docs.cherry-ai.com
|
||||
|
||||
# 🌠 Screenshot
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
# 🌟 Key Features
|
||||
|
||||

|
||||
|
||||
1. **Diverse LLM Provider Support**:
|
||||
|
||||
- ☁️ Major LLM Cloud Services: OpenAI, Gemini, Anthropic, and more
|
||||
@@ -87,6 +85,8 @@ https://docs.cherry-ai.com
|
||||
- Theme Gallery: https://cherrycss.com
|
||||
- Aero Theme: https://github.com/hakadao/CherryStudio-Aero
|
||||
- PaperMaterial Theme: https://github.com/rainoffallingstar/CherryStudio-PaperMaterial
|
||||
- Claude dynamic-style: https://github.com/bjl101501/CherryStudio-Claudestyle-dynamic
|
||||
- Maple Neon Theme: https://github.com/BoningtonChen/CherryStudio_themes
|
||||
|
||||
Welcome PR for more themes
|
||||
|
||||
|
||||
@@ -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://releases.cherry-ai.com
|
||||
|
||||
77
docs/CONTRIBUTING.zh.md
Normal file
77
docs/CONTRIBUTING.zh.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Cherry Studio 贡献者指南
|
||||
|
||||
[**English**](../CONTRIBUTING.md) | [**中文**](./CONTRIBUTING.zh.md)
|
||||
|
||||
欢迎来到 Cherry Studio 的贡献者社区!我们致力于将 Cherry Studio 打造成一个长期提供价值的项目,并希望邀请更多的开发者加入我们的行列。无论您是经验丰富的开发者还是刚刚起步的初学者,您的贡献都将帮助我们更好地服务用户,提升软件质量。
|
||||
|
||||
## 如何贡献
|
||||
|
||||
以下是您可以参与的几种方式:
|
||||
|
||||
1. **贡献代码**:帮助我们开发新功能或优化现有代码。请确保您的代码符合我们的编码标准,并通过所有测试。
|
||||
|
||||
2. **修复 BUG**:如果您发现了 BUG,欢迎提交修复方案。请在提交前确认问题已被解决,并附上相关测试。
|
||||
|
||||
3. **维护 Issue**:协助我们管理 GitHub 上的 issue,帮助标记、分类和解决问题。
|
||||
|
||||
4. **产品设计**:参与产品设计讨论,帮助我们改进用户体验和界面设计。
|
||||
|
||||
5. **编写文档**:帮助我们完善用户手册、API 文档和开发者指南。
|
||||
|
||||
6. **社区维护**:参与社区讨论,帮助解答用户问题,促进社区活跃。
|
||||
|
||||
7. **推广使用**:通过博客、社交媒体等渠道推广 Cherry Studio,吸引更多用户和开发者。
|
||||
|
||||
## 开始之前
|
||||
|
||||
请确保阅读了[行为准则](CODE_OF_CONDUCT.md)和[LICENSE](LICENSE)。
|
||||
|
||||
## 开始贡献
|
||||
|
||||
为了让您更熟悉代码,建议您处理一些标记有以下标签之一或多个的问题:[good-first-issue](https://github.com/CherryHQ/cherry-studio/labels/good%20first%20issue)、[help-wanted](https://github.com/CherryHQ/cherry-studio/labels/help%20wanted) 或 [kind/bug](https://github.com/CherryHQ/cherry-studio/labels/kind%2Fbug)。任何帮助都会收到欢迎。
|
||||
|
||||
### 测试
|
||||
|
||||
未经测试的功能等同于不存在。为确保代码真正有效,应通过单元测试和功能测试覆盖相关流程。因此,在考虑贡献时,也请考虑可测试性。所有测试均可本地运行,无需依赖 CI。请参阅[开发者指南](docs/dev.md#test)中的“Test”部分。
|
||||
|
||||
### 拉取请求的自动化测试
|
||||
|
||||
自动化测试会在 Cherry Studio 组织成员开启的拉取请求(PR)上触发,草稿 PR 除外。新贡献者开启的 PR 最初会标记为 needs-ok-to-test 标签且不自动测试。待 Cherry Studio 组织成员在 PR 上添加 /ok-to-test 后,测试通道将被创建。
|
||||
|
||||
### 考虑将您的拉取请求作为草稿打开
|
||||
|
||||
并非所有拉取请求在创建时就准备好接受审查。这可能是因为作者想发起讨论,或者他们不完全确定更改是否朝着正确的方向发展,甚至可能是因为更改尚未完成。请考虑将这些 PR 创建为[草稿拉取请求](https://github.blog/2019-02-14-introducing-draft-pull-requests/)。草稿 PR 会被CI跳过,从而节省CI资源。这也意味着审阅者不会被自动分配,社区会理解此 PR 尚未准备好接受审阅。
|
||||
在您将草稿拉取请求标记为准备审核后,审核人员将被分配
|
||||
|
||||
### 贡献者遵守项目条款
|
||||
|
||||
我们要求每位贡献者证明他们有权合法地为我们的项目做出贡献。贡献者通过有意识地签署他们的提交来表达这一点,并通过这一行为表明他们遵守许可证[LICENSE](LICENSE)。
|
||||
签名提交是指提交信息中包含以下内容的提交:
|
||||
|
||||
```
|
||||
Signed-off-by: Your Name <your.email@example.com>
|
||||
```
|
||||
|
||||
您可以通过以下命令[git commit --signoff](https://git-scm.com/docs/git-commit#Documentation/git-commit.txt---signoff)生成签名提交:
|
||||
|
||||
```
|
||||
git commit --signoff -m "Your commit message"
|
||||
```
|
||||
|
||||
### 获取代码审查/合并
|
||||
|
||||
维护者在此帮助您在合理时间内实现您的用例。他们会尽力在合理时间内审查您的代码并提供建设性反馈。但如果您在审查过程中受阻,或认为您的 Pull Request 未得到应有的关注,请通过 Issue 中的评论或者[社群](README.md#-community)联系我们
|
||||
|
||||
### 其他建议
|
||||
|
||||
- **联系开发者**:在提交 PR 之前,您可以先和开发者进行联系,共同探讨或者获取帮助。
|
||||
- **成为核心开发者**:如果您能够稳定为项目贡献,恭喜您可以成为项目核心开发者,获取到项目成员身份。请查看我们的[成员指南](https://github.com/CherryHQ/community/blob/main/membership.md)
|
||||
|
||||
## 联系我们
|
||||
|
||||
如果您有任何问题或建议,欢迎通过以下方式联系我们:
|
||||
|
||||
- 微信:kangfenmao
|
||||
- [GitHub Issues](https://github.com/CherryHQ/cherry-studio/issues)
|
||||
|
||||
感谢您的支持和贡献!我们期待与您一起将 Cherry Studio 打造成更好的产品。
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
Cherry Studio は、複数の LLM プロバイダーをサポートするデスクトップクライアントで、Windows、Mac、Linux で利用可能です。
|
||||
|
||||
👏 [Telegram](https://t.me/CherryStudioAI)|[Discord](https://discord.gg/wez8HtpxqQ) | [QQグループ(472019156)](https://qm.qq.com/q/CbZiBWwCXu)
|
||||
👏 [Telegram](https://t.me/CherryStudioAI)|[Discord](https://discord.gg/wez8HtpxqQ) | [QQグループ(575014769)](https://qm.qq.com/q/lo0D4qVZKi)
|
||||
|
||||
❤️ Cherry Studio をお気に入りにしましたか?小さな星をつけてください 🌟 または [スポンサー](sponsor.md) をして開発をサポートしてください!❤️
|
||||
|
||||
@@ -24,14 +24,12 @@ https://docs.cherry-ai.com
|
||||
|
||||
# 🌠 スクリーンショット
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
# 🌟 主な機能
|
||||
|
||||

|
||||
|
||||
1. **多様な LLM サービス対応**:
|
||||
|
||||
- ☁️ 主要な LLM クラウドサービス対応:OpenAI、Gemini、Anthropic など
|
||||
@@ -85,8 +83,11 @@ https://docs.cherry-ai.com
|
||||
|
||||
# 🌈 テーマ
|
||||
|
||||
テーマギャラリー: https://cherrycss.com
|
||||
Aero テーマ: https://github.com/hakadao/CherryStudio-Aero
|
||||
- テーマギャラリー: https://cherrycss.com
|
||||
- Aero テーマ: https://github.com/hakadao/CherryStudio-Aero
|
||||
- PaperMaterial テーマ: https://github.com/rainoffallingstar/CherryStudio-PaperMaterial
|
||||
- Claude テーマ: https://github.com/bjl101501/CherryStudio-Claudestyle-dynamic
|
||||
- メープルネオンテーマ: https://github.com/BoningtonChen/CherryStudio_themes
|
||||
|
||||
より多くのテーマのPRを歓迎します
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
Cherry Studio 是一款支持多个大语言模型(LLM)服务商的桌面客户端,兼容 Windows、Mac 和 Linux 系统。
|
||||
|
||||
👏 欢迎加入 [Telegram 群组](https://t.me/CherryStudioAI)|[Discord](https://discord.gg/wez8HtpxqQ) | [QQ群(472019156)](https://qm.qq.com/q/CbZiBWwCXu)
|
||||
👏 欢迎加入 [Telegram 群组](https://t.me/CherryStudioAI)|[Discord](https://discord.gg/wez8HtpxqQ) | [QQ群(575014769)](https://qm.qq.com/q/lo0D4qVZKi)
|
||||
|
||||
❤️ 喜欢 Cherry Studio? 点亮小星星 🌟 或 [赞助开发者](sponsor.md)! ❤️
|
||||
|
||||
@@ -24,14 +24,12 @@ https://docs.cherry-ai.com
|
||||
|
||||
# 🌠 界面
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
# 🌟 主要特性
|
||||
|
||||

|
||||
|
||||
1. **多样化 LLM 服务支持**:
|
||||
|
||||
- ☁️ 支持主流 LLM 云服务:OpenAI、Gemini、Anthropic、硅基流动等
|
||||
@@ -85,8 +83,11 @@ https://docs.cherry-ai.com
|
||||
|
||||
# 🌈 主题
|
||||
|
||||
主题库:https://cherrycss.com
|
||||
Aero 主题:https://github.com/hakadao/CherryStudio-Aero
|
||||
- 主题库:https://cherrycss.com
|
||||
- Aero 主题:https://github.com/hakadao/CherryStudio-Aero
|
||||
- PaperMaterial 主题: https://github.com/rainoffallingstar/CherryStudio-PaperMaterial
|
||||
- 仿Claude 主题: https://github.com/bjl101501/CherryStudio-Claudestyle-dynamic
|
||||
- 霓虹枫叶字体主题: https://github.com/BoningtonChen/CherryStudio_themes
|
||||
|
||||
欢迎 PR 更多主题
|
||||
|
||||
@@ -113,7 +114,7 @@ Aero 主题:https://github.com/hakadao/CherryStudio-Aero
|
||||
3. **提交更改**:提交并推送您的更改。
|
||||
4. **打开 Pull Request**:描述您的更改和原因。
|
||||
|
||||
有关更详细的指南,请参阅我们的 [贡献指南](../CONTRIBUTING.md)。
|
||||
有关更详细的指南,请参阅我们的 [贡献指南](./CONTRIBUTING.zh.md)。
|
||||
|
||||
感谢您的支持和贡献!
|
||||
|
||||
|
||||
@@ -37,6 +37,12 @@ yarn install
|
||||
yarn dev
|
||||
```
|
||||
|
||||
### Test
|
||||
|
||||
```bash
|
||||
yarn test
|
||||
```
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
appId: com.kangfenmao.CherryStudio
|
||||
productName: Cherry Studio
|
||||
electronLanguages:
|
||||
- zh-CN
|
||||
- zh-TW
|
||||
- en-US
|
||||
- ja # macOS/linux/win
|
||||
- ru # macOS/linux/win
|
||||
- zh_CN # for macOS
|
||||
- zh_TW # for macOS
|
||||
- en # for macOS
|
||||
directories:
|
||||
buildResources: build
|
||||
files:
|
||||
@@ -29,7 +38,7 @@ files:
|
||||
- '!node_modules/mammoth/{mammoth.browser.js,mammoth.browser.min.js}'
|
||||
asarUnpack:
|
||||
- resources/**
|
||||
- '**/*.{node,dll,metal,exp,lib}'
|
||||
- '**/*.{metal,exp,lib}'
|
||||
win:
|
||||
executableName: Cherry Studio
|
||||
artifactName: ${productName}-${version}-${arch}-setup.${ext}
|
||||
@@ -44,6 +53,7 @@ nsis:
|
||||
allowToChangeInstallationDirectory: true
|
||||
oneClick: false
|
||||
include: build/nsis-installer.nsh
|
||||
buildUniversalInstaller: false
|
||||
portable:
|
||||
artifactName: ${productName}-${version}-${arch}-portable.${ext}
|
||||
mac:
|
||||
@@ -57,35 +67,29 @@ mac:
|
||||
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
|
||||
target:
|
||||
- target: dmg
|
||||
arch:
|
||||
- arm64
|
||||
- x64
|
||||
- target: zip
|
||||
arch:
|
||||
- arm64
|
||||
- x64
|
||||
linux:
|
||||
artifactName: ${productName}-${version}-${arch}.${ext}
|
||||
target:
|
||||
- target: AppImage
|
||||
arch:
|
||||
- arm64
|
||||
- x64
|
||||
maintainer: electronjs.org
|
||||
category: Utility
|
||||
desktop:
|
||||
entry:
|
||||
StartupWMClass: CherryStudio
|
||||
publish:
|
||||
# provider: generic
|
||||
# url: https://cherrystudio.ocool.online
|
||||
provider: github
|
||||
repo: cherry-studio
|
||||
owner: CherryHQ
|
||||
provider: generic
|
||||
url: https://releases.cherry-ai.com
|
||||
electronDownload:
|
||||
mirror: https://npmmirror.com/mirrors/electron/
|
||||
afterPack: scripts/after-pack.js
|
||||
afterSign: scripts/notarize.js
|
||||
artifactBuildCompleted: scripts/artifact-build-completed.js
|
||||
releaseInfo:
|
||||
releaseNotes: |
|
||||
知识库和服务商界面更新
|
||||
增加 Dangbei 小程序
|
||||
可以强制使用搜索引擎覆盖模型自带搜索能力
|
||||
修复部分公式无法正常渲染问题
|
||||
新增对 grok-2-image 和 gpt-4o-image 图像支持
|
||||
支持 Windows 便携版使用 data 目录存储数据
|
||||
MCP 界面改版,新增描述信息显示
|
||||
Mermaid 渲染逻辑优化
|
||||
支持关闭公示渲染
|
||||
修复 OpenAI 类型渲染错误
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import react from '@vitejs/plugin-react'
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
||||
import { resolve } from 'path'
|
||||
import { visualizer } from 'rollup-plugin-visualizer'
|
||||
@@ -52,19 +52,17 @@ export default defineConfig({
|
||||
renderer: {
|
||||
plugins: [
|
||||
react({
|
||||
babel: {
|
||||
plugins: [
|
||||
[
|
||||
'styled-components',
|
||||
{
|
||||
displayName: true, // 开发环境下启用组件名称
|
||||
fileName: false, // 不在类名中包含文件名
|
||||
pure: true, // 优化性能
|
||||
ssr: false // 不需要服务端渲染
|
||||
}
|
||||
]
|
||||
plugins: [
|
||||
[
|
||||
'@swc/plugin-styled-components',
|
||||
{
|
||||
displayName: true, // 开发环境下启用组件名称
|
||||
fileName: false, // 不在类名中包含文件名
|
||||
pure: true, // 优化性能
|
||||
ssr: false // 不需要服务端渲染
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}),
|
||||
...visualizerPlugin('renderer')
|
||||
],
|
||||
|
||||
59
package.json
59
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "CherryStudio",
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.8",
|
||||
"private": true,
|
||||
"description": "A powerful AI assistant for producer.",
|
||||
"main": "./out/main/index.js",
|
||||
@@ -23,13 +23,13 @@
|
||||
"build": "npm run typecheck && electron-vite build",
|
||||
"build:check": "yarn test && yarn typecheck && yarn check:i18n",
|
||||
"build:unpack": "dotenv npm run build && electron-builder --dir",
|
||||
"build:win": "dotenv npm run build && electron-builder --win",
|
||||
"build:win": "dotenv npm run build && electron-builder --win --x64 --arm64",
|
||||
"build:win:x64": "dotenv npm run build && electron-builder --win --x64",
|
||||
"build:win:arm64": "dotenv npm run build && electron-builder --win --arm64",
|
||||
"build:mac": "dotenv electron-vite build && electron-builder --mac",
|
||||
"build:mac": "dotenv electron-vite build && electron-builder --mac --arm64 --x64",
|
||||
"build:mac:arm64": "dotenv electron-vite build && electron-builder --mac --arm64",
|
||||
"build:mac:x64": "dotenv electron-vite build && electron-builder --mac --x64",
|
||||
"build:linux": "dotenv electron-vite build && electron-builder --linux",
|
||||
"build:linux": "dotenv electron-vite build && electron-builder --linux --x64 --arm64",
|
||||
"build:linux:arm64": "dotenv electron-vite build && electron-builder --linux --arm64",
|
||||
"build:linux:x64": "dotenv electron-vite build && electron-builder --linux --x64",
|
||||
"build:npm": "node scripts/build-npm.js",
|
||||
@@ -44,7 +44,12 @@
|
||||
"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",
|
||||
"test": "yarn test:renderer",
|
||||
"test:coverage": "yarn test:renderer:coverage",
|
||||
"test:node": "npx -y tsx --test src/**/*.test.ts",
|
||||
"test:renderer": "vitest run",
|
||||
"test:renderer:ui": "vitest --ui",
|
||||
"test:renderer:coverage": "vitest run --coverage",
|
||||
"format": "prettier --write .",
|
||||
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
@@ -64,43 +69,44 @@
|
||||
"@cherrystudio/embedjs-openai": "^0.1.28",
|
||||
"@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",
|
||||
"@strongtz/win32-arm64-msvc": "^0.4.7",
|
||||
"@tryfabric/martian": "^1.2.4",
|
||||
"@types/react-infinite-scroll-component": "^5.0.0",
|
||||
"@xyflow/react": "^12.4.4",
|
||||
"adm-zip": "^0.5.16",
|
||||
"archiver": "^7.0.1",
|
||||
"async-mutex": "^0.5.0",
|
||||
"bufferutil": "^4.0.9",
|
||||
"color": "^5.0.0",
|
||||
"diff": "^7.0.0",
|
||||
"docx": "^9.0.2",
|
||||
"electron-log": "^5.1.5",
|
||||
"electron-store": "^8.2.0",
|
||||
"electron-updater": "^6.3.9",
|
||||
"electron-updater": "patch:electron-updater@npm%3A6.6.3#~/.yarn/patches/electron-updater-npm-6.6.3-9269dbaf84.patch",
|
||||
"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",
|
||||
"extract-zip": "^2.0.1",
|
||||
"fast-xml-parser": "^5.2.0",
|
||||
"fetch-socks": "^1.3.2",
|
||||
"fs-extra": "^11.2.0",
|
||||
"got-scraping": "^4.1.1",
|
||||
"jsdom": "^26.0.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"node-stream-zip": "^1.15.0",
|
||||
"officeparser": "^4.1.1",
|
||||
"os-proxy-config": "^1.1.2",
|
||||
"proxy-agent": "^6.5.0",
|
||||
"tar": "^7.4.3",
|
||||
"tiny-pinyin": "^1.3.2",
|
||||
"turndown": "^7.2.0",
|
||||
"turndown-plugin-gfm": "^1.0.2",
|
||||
"undici": "^7.4.0",
|
||||
"webdav": "^5.8.0",
|
||||
"ws": "^8.18.1",
|
||||
"zipread": "^1.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@agentic/exa": "^7.3.3",
|
||||
"@agentic/searxng": "^7.3.3",
|
||||
"@agentic/tavily": "^7.3.3",
|
||||
"@analytics/google-analytics": "^1.1.0",
|
||||
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
||||
"@anthropic-ai/sdk": "^0.38.0",
|
||||
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
|
||||
@@ -110,12 +116,15 @@
|
||||
"@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/genai": "^0.10.0",
|
||||
"@hello-pangea/dnd": "^16.6.0",
|
||||
"@kangfenmao/keyv-storage": "^0.1.0",
|
||||
"@modelcontextprotocol/sdk": "^1.9.0",
|
||||
"@modelcontextprotocol/sdk": "^1.10.2",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@notionhq/client": "^2.2.15",
|
||||
"@reduxjs/toolkit": "^2.2.5",
|
||||
"@shikijs/markdown-it": "^3.2.2",
|
||||
"@swc/plugin-styled-components": "^7.1.3",
|
||||
"@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",
|
||||
@@ -130,8 +139,11 @@
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@types/react-infinite-scroll-component": "^5.0.0",
|
||||
"@types/tinycolor2": "^1",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"analytics": "^0.8.16",
|
||||
"@types/ws": "^8",
|
||||
"@vitejs/plugin-react-swc": "^3.9.0",
|
||||
"@vitest/coverage-v8": "^3.1.1",
|
||||
"@vitest/ui": "^3.1.1",
|
||||
"@xyflow/react": "^12.4.4",
|
||||
"antd": "^5.22.5",
|
||||
"applescript": "^1.0.0",
|
||||
"axios": "^1.7.3",
|
||||
@@ -142,7 +154,7 @@
|
||||
"dexie-react-hooks": "^1.1.7",
|
||||
"dotenv-cli": "^7.4.2",
|
||||
"electron": "31.7.6",
|
||||
"electron-builder": "^24.13.3",
|
||||
"electron-builder": "26.0.13",
|
||||
"electron-devtools-installer": "^3.2.0",
|
||||
"electron-icon-builder": "^2.0.1",
|
||||
"electron-vite": "^2.3.0",
|
||||
@@ -158,6 +170,7 @@
|
||||
"lint-staged": "^15.5.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lru-cache": "^11.1.0",
|
||||
"lucide-react": "^0.487.0",
|
||||
"mime": "^4.0.4",
|
||||
"npx-scope-finder": "^1.2.0",
|
||||
"openai": "patch:openai@npm%3A4.87.3#~/.yarn/patches/openai-npm-4.87.3-2b30a7685f.patch",
|
||||
@@ -184,14 +197,16 @@
|
||||
"remark-math": "^6.0.0",
|
||||
"rollup-plugin-visualizer": "^5.12.0",
|
||||
"sass": "^1.77.2",
|
||||
"shiki": "^3.2.1",
|
||||
"shiki": "^3.2.2",
|
||||
"string-width": "^7.2.0",
|
||||
"styled-components": "^6.1.11",
|
||||
"tiny-pinyin": "^1.3.2",
|
||||
"tinycolor2": "^1.6.0",
|
||||
"tokenx": "^0.4.1",
|
||||
"typescript": "^5.6.2",
|
||||
"uuid": "^10.0.0",
|
||||
"vite": "^5.0.12"
|
||||
"vite": "6.2.6",
|
||||
"vitest": "^3.1.1"
|
||||
},
|
||||
"resolutions": {
|
||||
"pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch",
|
||||
@@ -200,7 +215,9 @@
|
||||
"node-gyp": "^9.1.0",
|
||||
"libsql@npm:^0.4.4": "patch:libsql@npm%3A0.4.7#~/.yarn/patches/libsql-npm-0.4.7-444e260fb1.patch",
|
||||
"openai@npm:^4.77.0": "patch:openai@npm%3A4.87.3#~/.yarn/patches/openai-npm-4.87.3-2b30a7685f.patch",
|
||||
"pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch"
|
||||
"pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch",
|
||||
"app-builder-lib@npm:26.0.13": "patch:app-builder-lib@npm%3A26.0.13#~/.yarn/patches/app-builder-lib-npm-26.0.13-a064c9e1d0.patch",
|
||||
"shiki": "3.2.2"
|
||||
},
|
||||
"packageManager": "yarn@4.6.0",
|
||||
"lint-staged": {
|
||||
|
||||
@@ -12,12 +12,16 @@ export enum IpcChannel {
|
||||
App_SetTrayOnClose = 'app:set-tray-on-close',
|
||||
App_RestartTray = 'app:restart-tray',
|
||||
App_SetTheme = 'app:set-theme',
|
||||
App_SetCustomCss = 'app:set-custom-css',
|
||||
App_SetAutoUpdate = 'app:set-auto-update',
|
||||
|
||||
App_IsBinaryExist = 'app:is-binary-exist',
|
||||
App_GetBinaryPath = 'app:get-binary-path',
|
||||
App_InstallUvBinary = 'app:install-uv-binary',
|
||||
App_InstallBunBinary = 'app:install-bun-binary',
|
||||
|
||||
Webview_SetOpenLinkExternal = 'webview:set-open-link-external',
|
||||
|
||||
// Open
|
||||
Open_Path = 'open:path',
|
||||
Open_Website = 'open:website',
|
||||
@@ -39,6 +43,10 @@ export enum IpcChannel {
|
||||
Mcp_StopServer = 'mcp:stop-server',
|
||||
Mcp_ListTools = 'mcp:list-tools',
|
||||
Mcp_CallTool = 'mcp:call-tool',
|
||||
Mcp_ListPrompts = 'mcp:list-prompts',
|
||||
Mcp_GetPrompt = 'mcp:get-prompt',
|
||||
Mcp_ListResources = 'mcp:list-resources',
|
||||
Mcp_GetResource = 'mcp:get-resource',
|
||||
Mcp_GetInstallInfo = 'mcp:get-install-info',
|
||||
Mcp_ServersChanged = 'mcp:servers-changed',
|
||||
Mcp_ServersUpdated = 'mcp:servers-updated',
|
||||
@@ -116,6 +124,7 @@ export enum IpcChannel {
|
||||
Backup_ListWebdavFiles = 'backup:listWebdavFiles',
|
||||
Backup_CheckConnection = 'backup:checkConnection',
|
||||
Backup_CreateDirectory = 'backup:createDirectory',
|
||||
Backup_DeleteWebdavFile = 'backup:deleteWebdavFile',
|
||||
|
||||
// zip
|
||||
Zip_Compress = 'zip:compress',
|
||||
@@ -123,6 +132,7 @@ export enum IpcChannel {
|
||||
|
||||
// system
|
||||
System_GetDeviceType = 'system:getDeviceType',
|
||||
System_GetHostname = 'system:getHostname',
|
||||
|
||||
// events
|
||||
SelectionAction = 'selection-action',
|
||||
@@ -146,5 +156,10 @@ export enum IpcChannel {
|
||||
MiniWindowReload = 'miniwindow-reload',
|
||||
|
||||
ReduxStateChange = 'redux-state-change',
|
||||
ReduxStoreReady = 'redux-store-ready'
|
||||
ReduxStoreReady = 'redux-store-ready',
|
||||
|
||||
// Search Window
|
||||
SearchWindow_Open = 'search-window:open',
|
||||
SearchWindow_Close = 'search-window:close',
|
||||
SearchWindow_OpenUrl = 'search-window:open-url'
|
||||
}
|
||||
|
||||
@@ -1,111 +1,199 @@
|
||||
<!doctype html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CherryStudio 许可协议-ZH/EN</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet" />
|
||||
</head>
|
||||
|
||||
<body class="bg-gray-100 p-8">
|
||||
<div class="container mx-auto bg-white p-6 rounded shadow-lg">
|
||||
<h1 class="text-3xl font-bold mb-6 text-center">Cherry Studio 许可协议</h1>
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-semibold mb-4">许可协议</h2>
|
||||
<p class="mb-4">
|
||||
本软件采用 <strong>Apache License 2.0</strong> 许可。除 Apache License 2.0 规定的条款外,您在使用 Cherry
|
||||
Studio 时还应遵守以下附加条款:
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>许可协议 | License Agreement</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
|
||||
<body class="bg-gray-50">
|
||||
<div class="max-w-4xl mx-auto px-4 py-8">
|
||||
<!-- 中文版本 -->
|
||||
<div class="mb-12">
|
||||
<h1 class="text-3xl font-bold mb-8 text-gray-900">许可协议</h1>
|
||||
|
||||
<p class="mb-6 text-gray-700">本项目采用<strong>区分用户的双重许可 (User-Segmented Dual Licensing)</strong> 模式。</p>
|
||||
|
||||
<section class="mb-8">
|
||||
<h2 class="text-xl font-semibold mb-4 text-gray-900">核心原则</h2>
|
||||
<ul class="list-disc pl-6 space-y-2 text-gray-700">
|
||||
<li><strong>个人用户 和 10人及以下企业/组织:</strong> 默认适用 <strong>GNU Affero 通用公共许可证 v3.0 (AGPLv3)</strong>。</li>
|
||||
<li><strong>超过10人的企业/组织:</strong> <strong>必须</strong> 获取 <strong>商业许可证 (Commercial License)</strong>。</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="mb-8">
|
||||
<h2 class="text-xl font-semibold mb-4 text-gray-900">定义:"10人及以下"</h2>
|
||||
<p class="text-gray-700">
|
||||
指在您的组织(包括公司、非营利组织、政府机构、教育机构等任何实体)中,能够访问、使用或以任何方式直接或间接受益于本软件(Cherry
|
||||
Studio)功能的个人总数不超过10人。这包括但不限于开发者、测试人员、运营人员、最终用户、通过集成系统间接使用者等。
|
||||
</p>
|
||||
<h3 class="text-xl font-semibold mb-2">一. 商用许可</h3>
|
||||
<ol class="list-decimal list-inside mb-4">
|
||||
<li><strong>免费商用</strong>:用户在不修改代码的情况下,可以免费用于商业目的。</li>
|
||||
<li>
|
||||
<strong>商业授权</strong>:如果您满足以下任意条件之一,需取得商业授权:
|
||||
<ol class="list-decimal list-inside ml-4">
|
||||
<li>对本软件进行二次修改、开发(包括但不限于修改应用名称、logo、代码以及功能)。</li>
|
||||
<li>为企业客户提供多租户服务,且该服务支持 10 人或以上的使用。</li>
|
||||
<li>预装或集成到硬件设备或产品中进行捆绑销售。</li>
|
||||
<li>政府或教育机构的大规模采购项目,特别是涉及安全、数据隐私等敏感需求时。</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section class="mb-8">
|
||||
<h2 class="text-xl font-semibold mb-4 text-gray-900">1. 开源许可证 (Open Source License): AGPLv3 - 适用于个人及10人及以下组织
|
||||
</h2>
|
||||
<ul class="list-disc pl-6 space-y-2 text-gray-700">
|
||||
<li>如果您是个人用户,或者您的组织满足上述"10人及以下"的定义,您可以在 <strong>AGPLv3</strong> 的条款下自由使用、修改和分发 Cherry Studio。AGPLv3 的完整文本可以访问
|
||||
<a href="https://www.gnu.org/licenses/agpl-3.0.html"
|
||||
class="text-blue-600 hover:underline">https://www.gnu.org/licenses/agpl-3.0.html</a> 获取。
|
||||
</li>
|
||||
</ol>
|
||||
<h3 class="text-xl font-semibold mb-2">二. 贡献者协议</h3>
|
||||
<ol class="list-decimal list-inside mb-4">
|
||||
<li><strong>许可调整</strong>:生产者有权根据需要对开源协议进行调整,使其更加严格或宽松。</li>
|
||||
<li><strong>商业用途</strong>:您贡献的代码可能会被用于商业用途,包括但不限于云业务运营。</li>
|
||||
</ol>
|
||||
<h3 class="text-xl font-semibold mb-2">三. 其他条款</h3>
|
||||
<ol class="list-decimal list-inside mb-4">
|
||||
<li>本协议条款的解释权归 Cherry Studio 开发者所有。</li>
|
||||
<li>本协议可能根据实际情况进行更新,更新时将通过本软件通知用户。</li>
|
||||
</ol>
|
||||
<p class="mb-4">如有任何问题或需申请商业授权,请联系 Cherry Studio 开发团队。</p>
|
||||
<p>
|
||||
除上述特定条件外,其他所有权利和限制均遵循 Apache License 2.0。有关 Apache License 2.0 的详细信息,请访问
|
||||
<a href="http://www.apache.org/licenses/LICENSE-2.0" class="text-blue-500 underline"
|
||||
>http://www.apache.org/licenses/LICENSE-2.0</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold mb-6 text-center">Cherry Studio License</h1>
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-semibold mb-4">License Agreement</h2>
|
||||
<p class="mb-4">
|
||||
This software is licensed under the <strong>Apache License 2.0</strong>. In addition to the terms of the
|
||||
Apache License 2.0, the following additional terms apply to the use of Cherry Studio:
|
||||
</p>
|
||||
<h3 class="text-xl font-semibold mb-2">I. Commercial Use License</h3>
|
||||
<ol class="list-decimal list-inside mb-4">
|
||||
<li>
|
||||
<strong>Free Commercial Use</strong>: Users can use the software for commercial purposes without modifying
|
||||
the code.
|
||||
<li><strong>核心义务:</strong> AGPLv3 的一个关键要求是,如果您修改了 Cherry Studio 并通过网络提供服务,或者分发了修改后的版本,您必须以 AGPLv3
|
||||
许可证向接收者提供相应的<strong>完整源代码</strong>。即使您符合"10人及以下"的标准,如果您希望避免此源代码公开义务,您也需要考虑获取商业许可证(见下文)。</li>
|
||||
<li>使用前请务必仔细阅读并理解 AGPLv3 的所有条款。</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="mb-8">
|
||||
<h2 class="text-xl font-semibold mb-4 text-gray-900">2. 商业许可证 (Commercial License) - 适用于超过10人的组织,或希望规避 AGPLv3
|
||||
义务的用户</h2>
|
||||
<ul class="list-disc pl-6 space-y-2 text-gray-700">
|
||||
<li><strong>强制要求:</strong>
|
||||
如果您的组织<strong>不</strong>满足上述"10人及以下"的定义(即有11人或更多人可以访问、使用或受益于本软件),您<strong>必须</strong>联系我们获取并签署一份商业许可证才能使用
|
||||
Cherry Studio。</li>
|
||||
<li><strong>自愿选择:</strong> 即使您的组织满足"10人及以下"的条件,但如果您的使用场景<strong>无法满足 AGPLv3
|
||||
的条款要求</strong>(特别是关于<strong>源代码公开</strong>的义务),或者您需要 AGPLv3 <strong>未提供</strong>的特定商业条款(如保证、赔偿、无 Copyleft
|
||||
限制等),您也<strong>必须</strong>联系我们获取并签署一份商业许可证。</li>
|
||||
<li><strong>需要商业许可证的常见情况包括(但不限于):</strong>
|
||||
<ul class="list-disc pl-6 mt-2 space-y-1">
|
||||
<li>您的组织规模超过10人。</li>
|
||||
<li>(无论组织规模)您希望分发修改过的 Cherry Studio 版本,但<strong>不希望</strong>根据 AGPLv3 公开您修改部分的源代码。</li>
|
||||
<li>(无论组织规模)您希望基于修改过的 Cherry Studio 提供网络服务(SaaS),但<strong>不希望</strong>根据 AGPLv3 向服务使用者提供修改后的源代码。</li>
|
||||
<li>(无论组织规模)您的公司政策、客户合同或项目要求不允许使用 AGPLv3 许可的软件,或要求闭源分发及保密。</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Commercial License Required</strong>: A commercial license is required if any of the following
|
||||
conditions are met:
|
||||
<ol class="list-decimal list-inside ml-4">
|
||||
<li>
|
||||
You modify, develop, or alter the software, including but not limited to changes to the application
|
||||
name, logo, code, or functionality.
|
||||
</li>
|
||||
<li>You provide multi-tenant services to enterprise customers with 10 or more users.</li>
|
||||
<li>
|
||||
You pre-install or integrate the software into hardware devices or products and bundle it for sale.
|
||||
</li>
|
||||
<li>
|
||||
You are engaging in large-scale procurement for government or educational institutions, especially
|
||||
involving security, data privacy, or other sensitive requirements.
|
||||
</li>
|
||||
</ol>
|
||||
</li>
|
||||
</ol>
|
||||
<h3 class="text-xl font-semibold mb-2">II. Contributor Agreement</h3>
|
||||
<ol class="list-decimal list-inside mb-4">
|
||||
<li>
|
||||
<strong>License Adjustment</strong>: The producer reserves the right to adjust the open-source license as
|
||||
needed, making it stricter or more lenient.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Commercial Use</strong>: Any code you contribute may be used for commercial purposes, including but
|
||||
not limited to cloud business operations.
|
||||
</li>
|
||||
</ol>
|
||||
<h3 class="text-xl font-semibold mb-2">III. Other Terms</h3>
|
||||
<ol class="list-decimal list-inside mb-4">
|
||||
<li>The interpretation of these terms is subject to the discretion of Cherry Studio developers.</li>
|
||||
<li>These terms may be updated, and users will be notified through the software when changes occur.</li>
|
||||
</ol>
|
||||
<p class="mb-4">
|
||||
For any questions or to request a commercial license, please contact the Cherry Studio development team.
|
||||
</p>
|
||||
<p>
|
||||
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
|
||||
<a href="http://www.apache.org/licenses/LICENSE-2.0" class="text-blue-500 underline"
|
||||
>http://www.apache.org/licenses/LICENSE-2.0</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
<li><strong>获取商业许可:</strong> 请通过邮箱 <a href="mailto:bd@cherry-ai.com"
|
||||
class="text-blue-600 hover:underline">bd@cherry-ai.com</a> 联系 Cherry Studio 开发团队洽谈商业授权事宜。</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="mb-8">
|
||||
<h2 class="text-xl font-semibold mb-4 text-gray-900">3. 贡献 (Contributions)</h2>
|
||||
<ul class="list-disc pl-6 space-y-2 text-gray-700">
|
||||
<li>我们欢迎社区对 Cherry Studio 的贡献。所有向本项目提交的贡献都将被视为在 <strong>AGPLv3</strong> 许可证下提供。</li>
|
||||
<li>通过向本项目提交贡献(例如通过 Pull Request),即表示您同意您的代码以 AGPLv3 许可证授权给本项目及所有后续使用者(无论这些使用者最终遵循 AGPLv3 还是商业许可)。</li>
|
||||
<li>您也理解并同意,您的贡献可能会被包含在根据商业许可证分发的 Cherry Studio 版本中。</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="mb-8">
|
||||
<h2 class="text-xl font-semibold mb-4 text-gray-900">4. 其他条款 (Other Terms)</h2>
|
||||
<ul class="list-disc pl-6 space-y-2 text-gray-700">
|
||||
<li>关于商业许可证的具体条款和条件,以双方签署的正式商业许可协议为准。</li>
|
||||
<li>项目维护者保留根据需要更新本许可政策(包括用户规模定义和阈值)的权利。相关更新将通过项目官方渠道(如代码仓库、官方网站)进行通知。</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<hr class="my-12 border-gray-300">
|
||||
|
||||
<!-- English Version -->
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold mb-8 text-gray-900">Licensing</h1>
|
||||
|
||||
<p class="mb-6 text-gray-700">This project employs a <strong>User-Segmented Dual Licensing</strong> model.</p>
|
||||
|
||||
<section class="mb-8">
|
||||
<h2 class="text-xl font-semibold mb-4 text-gray-900">Core Principle</h2>
|
||||
<ul class="list-disc pl-6 space-y-2 text-gray-700">
|
||||
<li><strong>Individual Users and Organizations with 10 or Fewer Individuals:</strong> Governed by default
|
||||
under the <strong>GNU Affero General Public License v3.0 (AGPLv3)</strong>.</li>
|
||||
<li><strong>Organizations with More Than 10 Individuals:</strong> <strong>Must</strong> obtain a
|
||||
<strong>Commercial License</strong>.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="mb-8">
|
||||
<h2 class="text-xl font-semibold mb-4 text-gray-900">Definition: "10 or Fewer Individuals"</h2>
|
||||
<p class="text-gray-700">
|
||||
Refers to any organization (including companies, non-profits, government agencies, educational institutions,
|
||||
etc.) where the total number of individuals who can access, use, or in any way directly or indirectly benefit
|
||||
from the functionality of this software (Cherry Studio) does not exceed 10. This includes, but is not limited
|
||||
to, developers, testers, operations staff, end-users, and indirect users via integrated systems.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mb-8">
|
||||
<h2 class="text-xl font-semibold mb-4 text-gray-900">1. Open Source License: AGPLv3 - For Individuals and
|
||||
Organizations of 10 or Fewer</h2>
|
||||
<ul class="list-disc pl-6 space-y-2 text-gray-700">
|
||||
<li>If you are an individual user, or if your organization meets the "10 or Fewer Individuals" definition
|
||||
above, you are free to use, modify, and distribute Cherry Studio under the terms of the
|
||||
<strong>AGPLv3</strong>. The full text of the AGPLv3 can be found at <a
|
||||
href="https://www.gnu.org/licenses/agpl-3.0.html"
|
||||
class="text-blue-600 hover:underline">https://www.gnu.org/licenses/agpl-3.0.html</a>.
|
||||
</li>
|
||||
<li><strong>Core Obligation:</strong> A key requirement of the AGPLv3 is that if you modify Cherry Studio and
|
||||
make it available over a network, or distribute the modified version, you must provide the <strong>complete
|
||||
corresponding source code</strong> under the AGPLv3 license to the recipients. Even if you qualify under
|
||||
the "10 or Fewer Individuals" rule, if you wish to avoid this source code disclosure obligation, you will
|
||||
need to obtain a Commercial License (see below).</li>
|
||||
<li>Please read and understand the full terms of the AGPLv3 carefully before use.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="mb-8">
|
||||
<h2 class="text-xl font-semibold mb-4 text-gray-900">2. Commercial License - For Organizations with More Than 10
|
||||
Individuals, or Users Needing to Avoid AGPLv3 Obligations</h2>
|
||||
<ul class="list-disc pl-6 space-y-2 text-gray-700">
|
||||
<li><strong>Mandatory Requirement:</strong> If your organization does <strong>not</strong> meet the "10 or
|
||||
Fewer Individuals" definition above (i.e., 11 or more individuals can access, use, or benefit from the
|
||||
software), you <strong>must</strong> contact us to obtain and execute a Commercial License to use Cherry
|
||||
Studio.</li>
|
||||
<li><strong>Voluntary Option:</strong> Even if your organization meets the "10 or Fewer Individuals"
|
||||
condition, if your intended use case <strong>cannot comply with the terms of the AGPLv3</strong>
|
||||
(particularly the obligations regarding <strong>source code disclosure</strong>), or if you require specific
|
||||
commercial terms <strong>not offered</strong> by the AGPLv3 (such as warranties, indemnities, or freedom
|
||||
from copyleft restrictions), you also <strong>must</strong> contact us to obtain and execute a Commercial
|
||||
License.</li>
|
||||
<li><strong>Common scenarios requiring a Commercial License include (but are not limited to):</strong>
|
||||
<ul class="list-disc pl-6 mt-2 space-y-1">
|
||||
<li>Your organization has more than 10 individuals who can access, use, or benefit from the software.</li>
|
||||
<li>(Regardless of organization size) You wish to distribute a modified version of Cherry Studio but
|
||||
<strong>do not want</strong> to disclose the source code of your modifications under AGPLv3.
|
||||
</li>
|
||||
<li>(Regardless of organization size) You wish to provide a network service (SaaS) based on a modified
|
||||
version of Cherry Studio but <strong>do not want</strong> to provide the modified source code to users
|
||||
of the service under AGPLv3.</li>
|
||||
<li>(Regardless of organization size) Your corporate policies, client contracts, or project requirements
|
||||
prohibit the use of AGPLv3-licensed software or mandate closed-source distribution and confidentiality.
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>Obtaining a Commercial License:</strong> Please contact the Cherry Studio development team via
|
||||
email at <a href="mailto:bd@cherry-ai.com" class="text-blue-600 hover:underline">bd@cherry-ai.com</a> to
|
||||
discuss commercial licensing options.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="mb-8">
|
||||
<h2 class="text-xl font-semibold mb-4 text-gray-900">3. Contributions</h2>
|
||||
<ul class="list-disc pl-6 space-y-2 text-gray-700">
|
||||
<li>We welcome community contributions to Cherry Studio. All contributions submitted to this project are
|
||||
considered to be offered under the <strong>AGPLv3</strong> license.</li>
|
||||
<li>By submitting a contribution to this project (e.g., via a Pull Request), you agree to license your code
|
||||
under the AGPLv3 to the project and all its downstream users (regardless of whether those users ultimately
|
||||
operate under AGPLv3 or a Commercial License).</li>
|
||||
<li>You also understand and agree that your contribution may be included in distributions of Cherry Studio
|
||||
offered under our commercial license.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="mb-8">
|
||||
<h2 class="text-xl font-semibold mb-4 text-gray-900">4. Other Terms</h2>
|
||||
<ul class="list-disc pl-6 space-y-2 text-gray-700">
|
||||
<li>The specific terms and conditions of the Commercial License are governed by the formal commercial license
|
||||
agreement signed by both parties.</li>
|
||||
<li>The project maintainers reserve the right to update this licensing policy (including the definition and
|
||||
threshold for user count) as needed. Updates will be communicated through official project channels (e.g.,
|
||||
code repository, official website).</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -6,8 +6,8 @@ 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
|
||||
const BUN_RELEASE_BASE_URL = 'https://gitcode.com/CherryHQ/bun/releases/download'
|
||||
const DEFAULT_BUN_VERSION = '1.2.9' // Default fallback version
|
||||
|
||||
// Mapping of platform+arch to binary package name
|
||||
const BUN_PACKAGES = {
|
||||
|
||||
@@ -7,8 +7,8 @@ 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'
|
||||
const UV_RELEASE_BASE_URL = 'https://gitcode.com/CherryHQ/uv/releases/download'
|
||||
const DEFAULT_UV_VERSION = '0.6.14'
|
||||
|
||||
// Mapping of platform+arch to binary package name
|
||||
const UV_PACKAGES = {
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
const { Arch } = require('electron-builder')
|
||||
const { default: removeLocales } = require('./remove-locales')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
exports.default = async function (context) {
|
||||
await removeLocales(context)
|
||||
const platform = context.packager.platform.name
|
||||
const arch = context.arch
|
||||
|
||||
@@ -18,35 +16,48 @@ exports.default = async function (context) {
|
||||
'node_modules'
|
||||
)
|
||||
|
||||
removeDifferentArchNodeFiles(node_modules_path, '@libsql', arch === Arch.arm64 ? ['darwin-arm64'] : ['darwin-x64'])
|
||||
keepPackageNodeFiles(node_modules_path, '@libsql', arch === Arch.arm64 ? ['darwin-arm64'] : ['darwin-x64'])
|
||||
}
|
||||
|
||||
if (platform === 'linux') {
|
||||
const node_modules_path = path.join(context.appOutDir, 'resources', 'app.asar.unpacked', 'node_modules')
|
||||
const _arch = arch === Arch.arm64 ? ['linux-arm64-gnu', 'linux-arm64-musl'] : ['linux-x64-gnu', 'linux-x64-musl']
|
||||
removeDifferentArchNodeFiles(node_modules_path, '@libsql', _arch)
|
||||
keepPackageNodeFiles(node_modules_path, '@libsql', _arch)
|
||||
}
|
||||
|
||||
if (platform === 'windows') {
|
||||
const node_modules_path = path.join(context.appOutDir, 'resources', 'app.asar.unpacked', 'node_modules')
|
||||
if (arch === Arch.arm64) {
|
||||
removeDifferentArchNodeFiles(node_modules_path, '@strongtz', ['win32-arm64-msvc'])
|
||||
removeDifferentArchNodeFiles(node_modules_path, '@libsql', ['win32-arm64-msvc'])
|
||||
keepPackageNodeFiles(node_modules_path, '@strongtz', ['win32-arm64-msvc'])
|
||||
keepPackageNodeFiles(node_modules_path, '@libsql', ['win32-arm64-msvc'])
|
||||
}
|
||||
if (arch === Arch.x64) {
|
||||
removeDifferentArchNodeFiles(node_modules_path, '@strongtz', ['win32-x64-msvc'])
|
||||
removeDifferentArchNodeFiles(node_modules_path, '@libsql', ['win32-x64-msvc'])
|
||||
keepPackageNodeFiles(node_modules_path, '@strongtz', ['win32-x64-msvc'])
|
||||
keepPackageNodeFiles(node_modules_path, '@libsql', ['win32-x64-msvc'])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function removeDifferentArchNodeFiles(nodeModulesPath, packageName, arch) {
|
||||
/**
|
||||
* 使用指定架构的 node_modules 文件
|
||||
* @param {*} nodeModulesPath
|
||||
* @param {*} packageName
|
||||
* @param {*} arch
|
||||
* @returns
|
||||
*/
|
||||
function keepPackageNodeFiles(nodeModulesPath, packageName, arch) {
|
||||
const modulePath = path.join(nodeModulesPath, packageName)
|
||||
|
||||
if (!fs.existsSync(modulePath)) {
|
||||
console.log(`[After Pack] Directory does not exist: ${modulePath}`)
|
||||
return
|
||||
}
|
||||
|
||||
const dirs = fs.readdirSync(modulePath)
|
||||
dirs
|
||||
.filter((dir) => !arch.includes(dir))
|
||||
.forEach((dir) => {
|
||||
fs.rmSync(path.join(modulePath, dir), { recursive: true, force: true })
|
||||
console.log(`Removed dir: ${dir}`, arch)
|
||||
console.log(`[After Pack] Removed dir: ${dir}`, arch)
|
||||
})
|
||||
}
|
||||
|
||||
23
scripts/artifact-build-completed.js
Normal file
23
scripts/artifact-build-completed.js
Normal file
@@ -0,0 +1,23 @@
|
||||
const fs = require('fs')
|
||||
|
||||
exports.default = function (buildResult) {
|
||||
try {
|
||||
console.log('[artifact build completed] rename artifact file...')
|
||||
if (!buildResult.file.includes(' ')) {
|
||||
return
|
||||
}
|
||||
|
||||
let oldFilePath = buildResult.file
|
||||
if (oldFilePath.includes('-portable') && !oldFilePath.includes('-x64') && !oldFilePath.includes('-arm64')) {
|
||||
console.log('[artifact build completed] delete portable file:', oldFilePath)
|
||||
fs.unlinkSync(oldFilePath)
|
||||
return
|
||||
}
|
||||
const newfilePath = oldFilePath.replace(/ /g, '-')
|
||||
fs.renameSync(oldFilePath, newfilePath)
|
||||
buildResult.file = newfilePath
|
||||
console.log(`[artifact build completed] rename file ${oldFilePath} to ${newfilePath} `)
|
||||
} catch (error) {
|
||||
console.error('Error renaming file:', error)
|
||||
}
|
||||
}
|
||||
157
scripts/auto-i18n.py
Normal file
157
scripts/auto-i18n.py
Normal file
@@ -0,0 +1,157 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.13"
|
||||
# dependencies = [
|
||||
# "agno",
|
||||
# "openai",
|
||||
# ]
|
||||
# ///
|
||||
#
|
||||
# Example of how to run the script:
|
||||
#
|
||||
# 1. First, set the OpenRouter API key environment variable:
|
||||
# ```
|
||||
# export OPENROUTER_API_KEY=your-api-key
|
||||
# ```
|
||||
#
|
||||
# 2. Then run the script using uv:
|
||||
# ```
|
||||
# uv run i18n.py --dir src/renderer/src/i18n/locales "settings.mcp.autoDescription='auto set i18n', settings.mcp.autoName='auto set i18n name'"
|
||||
# ```
|
||||
|
||||
|
||||
import json
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from agno.agent import Agent
|
||||
from agno.models.openrouter import OpenRouter
|
||||
from agno.tools import tool
|
||||
|
||||
LANGUAGES = ["en-us", "zh-cn", "ja-jp", "ru-ru", "zh-tw"]
|
||||
|
||||
|
||||
def ensure_json_files_exist(output_dir=None):
|
||||
"""Ensure that all language JSON files exist with at least an empty object."""
|
||||
output_dir = Path(output_dir) if output_dir else Path(".")
|
||||
|
||||
# Create the directory if it doesn't exist
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for lang in LANGUAGES:
|
||||
file_path = output_dir / f"{lang}.json"
|
||||
if not file_path.exists():
|
||||
with open(file_path, "w") as f:
|
||||
json.dump({}, f, indent=4)
|
||||
|
||||
|
||||
def set_nested_value(data, keys, value):
|
||||
"""Recursively navigate through a nested dictionary and set the value."""
|
||||
if len(keys) == 1:
|
||||
data[keys[0]] = value
|
||||
return
|
||||
|
||||
key = keys[0]
|
||||
if key not in data:
|
||||
data[key] = {}
|
||||
|
||||
set_nested_value(data[key], keys[1:], value)
|
||||
|
||||
|
||||
@tool(show_result=True, stop_after_tool_call=True)
|
||||
def set_i18n(key: str, translations: dict[str, str], output_dir=None):
|
||||
"""
|
||||
Set i18n translations for a key in all language files.
|
||||
|
||||
Args:
|
||||
key: The i18n key (e.g., "settings.mcp.sync.title")
|
||||
translations: Dictionary with translations for different languages
|
||||
output_dir: Directory to store the i18n JSON files
|
||||
|
||||
Example:
|
||||
set_i18n("settings.mcp.hello", {
|
||||
"en-us": "Hello",
|
||||
"zh-cn": "你好",
|
||||
"ja-jp": "こんにちは",
|
||||
"ru-ru": "Привет",
|
||||
"zh-tw": "你好"
|
||||
})
|
||||
"""
|
||||
ensure_json_files_exist(output_dir)
|
||||
output_dir = Path(output_dir) if output_dir else Path(".")
|
||||
|
||||
results = {}
|
||||
keys = key.split(".")
|
||||
if keys[0] != "translation":
|
||||
keys = ["translation"] + keys
|
||||
|
||||
for lang, text in translations.items():
|
||||
if lang not in LANGUAGES:
|
||||
continue
|
||||
|
||||
file_path = output_dir / f"{lang}.json"
|
||||
try:
|
||||
# Load existing data
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
try:
|
||||
data = json.load(f)
|
||||
except json.JSONDecodeError:
|
||||
data = {}
|
||||
|
||||
# Set the value at the nested path
|
||||
set_nested_value(data, keys, text)
|
||||
|
||||
# Save the updated data
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
results[lang] = f"Updated {key} in {file_path}"
|
||||
except Exception as e:
|
||||
results[lang] = f"Error updating {file_path}: {str(e)}"
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def main():
|
||||
"""Main function to run the i18n translation agent."""
|
||||
# Set up command line argument parser
|
||||
parser = argparse.ArgumentParser(description="Translate i18n JSON content")
|
||||
parser.add_argument("content", help="JSON content to translate")
|
||||
parser.add_argument(
|
||||
"-m",
|
||||
"--model",
|
||||
default="gpt-4.1-mini",
|
||||
help="Model to use for translation (default: gpt-4.1-mini)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dir",
|
||||
default=None,
|
||||
help="Directory to store i18n JSON files (default: current directory)",
|
||||
)
|
||||
|
||||
# Parse arguments
|
||||
args = parser.parse_args()
|
||||
|
||||
# Initialize the agent with the specified model
|
||||
agent = Agent(
|
||||
model=OpenRouter(id=args.model),
|
||||
tools=[set_i18n],
|
||||
markdown=True,
|
||||
)
|
||||
|
||||
# Create the prompt with the provided content
|
||||
prompt = f"""Please help set i18n translations for the following content to all supported languages: {LANGUAGES}.
|
||||
<content>
|
||||
{args.content}
|
||||
</content>
|
||||
|
||||
Use the provided directory {args.dir} for storing the i18n JSON files.
|
||||
"""
|
||||
|
||||
# Call the agent with the tools context that includes the output directory
|
||||
agent.print_response(
|
||||
prompt, stream=True, tools_context={"set_i18n": {"output_dir": args.dir}}
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,58 +0,0 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
exports.default = async function (context) {
|
||||
const platform = context.packager.platform.name
|
||||
|
||||
// 根据平台确定 locales 目录位置
|
||||
let resourceDirs = []
|
||||
if (platform === 'mac') {
|
||||
// macOS 的语言文件位置
|
||||
resourceDirs = [
|
||||
path.join(context.appOutDir, 'Cherry Studio.app', 'Contents', 'Resources'),
|
||||
path.join(
|
||||
context.appOutDir,
|
||||
'Cherry Studio.app',
|
||||
'Contents',
|
||||
'Frameworks',
|
||||
'Electron Framework.framework',
|
||||
'Resources'
|
||||
)
|
||||
]
|
||||
} else {
|
||||
// Windows 和 Linux 的语言文件位置
|
||||
resourceDirs = [path.join(context.appOutDir, 'locales')]
|
||||
}
|
||||
|
||||
// 处理每个资源目录
|
||||
for (const resourceDir of resourceDirs) {
|
||||
if (!fs.existsSync(resourceDir)) {
|
||||
console.log(`Resource directory not found: ${resourceDir}, skipping...`)
|
||||
continue
|
||||
}
|
||||
|
||||
// 读取所有文件和目录
|
||||
const items = fs.readdirSync(resourceDir)
|
||||
|
||||
// 遍历并删除不需要的语言文件
|
||||
for (const item of items) {
|
||||
if (platform === 'mac') {
|
||||
// 在 macOS 上检查 .lproj 目录
|
||||
if (item.endsWith('.lproj') && !item.match(/^(en|zh|ru)/)) {
|
||||
const dirPath = path.join(resourceDir, item)
|
||||
fs.rmSync(dirPath, { recursive: true, force: true })
|
||||
console.log(`Removed locale directory: ${item} from ${resourceDir}`)
|
||||
}
|
||||
} else {
|
||||
// 其他平台处理 .pak 文件
|
||||
if (!item.match(/^(en|zh|ru)/)) {
|
||||
const filePath = path.join(resourceDir, item)
|
||||
fs.unlinkSync(filePath)
|
||||
console.log(`Removed locale file: ${item} from ${resourceDir}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Locale cleanup completed!')
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
// replaceSpaces.js
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const directory = 'dist'
|
||||
|
||||
// 处理文件名中的空格
|
||||
function replaceFileNames() {
|
||||
fs.readdir(directory, (err, files) => {
|
||||
if (err) throw err
|
||||
|
||||
files.forEach((file) => {
|
||||
const oldPath = path.join(directory, file)
|
||||
const newPath = path.join(directory, file.replace(/ /g, '-'))
|
||||
|
||||
fs.stat(oldPath, (err, stats) => {
|
||||
if (err) throw err
|
||||
|
||||
if (stats.isFile() && oldPath !== newPath) {
|
||||
fs.rename(oldPath, newPath, (err) => {
|
||||
if (err) throw err
|
||||
console.log(`Renamed: ${oldPath} -> ${newPath}`)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function replaceYmlContent() {
|
||||
fs.readdir(directory, (err, files) => {
|
||||
if (err) throw err
|
||||
|
||||
files.forEach((file) => {
|
||||
if (path.extname(file).toLowerCase() === '.yml') {
|
||||
const filePath = path.join(directory, file)
|
||||
|
||||
fs.readFile(filePath, 'utf8', (err, data) => {
|
||||
if (err) throw err
|
||||
|
||||
// 替换内容
|
||||
const newContent = data.replace(/Cherry Studio-/g, 'Cherry-Studio-')
|
||||
|
||||
// 写回文件
|
||||
fs.writeFile(filePath, newContent, 'utf8', (err) => {
|
||||
if (err) throw err
|
||||
console.log(`Updated content in: ${filePath}`)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 执行两个操作
|
||||
replaceFileNames()
|
||||
replaceYmlContent()
|
||||
@@ -2,3 +2,4 @@ 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'
|
||||
export const isPortable = isWin && 'PORTABLE_EXECUTABLE_DIR' in process.env
|
||||
|
||||
@@ -11,6 +11,7 @@ export default class VoyageEmbeddings extends BaseEmbeddings {
|
||||
if (!this.configuration.outputDimension) {
|
||||
throw new Error('You need to pass in the optional dimensions parameter for this model')
|
||||
}
|
||||
console.log('VoyageEmbeddings', this.configuration)
|
||||
this.model = new _VoyageEmbeddings(this.configuration)
|
||||
}
|
||||
override async getDimensions(): Promise<number> {
|
||||
|
||||
@@ -3,13 +3,16 @@ import { replaceDevtoolsFont } from '@main/utils/windowUtil'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { app, ipcMain } from 'electron'
|
||||
import installExtension, { REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } from 'electron-devtools-installer'
|
||||
import Logger from 'electron-log'
|
||||
|
||||
import { registerIpc } from './ipc'
|
||||
import { configManager } from './services/ConfigManager'
|
||||
import mcpService from './services/MCPService'
|
||||
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 { setAppDataDir } from './utils/file'
|
||||
|
||||
// Check for single instance lock
|
||||
if (!app.requestSingleInstanceLock()) {
|
||||
@@ -48,6 +51,8 @@ if (!app.requestSingleInstanceLock()) {
|
||||
|
||||
replaceDevtoolsFont(mainWindow)
|
||||
|
||||
setAppDataDir()
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
installExtension([REDUX_DEVTOOLS, REACT_DEVELOPER_TOOLS])
|
||||
.then((name) => console.log(`Added Extension: ${name}`))
|
||||
@@ -56,14 +61,10 @@ if (!app.requestSingleInstanceLock()) {
|
||||
ipcMain.handle(IpcChannel.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)
|
||||
ipcMain.handle(IpcChannel.System_GetHostname, () => {
|
||||
return require('os').hostname()
|
||||
})
|
||||
})
|
||||
|
||||
registerProtocolClient(app)
|
||||
@@ -92,6 +93,15 @@ if (!app.requestSingleInstanceLock()) {
|
||||
app.isQuitting = true
|
||||
})
|
||||
|
||||
app.on('will-quit', async () => {
|
||||
// event.preventDefault()
|
||||
try {
|
||||
await mcpService.cleanup()
|
||||
} catch (error) {
|
||||
Logger.error('Error cleaning up MCP service:', error)
|
||||
}
|
||||
})
|
||||
|
||||
// In this file you can include the rest of your app"s specific main process
|
||||
// code. You can also put them in separate files and require them here.
|
||||
}
|
||||
|
||||
14
src/main/integration/nutstore/sso/lib/index.d.mts
Normal file
14
src/main/integration/nutstore/sso/lib/index.d.mts
Normal file
@@ -0,0 +1,14 @@
|
||||
interface CreateOAuthUrlArgs {
|
||||
app: string;
|
||||
}
|
||||
declare function createOAuthUrl({ app }: CreateOAuthUrlArgs): Promise<string>;
|
||||
declare function _dont_use_in_prod_createOAuthUrl({ app, }: CreateOAuthUrlArgs): Promise<string>;
|
||||
|
||||
interface DecryptSecretArgs {
|
||||
app: string;
|
||||
s: string;
|
||||
}
|
||||
declare function decryptSecret({ app, s }: DecryptSecretArgs): Promise<string>;
|
||||
declare function _dont_use_in_prod_decryptSecret({ app, s, }: DecryptSecretArgs): Promise<string>;
|
||||
|
||||
export { type CreateOAuthUrlArgs, type DecryptSecretArgs, _dont_use_in_prod_createOAuthUrl, _dont_use_in_prod_decryptSecret, createOAuthUrl, decryptSecret };
|
||||
@@ -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 it is too large
Load Diff
1
src/main/integration/nutstore/sso/lib/index.mjs
Normal file
1
src/main/integration/nutstore/sso/lib/index.mjs
Normal file
File diff suppressed because one or more lines are too long
@@ -1,4 +1,5 @@
|
||||
import fs from 'node:fs'
|
||||
import { arch } from 'node:os'
|
||||
|
||||
import { isMac, isWin } from '@main/constant'
|
||||
import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process'
|
||||
@@ -21,8 +22,10 @@ import mcpService from './services/MCPService'
|
||||
import * as NutstoreService from './services/NutstoreService'
|
||||
import ObsidianVaultService from './services/ObsidianVaultService'
|
||||
import { ProxyConfig, proxyManager } from './services/ProxyManager'
|
||||
import { searchService } from './services/SearchService'
|
||||
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
|
||||
import { TrayService } from './services/TrayService'
|
||||
import { setOpenLinkExternal } from './services/WebviewService'
|
||||
import { windowService } from './services/WindowService'
|
||||
import { getResourcePath } from './utils'
|
||||
import { decrypt, encrypt } from './utils/aes'
|
||||
@@ -45,7 +48,9 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
configPath: getConfigDir(),
|
||||
appDataPath: app.getPath('userData'),
|
||||
resourcesPath: getResourcePath(),
|
||||
logsPath: log.transports.file.getFile().path
|
||||
logsPath: log.transports.file.getFile().path,
|
||||
arch: arch(),
|
||||
isPortable: isWin && 'PORTABLE_EXECUTABLE_DIR' in process.env
|
||||
}))
|
||||
|
||||
ipcMain.handle(IpcChannel.App_Proxy, async (_, proxy: string) => {
|
||||
@@ -97,6 +102,12 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
configManager.setTrayOnClose(isActive)
|
||||
})
|
||||
|
||||
// auto update
|
||||
ipcMain.handle(IpcChannel.App_SetAutoUpdate, (_, isActive: boolean) => {
|
||||
appUpdater.setAutoUpdate(isActive)
|
||||
configManager.setAutoUpdate(isActive)
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.App_RestartTray, () => TrayService.getInstance().restartTray())
|
||||
|
||||
ipcMain.handle(IpcChannel.Config_Set, (_, key: string, value: any) => {
|
||||
@@ -127,6 +138,22 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
mainWindow.setTitleBarOverlay(theme === 'dark' ? titleBarOverlayDark : titleBarOverlayLight)
|
||||
})
|
||||
|
||||
// custom css
|
||||
ipcMain.handle(IpcChannel.App_SetCustomCss, (event, css: string) => {
|
||||
if (css === configManager.getCustomCss()) return
|
||||
configManager.setCustomCss(css)
|
||||
|
||||
// Broadcast to all windows including the mini window
|
||||
const senderWindowId = event.sender.id
|
||||
const windows = BrowserWindow.getAllWindows()
|
||||
// 向其他窗口广播主题变化
|
||||
windows.forEach((win) => {
|
||||
if (win.webContents.id !== senderWindowId) {
|
||||
win.webContents.send('custom-css:update', css)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// clear cache
|
||||
ipcMain.handle(IpcChannel.App_ClearCache, async () => {
|
||||
const sessions = [session.defaultSession, session.fromPartition('persist:webview')]
|
||||
@@ -151,7 +178,15 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
|
||||
// check for update
|
||||
ipcMain.handle(IpcChannel.App_CheckForUpdate, async () => {
|
||||
if (isWin && 'PORTABLE_EXECUTABLE_DIR' in process.env) {
|
||||
return {
|
||||
currentVersion: app.getVersion(),
|
||||
updateInfo: null
|
||||
}
|
||||
}
|
||||
|
||||
const update = await appUpdater.autoUpdater.checkForUpdates()
|
||||
|
||||
return {
|
||||
currentVersion: appUpdater.autoUpdater.currentVersion,
|
||||
updateInfo: update?.updateInfo
|
||||
@@ -170,6 +205,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle(IpcChannel.Backup_ListWebdavFiles, backupManager.listWebdavFiles)
|
||||
ipcMain.handle(IpcChannel.Backup_CheckConnection, backupManager.checkConnection)
|
||||
ipcMain.handle(IpcChannel.Backup_CreateDirectory, backupManager.createDirectory)
|
||||
ipcMain.handle(IpcChannel.Backup_DeleteWebdavFile, backupManager.deleteWebdavFile)
|
||||
|
||||
// file
|
||||
ipcMain.handle(IpcChannel.File_Open, fileManager.open)
|
||||
@@ -261,6 +297,10 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle(IpcChannel.Mcp_StopServer, mcpService.stopServer)
|
||||
ipcMain.handle(IpcChannel.Mcp_ListTools, mcpService.listTools)
|
||||
ipcMain.handle(IpcChannel.Mcp_CallTool, mcpService.callTool)
|
||||
ipcMain.handle(IpcChannel.Mcp_ListPrompts, mcpService.listPrompts)
|
||||
ipcMain.handle(IpcChannel.Mcp_GetPrompt, mcpService.getPrompt)
|
||||
ipcMain.handle(IpcChannel.Mcp_ListResources, mcpService.listResources)
|
||||
ipcMain.handle(IpcChannel.Mcp_GetResource, mcpService.getResource)
|
||||
ipcMain.handle(IpcChannel.Mcp_GetInstallInfo, mcpService.getInstallInfo)
|
||||
|
||||
ipcMain.handle(IpcChannel.App_IsBinaryExist, (_, name: string) => isBinaryExists(name))
|
||||
@@ -291,4 +331,20 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle(IpcChannel.Nutstore_GetDirectoryContents, (_, token: string, path: string) =>
|
||||
NutstoreService.getDirectoryContents(token, path)
|
||||
)
|
||||
|
||||
// search window
|
||||
ipcMain.handle(IpcChannel.SearchWindow_Open, async (_, uid: string) => {
|
||||
await searchService.openSearchWindow(uid)
|
||||
})
|
||||
ipcMain.handle(IpcChannel.SearchWindow_Close, async (_, uid: string) => {
|
||||
await searchService.closeSearchWindow(uid)
|
||||
})
|
||||
ipcMain.handle(IpcChannel.SearchWindow_OpenUrl, async (_, uid: string, url: string) => {
|
||||
return await searchService.openUrlInSearchWindow(uid, url)
|
||||
})
|
||||
|
||||
// webview
|
||||
ipcMain.handle(IpcChannel.Webview_SetOpenLinkExternal, (_, webviewId: number, isExternal: boolean) =>
|
||||
setOpenLinkExternal(webviewId, isExternal)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
// port https://github.com/modelcontextprotocol/servers/blob/main/src/memory/index.ts
|
||||
import { getConfigDir } from '@main/utils/file'
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
|
||||
import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from '@modelcontextprotocol/sdk/types.js'
|
||||
import { Mutex } from 'async-mutex' // 引入 Mutex
|
||||
import { promises as fs } from 'fs'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
// Define memory file path using environment variable with fallback
|
||||
// Define memory file path
|
||||
const defaultMemoryPath = path.join(getConfigDir(), 'memory.json')
|
||||
|
||||
// We are storing our memory using entities, relations, and observations in a graph structure
|
||||
// Interfaces remain the same
|
||||
interface Entity {
|
||||
name: string
|
||||
entityType: string
|
||||
@@ -22,6 +21,7 @@ interface Relation {
|
||||
relationType: string
|
||||
}
|
||||
|
||||
// Structure for storing the graph in memory and in the file
|
||||
interface KnowledgeGraph {
|
||||
entities: Entity[]
|
||||
relations: Relation[]
|
||||
@@ -30,200 +30,315 @@ interface KnowledgeGraph {
|
||||
// The KnowledgeGraphManager class contains all operations to interact with the knowledge graph
|
||||
class KnowledgeGraphManager {
|
||||
private memoryPath: string
|
||||
private entities: Map<string, Entity> // Use Map for efficient entity lookup
|
||||
private relations: Set<string> // Store stringified relations for easy Set operations
|
||||
private fileMutex: Mutex // Mutex for file writing
|
||||
|
||||
constructor(memoryPath: string) {
|
||||
private constructor(memoryPath: string) {
|
||||
this.memoryPath = memoryPath
|
||||
this.ensureMemoryPathExists()
|
||||
this.entities = new Map<string, Entity>()
|
||||
this.relations = new Set<string>()
|
||||
this.fileMutex = new Mutex()
|
||||
}
|
||||
|
||||
private async ensureMemoryPathExists(): Promise<void> {
|
||||
// Static async factory method for initialization
|
||||
public static async create(memoryPath: string): Promise<KnowledgeGraphManager> {
|
||||
const manager = new KnowledgeGraphManager(memoryPath)
|
||||
await manager._ensureMemoryPathExists()
|
||||
await manager._loadGraphFromDisk()
|
||||
return manager
|
||||
}
|
||||
|
||||
private async _ensureMemoryPathExists(): Promise<void> {
|
||||
try {
|
||||
// Ensure the directory exists
|
||||
const directory = path.dirname(this.memoryPath)
|
||||
await fs.mkdir(directory, { recursive: true })
|
||||
|
||||
// Check if the file exists, if not create an empty one
|
||||
try {
|
||||
await fs.access(this.memoryPath)
|
||||
} catch (error) {
|
||||
// File doesn't exist, create an empty file
|
||||
await fs.writeFile(this.memoryPath, '')
|
||||
// File doesn't exist, create an empty file with initial structure
|
||||
await fs.writeFile(this.memoryPath, JSON.stringify({ entities: [], relations: [] }, null, 2))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create memory path:', error)
|
||||
console.error('Failed to ensure memory path exists:', error)
|
||||
// Propagate the error or handle it more gracefully depending on requirements
|
||||
throw new McpError(
|
||||
ErrorCode.InternalError,
|
||||
`Failed to ensure memory path: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private async loadGraph(): Promise<KnowledgeGraph> {
|
||||
// Load graph from disk into memory (called once during initialization)
|
||||
private async _loadGraphFromDisk(): Promise<void> {
|
||||
try {
|
||||
const data = await fs.readFile(this.memoryPath, 'utf-8')
|
||||
const lines = data.split('\n').filter((line) => line.trim() !== '')
|
||||
return lines.reduce(
|
||||
(graph: KnowledgeGraph, line) => {
|
||||
const item = JSON.parse(line)
|
||||
if (item.type === 'entity') graph.entities.push(item as Entity)
|
||||
if (item.type === 'relation') graph.relations.push(item as Relation)
|
||||
return graph
|
||||
},
|
||||
{ entities: [], relations: [] }
|
||||
)
|
||||
// Handle empty file case
|
||||
if (data.trim() === '') {
|
||||
this.entities = new Map()
|
||||
this.relations = new Set()
|
||||
// Optionally write the initial empty structure back
|
||||
await this._persistGraph()
|
||||
return
|
||||
}
|
||||
const graph: KnowledgeGraph = JSON.parse(data)
|
||||
this.entities.clear()
|
||||
this.relations.clear()
|
||||
graph.entities.forEach((entity) => this.entities.set(entity.name, entity))
|
||||
graph.relations.forEach((relation) => this.relations.add(this._serializeRelation(relation)))
|
||||
} catch (error) {
|
||||
if (error instanceof Error && 'code' in error && (error as any).code === 'ENOENT') {
|
||||
return { entities: [], relations: [] }
|
||||
// File doesn't exist (should have been created by _ensureMemoryPathExists, but handle defensively)
|
||||
this.entities = new Map()
|
||||
this.relations = new Set()
|
||||
await this._persistGraph() // Create the file with empty structure
|
||||
} else if (error instanceof SyntaxError) {
|
||||
console.error('Failed to parse memory.json, initializing with empty graph:', error)
|
||||
// If JSON is invalid, start fresh and overwrite the corrupted file
|
||||
this.entities = new Map()
|
||||
this.relations = new Set()
|
||||
await this._persistGraph()
|
||||
} else {
|
||||
console.error('Failed to load knowledge graph from disk:', error)
|
||||
throw new McpError(
|
||||
ErrorCode.InternalError,
|
||||
`Failed to load graph: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async saveGraph(graph: KnowledgeGraph): Promise<void> {
|
||||
const lines = [
|
||||
...graph.entities.map((e) => JSON.stringify({ type: 'entity', ...e })),
|
||||
...graph.relations.map((r) => JSON.stringify({ type: 'relation', ...r }))
|
||||
]
|
||||
await fs.writeFile(this.memoryPath, lines.join('\n'))
|
||||
// Persist the current in-memory graph to disk using a mutex
|
||||
private async _persistGraph(): Promise<void> {
|
||||
const release = await this.fileMutex.acquire()
|
||||
try {
|
||||
const graphData: KnowledgeGraph = {
|
||||
entities: Array.from(this.entities.values()),
|
||||
relations: Array.from(this.relations).map((rStr) => this._deserializeRelation(rStr))
|
||||
}
|
||||
await fs.writeFile(this.memoryPath, JSON.stringify(graphData, null, 2))
|
||||
} catch (error) {
|
||||
console.error('Failed to save knowledge graph:', error)
|
||||
// Decide how to handle write errors - potentially retry or notify
|
||||
throw new McpError(
|
||||
ErrorCode.InternalError,
|
||||
`Failed to save graph: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to consistently serialize relations for Set storage
|
||||
private _serializeRelation(relation: Relation): string {
|
||||
// Simple serialization, ensure order doesn't matter if properties are consistent
|
||||
return JSON.stringify({ from: relation.from, to: relation.to, relationType: relation.relationType })
|
||||
}
|
||||
|
||||
// Helper to deserialize relations from Set storage
|
||||
private _deserializeRelation(relationStr: string): Relation {
|
||||
return JSON.parse(relationStr) as Relation
|
||||
}
|
||||
|
||||
async createEntities(entities: Entity[]): Promise<Entity[]> {
|
||||
const graph = await this.loadGraph()
|
||||
const newEntities = entities.filter((e) => !graph.entities.some((existingEntity) => existingEntity.name === e.name))
|
||||
graph.entities.push(...newEntities)
|
||||
await this.saveGraph(graph)
|
||||
const newEntities: Entity[] = []
|
||||
entities.forEach((entity) => {
|
||||
if (!this.entities.has(entity.name)) {
|
||||
// Ensure observations is always an array
|
||||
const newEntity = { ...entity, observations: Array.isArray(entity.observations) ? entity.observations : [] }
|
||||
this.entities.set(entity.name, newEntity)
|
||||
newEntities.push(newEntity)
|
||||
}
|
||||
})
|
||||
if (newEntities.length > 0) {
|
||||
await this._persistGraph()
|
||||
}
|
||||
return newEntities
|
||||
}
|
||||
|
||||
async createRelations(relations: Relation[]): Promise<Relation[]> {
|
||||
const graph = await this.loadGraph()
|
||||
const newRelations = relations.filter(
|
||||
(r) =>
|
||||
!graph.relations.some(
|
||||
(existingRelation) =>
|
||||
existingRelation.from === r.from &&
|
||||
existingRelation.to === r.to &&
|
||||
existingRelation.relationType === r.relationType
|
||||
)
|
||||
)
|
||||
graph.relations.push(...newRelations)
|
||||
await this.saveGraph(graph)
|
||||
const newRelations: Relation[] = []
|
||||
relations.forEach((relation) => {
|
||||
// Ensure related entities exist before creating a relation
|
||||
if (!this.entities.has(relation.from) || !this.entities.has(relation.to)) {
|
||||
console.warn(`Skipping relation creation: Entity not found for relation ${relation.from} -> ${relation.to}`)
|
||||
return // Skip this relation
|
||||
}
|
||||
const relationStr = this._serializeRelation(relation)
|
||||
if (!this.relations.has(relationStr)) {
|
||||
this.relations.add(relationStr)
|
||||
newRelations.push(relation)
|
||||
}
|
||||
})
|
||||
if (newRelations.length > 0) {
|
||||
await this._persistGraph()
|
||||
}
|
||||
return newRelations
|
||||
}
|
||||
|
||||
async addObservations(
|
||||
observations: { entityName: string; contents: string[] }[]
|
||||
): Promise<{ entityName: string; addedObservations: string[] }[]> {
|
||||
const graph = await this.loadGraph()
|
||||
const results = observations.map((o) => {
|
||||
const entity = graph.entities.find((e) => e.name === o.entityName)
|
||||
const results: { entityName: string; addedObservations: string[] }[] = []
|
||||
let changed = false
|
||||
observations.forEach((o) => {
|
||||
const entity = this.entities.get(o.entityName)
|
||||
if (!entity) {
|
||||
throw new Error(`Entity with name ${o.entityName} not found`)
|
||||
// Option 1: Throw error
|
||||
throw new McpError(ErrorCode.InvalidParams, `Entity with name ${o.entityName} not found`)
|
||||
// Option 2: Skip and warn
|
||||
// console.warn(`Entity with name ${o.entityName} not found when adding observations. Skipping.`);
|
||||
// return;
|
||||
}
|
||||
// Ensure observations array exists
|
||||
if (!Array.isArray(entity.observations)) {
|
||||
entity.observations = []
|
||||
}
|
||||
const newObservations = o.contents.filter((content) => !entity.observations.includes(content))
|
||||
entity.observations.push(...newObservations)
|
||||
return { entityName: o.entityName, addedObservations: newObservations }
|
||||
if (newObservations.length > 0) {
|
||||
entity.observations.push(...newObservations)
|
||||
results.push({ entityName: o.entityName, addedObservations: newObservations })
|
||||
changed = true
|
||||
} else {
|
||||
// Still include in results even if nothing was added, to confirm processing
|
||||
results.push({ entityName: o.entityName, addedObservations: [] })
|
||||
}
|
||||
})
|
||||
await this.saveGraph(graph)
|
||||
if (changed) {
|
||||
await this._persistGraph()
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
async deleteEntities(entityNames: string[]): Promise<void> {
|
||||
const graph = await this.loadGraph()
|
||||
graph.entities = graph.entities.filter((e) => !entityNames.includes(e.name))
|
||||
graph.relations = graph.relations.filter((r) => !entityNames.includes(r.from) && !entityNames.includes(r.to))
|
||||
await this.saveGraph(graph)
|
||||
let changed = false
|
||||
const namesToDelete = new Set(entityNames)
|
||||
|
||||
// Delete entities
|
||||
namesToDelete.forEach((name) => {
|
||||
if (this.entities.delete(name)) {
|
||||
changed = true
|
||||
}
|
||||
})
|
||||
|
||||
// Delete relations involving deleted entities
|
||||
const relationsToDelete = new Set<string>()
|
||||
this.relations.forEach((relStr) => {
|
||||
const rel = this._deserializeRelation(relStr)
|
||||
if (namesToDelete.has(rel.from) || namesToDelete.has(rel.to)) {
|
||||
relationsToDelete.add(relStr)
|
||||
}
|
||||
})
|
||||
|
||||
relationsToDelete.forEach((relStr) => {
|
||||
if (this.relations.delete(relStr)) {
|
||||
changed = true
|
||||
}
|
||||
})
|
||||
|
||||
if (changed) {
|
||||
await this._persistGraph()
|
||||
}
|
||||
}
|
||||
|
||||
async deleteObservations(deletions: { entityName: string; observations: string[] }[]): Promise<void> {
|
||||
const graph = await this.loadGraph()
|
||||
let changed = false
|
||||
deletions.forEach((d) => {
|
||||
const entity = graph.entities.find((e) => e.name === d.entityName)
|
||||
if (entity) {
|
||||
entity.observations = entity.observations.filter((o) => !d.observations.includes(o))
|
||||
const entity = this.entities.get(d.entityName)
|
||||
if (entity && Array.isArray(entity.observations)) {
|
||||
const initialLength = entity.observations.length
|
||||
const observationsToDelete = new Set(d.observations)
|
||||
entity.observations = entity.observations.filter((o) => !observationsToDelete.has(o))
|
||||
if (entity.observations.length !== initialLength) {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
})
|
||||
await this.saveGraph(graph)
|
||||
if (changed) {
|
||||
await this._persistGraph()
|
||||
}
|
||||
}
|
||||
|
||||
async deleteRelations(relations: Relation[]): Promise<void> {
|
||||
const graph = await this.loadGraph()
|
||||
graph.relations = graph.relations.filter(
|
||||
(r) =>
|
||||
!relations.some(
|
||||
(delRelation) =>
|
||||
r.from === delRelation.from && r.to === delRelation.to && r.relationType === delRelation.relationType
|
||||
)
|
||||
)
|
||||
await this.saveGraph(graph)
|
||||
let changed = false
|
||||
relations.forEach((rel) => {
|
||||
const relStr = this._serializeRelation(rel)
|
||||
if (this.relations.delete(relStr)) {
|
||||
changed = true
|
||||
}
|
||||
})
|
||||
if (changed) {
|
||||
await this._persistGraph()
|
||||
}
|
||||
}
|
||||
|
||||
// Read the current state from memory
|
||||
async readGraph(): Promise<KnowledgeGraph> {
|
||||
return this.loadGraph()
|
||||
// Return a deep copy to prevent external modification of the internal state
|
||||
return JSON.parse(
|
||||
JSON.stringify({
|
||||
entities: Array.from(this.entities.values()),
|
||||
relations: Array.from(this.relations).map((rStr) => this._deserializeRelation(rStr))
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Very basic search function
|
||||
// Search operates on the in-memory graph
|
||||
async searchNodes(query: string): Promise<KnowledgeGraph> {
|
||||
const graph = await this.loadGraph()
|
||||
|
||||
// Filter entities
|
||||
const filteredEntities = graph.entities.filter(
|
||||
const lowerCaseQuery = query.toLowerCase()
|
||||
const filteredEntities = Array.from(this.entities.values()).filter(
|
||||
(e) =>
|
||||
e.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||
e.entityType.toLowerCase().includes(query.toLowerCase()) ||
|
||||
e.observations.some((o) => o.toLowerCase().includes(query.toLowerCase()))
|
||||
e.name.toLowerCase().includes(lowerCaseQuery) ||
|
||||
e.entityType.toLowerCase().includes(lowerCaseQuery) ||
|
||||
(Array.isArray(e.observations) && e.observations.some((o) => o.toLowerCase().includes(lowerCaseQuery)))
|
||||
)
|
||||
|
||||
// Create a Set of filtered entity names for quick lookup
|
||||
const filteredEntityNames = new Set(filteredEntities.map((e) => e.name))
|
||||
|
||||
// Filter relations to only include those between filtered entities
|
||||
const filteredRelations = graph.relations.filter(
|
||||
(r) => filteredEntityNames.has(r.from) && filteredEntityNames.has(r.to)
|
||||
)
|
||||
const filteredRelations = Array.from(this.relations)
|
||||
.map((rStr) => this._deserializeRelation(rStr))
|
||||
.filter((r) => filteredEntityNames.has(r.from) && filteredEntityNames.has(r.to))
|
||||
|
||||
const filteredGraph: KnowledgeGraph = {
|
||||
return {
|
||||
entities: filteredEntities,
|
||||
relations: filteredRelations
|
||||
}
|
||||
|
||||
return filteredGraph
|
||||
}
|
||||
|
||||
// Open operates on the in-memory graph
|
||||
async openNodes(names: string[]): Promise<KnowledgeGraph> {
|
||||
const graph = await this.loadGraph()
|
||||
|
||||
// Filter entities
|
||||
const filteredEntities = graph.entities.filter((e) => names.includes(e.name))
|
||||
|
||||
// Create a Set of filtered entity names for quick lookup
|
||||
const nameSet = new Set(names)
|
||||
const filteredEntities = Array.from(this.entities.values()).filter((e) => nameSet.has(e.name))
|
||||
const filteredEntityNames = new Set(filteredEntities.map((e) => e.name))
|
||||
|
||||
// Filter relations to only include those between filtered entities
|
||||
const filteredRelations = graph.relations.filter(
|
||||
(r) => filteredEntityNames.has(r.from) && filteredEntityNames.has(r.to)
|
||||
)
|
||||
const filteredRelations = Array.from(this.relations)
|
||||
.map((rStr) => this._deserializeRelation(rStr))
|
||||
.filter((r) => filteredEntityNames.has(r.from) && filteredEntityNames.has(r.to))
|
||||
|
||||
const filteredGraph: KnowledgeGraph = {
|
||||
return {
|
||||
entities: filteredEntities,
|
||||
relations: filteredRelations
|
||||
}
|
||||
|
||||
return filteredGraph
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryServer {
|
||||
public server: Server
|
||||
private knowledgeGraphManager: KnowledgeGraphManager
|
||||
// Hold the manager instance, initialized asynchronously
|
||||
private knowledgeGraphManager: KnowledgeGraphManager | null = null
|
||||
private initializationPromise: Promise<void> // To track initialization
|
||||
|
||||
constructor(envPath: string = '') {
|
||||
const memoryPath = envPath
|
||||
? path.isAbsolute(envPath)
|
||||
? envPath
|
||||
: path.join(path.dirname(fileURLToPath(import.meta.url)), envPath)
|
||||
: path.resolve(envPath) // Use path.resolve for relative paths based on CWD
|
||||
: defaultMemoryPath
|
||||
this.knowledgeGraphManager = new KnowledgeGraphManager(memoryPath)
|
||||
|
||||
this.server = new Server(
|
||||
{
|
||||
name: 'memory-server',
|
||||
version: '1.0.0'
|
||||
version: '1.1.0' // Incremented version for changes
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
@@ -231,17 +346,53 @@ class MemoryServer {
|
||||
}
|
||||
}
|
||||
)
|
||||
this.initialize()
|
||||
// Start initialization, but don't block constructor
|
||||
this.initializationPromise = this._initializeManager(memoryPath)
|
||||
this.setupRequestHandlers() // Setup handlers immediately
|
||||
}
|
||||
|
||||
initialize() {
|
||||
// The server instance and tools exposed to Claude
|
||||
// Private async method to handle manager initialization
|
||||
private async _initializeManager(memoryPath: string): Promise<void> {
|
||||
try {
|
||||
this.knowledgeGraphManager = await KnowledgeGraphManager.create(memoryPath)
|
||||
console.log('KnowledgeGraphManager initialized successfully.')
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize KnowledgeGraphManager:', error)
|
||||
// Server might be unusable, consider how to handle this state
|
||||
// Maybe set a flag and return errors for all tool calls?
|
||||
this.knowledgeGraphManager = null // Ensure it's null if init fails
|
||||
}
|
||||
}
|
||||
|
||||
// Ensures the manager is initialized before handling tool calls
|
||||
private async _getManager(): Promise<KnowledgeGraphManager> {
|
||||
await this.initializationPromise // Wait for initialization to complete
|
||||
if (!this.knowledgeGraphManager) {
|
||||
throw new McpError(ErrorCode.InternalError, 'Memory server failed to initialize. Cannot process requests.')
|
||||
}
|
||||
return this.knowledgeGraphManager
|
||||
}
|
||||
|
||||
// Setup handlers (can be called from constructor)
|
||||
setupRequestHandlers() {
|
||||
// ListTools remains largely the same, descriptions might be updated if needed
|
||||
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
// Ensure manager is ready before listing tools that depend on it
|
||||
// Although ListTools itself doesn't *call* the manager, it implies the
|
||||
// manager is ready to handle calls for those tools.
|
||||
try {
|
||||
await this._getManager() // Wait for initialization before confirming tools are available
|
||||
} catch (error) {
|
||||
// If manager failed to init, maybe return an empty tool list or throw?
|
||||
console.error('Cannot list tools, manager initialization failed:', error)
|
||||
return { tools: [] } // Return empty list if server is not ready
|
||||
}
|
||||
|
||||
return {
|
||||
tools: [
|
||||
{
|
||||
name: 'create_entities',
|
||||
description: 'Create multiple new entities in the knowledge graph',
|
||||
description: 'Create multiple new entities in the knowledge graph. Skips existing entities.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -255,10 +406,11 @@ class MemoryServer {
|
||||
observations: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'An array of observation contents associated with the entity'
|
||||
description: 'An array of observation contents associated with the entity',
|
||||
default: [] // Add default empty array
|
||||
}
|
||||
},
|
||||
required: ['name', 'entityType', 'observations']
|
||||
required: ['name', 'entityType'] // Observations are optional now on creation
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -268,7 +420,7 @@ class MemoryServer {
|
||||
{
|
||||
name: 'create_relations',
|
||||
description:
|
||||
'Create multiple new relations between entities in the knowledge graph. Relations should be in active voice',
|
||||
'Create multiple new relations between EXISTING entities. Skips existing relations or relations with non-existent entities.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -290,7 +442,7 @@ class MemoryServer {
|
||||
},
|
||||
{
|
||||
name: 'add_observations',
|
||||
description: 'Add new observations to existing entities in the knowledge graph',
|
||||
description: 'Add new observations to existing entities. Skips duplicate observations.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -315,7 +467,7 @@ class MemoryServer {
|
||||
},
|
||||
{
|
||||
name: 'delete_entities',
|
||||
description: 'Delete multiple entities and their associated relations from the knowledge graph',
|
||||
description: 'Delete multiple entities and their associated relations.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -330,7 +482,7 @@ class MemoryServer {
|
||||
},
|
||||
{
|
||||
name: 'delete_observations',
|
||||
description: 'Delete specific observations from entities in the knowledge graph',
|
||||
description: 'Delete specific observations from entities.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -355,7 +507,7 @@ class MemoryServer {
|
||||
},
|
||||
{
|
||||
name: 'delete_relations',
|
||||
description: 'Delete multiple relations from the knowledge graph',
|
||||
description: 'Delete multiple specific relations.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -378,7 +530,7 @@ class MemoryServer {
|
||||
},
|
||||
{
|
||||
name: 'read_graph',
|
||||
description: 'Read the entire knowledge graph',
|
||||
description: 'Read the entire knowledge graph from memory.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {}
|
||||
@@ -386,7 +538,7 @@ class MemoryServer {
|
||||
},
|
||||
{
|
||||
name: 'search_nodes',
|
||||
description: 'Search for nodes in the knowledge graph based on a query',
|
||||
description: 'Search nodes (entities and relations) in memory based on a query.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -400,7 +552,7 @@ class MemoryServer {
|
||||
},
|
||||
{
|
||||
name: 'open_nodes',
|
||||
description: 'Open specific nodes in the knowledge graph by their names',
|
||||
description: 'Retrieve specific entities and their connecting relations from memory by name.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -417,90 +569,129 @@ class MemoryServer {
|
||||
}
|
||||
})
|
||||
|
||||
// CallTool handler needs to await the manager and the async methods
|
||||
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const manager = await this._getManager() // Ensure manager is ready
|
||||
const { name, arguments: args } = request.params
|
||||
|
||||
if (!args) {
|
||||
throw new Error(`No arguments provided for tool: ${name}`)
|
||||
// Use McpError for standard errors
|
||||
throw new McpError(ErrorCode.InvalidParams, `No arguments provided for tool: ${name}`)
|
||||
}
|
||||
|
||||
switch (name) {
|
||||
case 'create_entities':
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
await this.knowledgeGraphManager.createEntities(args.entities as Entity[]),
|
||||
null,
|
||||
2
|
||||
)
|
||||
}
|
||||
]
|
||||
}
|
||||
case 'create_relations':
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
await this.knowledgeGraphManager.createRelations(args.relations as Relation[]),
|
||||
null,
|
||||
2
|
||||
)
|
||||
}
|
||||
]
|
||||
}
|
||||
case 'add_observations':
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
await this.knowledgeGraphManager.addObservations(
|
||||
args.observations as { entityName: string; contents: string[] }[]
|
||||
),
|
||||
null,
|
||||
2
|
||||
)
|
||||
}
|
||||
]
|
||||
}
|
||||
case 'delete_entities':
|
||||
await this.knowledgeGraphManager.deleteEntities(args.entityNames as string[])
|
||||
return { content: [{ type: 'text', text: 'Entities deleted successfully' }] }
|
||||
case 'delete_observations':
|
||||
await this.knowledgeGraphManager.deleteObservations(
|
||||
args.deletions as { entityName: string; observations: string[] }[]
|
||||
)
|
||||
return { content: [{ type: 'text', text: 'Observations deleted successfully' }] }
|
||||
case 'delete_relations':
|
||||
await this.knowledgeGraphManager.deleteRelations(args.relations as Relation[])
|
||||
return { content: [{ type: 'text', text: 'Relations deleted successfully' }] }
|
||||
case 'read_graph':
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(await this.knowledgeGraphManager.readGraph(), null, 2) }]
|
||||
}
|
||||
case 'search_nodes':
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(await this.knowledgeGraphManager.searchNodes(args.query as string), null, 2)
|
||||
}
|
||||
]
|
||||
}
|
||||
case 'open_nodes':
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(await this.knowledgeGraphManager.openNodes(args.names as string[]), null, 2)
|
||||
}
|
||||
]
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown tool: ${name}`)
|
||||
try {
|
||||
switch (name) {
|
||||
case 'create_entities':
|
||||
// Validate args structure if necessary, though SDK might do basic validation
|
||||
if (!args.entities || !Array.isArray(args.entities)) {
|
||||
throw new McpError(
|
||||
ErrorCode.InvalidParams,
|
||||
`Invalid arguments for ${name}: 'entities' array is required.`
|
||||
)
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{ type: 'text', text: JSON.stringify(await manager.createEntities(args.entities as Entity[]), null, 2) }
|
||||
]
|
||||
}
|
||||
case 'create_relations':
|
||||
if (!args.relations || !Array.isArray(args.relations)) {
|
||||
throw new McpError(
|
||||
ErrorCode.InvalidParams,
|
||||
`Invalid arguments for ${name}: 'relations' array is required.`
|
||||
)
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(await manager.createRelations(args.relations as Relation[]), null, 2)
|
||||
}
|
||||
]
|
||||
}
|
||||
case 'add_observations':
|
||||
if (!args.observations || !Array.isArray(args.observations)) {
|
||||
throw new McpError(
|
||||
ErrorCode.InvalidParams,
|
||||
`Invalid arguments for ${name}: 'observations' array is required.`
|
||||
)
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
await manager.addObservations(args.observations as { entityName: string; contents: string[] }[]),
|
||||
null,
|
||||
2
|
||||
)
|
||||
}
|
||||
]
|
||||
}
|
||||
case 'delete_entities':
|
||||
if (!args.entityNames || !Array.isArray(args.entityNames)) {
|
||||
throw new McpError(
|
||||
ErrorCode.InvalidParams,
|
||||
`Invalid arguments for ${name}: 'entityNames' array is required.`
|
||||
)
|
||||
}
|
||||
await manager.deleteEntities(args.entityNames as string[])
|
||||
return { content: [{ type: 'text', text: 'Entities deleted successfully' }] }
|
||||
case 'delete_observations':
|
||||
if (!args.deletions || !Array.isArray(args.deletions)) {
|
||||
throw new McpError(
|
||||
ErrorCode.InvalidParams,
|
||||
`Invalid arguments for ${name}: 'deletions' array is required.`
|
||||
)
|
||||
}
|
||||
await manager.deleteObservations(args.deletions as { entityName: string; observations: string[] }[])
|
||||
return { content: [{ type: 'text', text: 'Observations deleted successfully' }] }
|
||||
case 'delete_relations':
|
||||
if (!args.relations || !Array.isArray(args.relations)) {
|
||||
throw new McpError(
|
||||
ErrorCode.InvalidParams,
|
||||
`Invalid arguments for ${name}: 'relations' array is required.`
|
||||
)
|
||||
}
|
||||
await manager.deleteRelations(args.relations as Relation[])
|
||||
return { content: [{ type: 'text', text: 'Relations deleted successfully' }] }
|
||||
case 'read_graph':
|
||||
// No arguments expected or needed for read_graph based on original schema
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(await manager.readGraph(), null, 2) }]
|
||||
}
|
||||
case 'search_nodes':
|
||||
if (typeof args.query !== 'string') {
|
||||
throw new McpError(ErrorCode.InvalidParams, `Invalid arguments for ${name}: 'query' string is required.`)
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{ type: 'text', text: JSON.stringify(await manager.searchNodes(args.query as string), null, 2) }
|
||||
]
|
||||
}
|
||||
case 'open_nodes':
|
||||
if (!args.names || !Array.isArray(args.names)) {
|
||||
throw new McpError(ErrorCode.InvalidParams, `Invalid arguments for ${name}: 'names' array is required.`)
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{ type: 'text', text: JSON.stringify(await manager.openNodes(args.names as string[]), null, 2) }
|
||||
]
|
||||
}
|
||||
default:
|
||||
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`)
|
||||
}
|
||||
} catch (error) {
|
||||
// Catch errors from manager methods (like entity not found) or other issues
|
||||
if (error instanceof McpError) {
|
||||
throw error // Re-throw McpErrors directly
|
||||
}
|
||||
console.error(`Error executing tool ${name}:`, error)
|
||||
// Throw a generic internal error for unexpected issues
|
||||
throw new McpError(
|
||||
ErrorCode.InternalError,
|
||||
`Error executing tool ${name}: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
|
||||
import AxiosProxy from '@main/services/AxiosProxy'
|
||||
import { KnowledgeBaseParams } from '@types'
|
||||
import axios from 'axios'
|
||||
|
||||
import BaseReranker from './BaseReranker'
|
||||
|
||||
@@ -20,7 +20,7 @@ export default class JinaReranker extends BaseReranker {
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await axios.post(url, requestBody, { headers: this.defaultHeaders() })
|
||||
const { data } = await AxiosProxy.axios.post(url, requestBody, { headers: this.defaultHeaders() })
|
||||
|
||||
const rerankResults = data.results
|
||||
return this.getRerankResult(searchResults, rerankResults)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
|
||||
import axiosProxy from '@main/services/AxiosProxy'
|
||||
import { KnowledgeBaseParams } from '@types'
|
||||
import axios from 'axios'
|
||||
|
||||
import BaseReranker from './BaseReranker'
|
||||
|
||||
@@ -22,7 +22,7 @@ export default class SiliconFlowReranker extends BaseReranker {
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await axios.post(url, requestBody, { headers: this.defaultHeaders() })
|
||||
const { data } = await axiosProxy.axios.post(url, requestBody, { headers: this.defaultHeaders() })
|
||||
|
||||
const rerankResults = data.results
|
||||
return this.getRerankResult(searchResults, rerankResults)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
|
||||
import axiosProxy from '@main/services/AxiosProxy'
|
||||
import { KnowledgeBaseParams } from '@types'
|
||||
import axios from 'axios'
|
||||
|
||||
import BaseReranker from './BaseReranker'
|
||||
|
||||
@@ -22,7 +22,7 @@ export default class VoyageReranker extends BaseReranker {
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await axios.post(url, requestBody, {
|
||||
const { data } = await axiosProxy.axios.post(url, requestBody, {
|
||||
headers: {
|
||||
...this.defaultHeaders()
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import logger from 'electron-log'
|
||||
import { AppUpdater as _AppUpdater, autoUpdater } from 'electron-updater'
|
||||
|
||||
import icon from '../../../build/icon.png?asset'
|
||||
import { configManager } from './ConfigManager'
|
||||
|
||||
export default class AppUpdater {
|
||||
autoUpdater: _AppUpdater = autoUpdater
|
||||
@@ -15,7 +16,8 @@ export default class AppUpdater {
|
||||
|
||||
autoUpdater.logger = logger
|
||||
autoUpdater.forceDevUpdateConfig = !app.isPackaged
|
||||
autoUpdater.autoDownload = true
|
||||
autoUpdater.autoDownload = configManager.getAutoUpdate()
|
||||
autoUpdater.autoInstallOnAppQuit = configManager.getAutoUpdate()
|
||||
|
||||
// 检测下载错误
|
||||
autoUpdater.on('error', (error) => {
|
||||
@@ -53,6 +55,11 @@ export default class AppUpdater {
|
||||
this.autoUpdater = autoUpdater
|
||||
}
|
||||
|
||||
public setAutoUpdate(isActive: boolean) {
|
||||
autoUpdater.autoDownload = isActive
|
||||
autoUpdater.autoInstallOnAppQuit = isActive
|
||||
}
|
||||
|
||||
public async showUpdateDialog(mainWindow: BrowserWindow) {
|
||||
if (!this.releaseInfo) {
|
||||
return
|
||||
|
||||
29
src/main/services/AxiosProxy.ts
Normal file
29
src/main/services/AxiosProxy.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { AxiosInstance, default as axios_ } from 'axios'
|
||||
import { ProxyAgent } from 'proxy-agent'
|
||||
|
||||
import { proxyManager } from './ProxyManager'
|
||||
|
||||
class AxiosProxy {
|
||||
private cacheAxios: AxiosInstance | null = null
|
||||
private proxyAgent: ProxyAgent | null = null
|
||||
|
||||
get axios(): AxiosInstance {
|
||||
const currentProxyAgent = proxyManager.getProxyAgent()
|
||||
|
||||
// 如果代理发生变化或尚未初始化,则重新创建 axios 实例
|
||||
if (this.cacheAxios === null || (currentProxyAgent !== null && this.proxyAgent !== currentProxyAgent)) {
|
||||
this.proxyAgent = currentProxyAgent
|
||||
|
||||
// 创建带有代理配置的 axios 实例
|
||||
this.cacheAxios = axios_.create({
|
||||
proxy: false,
|
||||
httpAgent: currentProxyAgent || undefined,
|
||||
httpsAgent: currentProxyAgent || undefined
|
||||
})
|
||||
}
|
||||
|
||||
return this.cacheAxios
|
||||
}
|
||||
}
|
||||
|
||||
export default new AxiosProxy()
|
||||
@@ -1,9 +1,10 @@
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { WebDavConfig } from '@types'
|
||||
import AdmZip from 'adm-zip'
|
||||
import archiver from 'archiver'
|
||||
import { exec } from 'child_process'
|
||||
import { app } from 'electron'
|
||||
import Logger from 'electron-log'
|
||||
import extract from 'extract-zip'
|
||||
import * as fs from 'fs-extra'
|
||||
import * as path from 'path'
|
||||
import { createClient, CreateDirectoryOptions, FileStat } from 'webdav'
|
||||
@@ -22,6 +23,7 @@ class BackupManager {
|
||||
this.backupToWebdav = this.backupToWebdav.bind(this)
|
||||
this.restoreFromWebdav = this.restoreFromWebdav.bind(this)
|
||||
this.listWebdavFiles = this.listWebdavFiles.bind(this)
|
||||
this.deleteWebdavFile = this.deleteWebdavFile.bind(this)
|
||||
}
|
||||
|
||||
private async setWritableRecursive(dirPath: string): Promise<void> {
|
||||
@@ -90,6 +92,7 @@ class BackupManager {
|
||||
|
||||
// 使用流的方式写入 data.json
|
||||
const tempDataPath = path.join(this.tempDir, 'data.json')
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const writeStream = fs.createWriteStream(tempDataPath)
|
||||
writeStream.write(data)
|
||||
@@ -98,6 +101,7 @@ class BackupManager {
|
||||
writeStream.on('finish', () => resolve())
|
||||
writeStream.on('error', (error) => reject(error))
|
||||
})
|
||||
|
||||
onProgress({ stage: 'writing_data', progress: 20, total: 100 })
|
||||
|
||||
// 复制 Data 目录到临时目录
|
||||
@@ -111,18 +115,92 @@ class BackupManager {
|
||||
// 使用流式复制
|
||||
await this.copyDirWithProgress(sourcePath, tempDataDir, (size) => {
|
||||
copiedSize += size
|
||||
const progress = Math.min(80, 20 + Math.floor((copiedSize / totalSize) * 60))
|
||||
const progress = Math.min(50, Math.floor((copiedSize / totalSize) * 50))
|
||||
onProgress({ stage: 'copying_files', progress, total: 100 })
|
||||
})
|
||||
|
||||
await this.setWritableRecursive(tempDataDir)
|
||||
onProgress({ stage: 'compressing', progress: 80, total: 100 })
|
||||
onProgress({ stage: 'preparing_compression', progress: 50, total: 100 })
|
||||
|
||||
// 使用 adm-zip 创建压缩文件
|
||||
const zip = new AdmZip()
|
||||
zip.addLocalFolder(this.tempDir)
|
||||
// 创建输出文件流
|
||||
const backupedFilePath = path.join(destinationPath, fileName)
|
||||
zip.writeZip(backupedFilePath)
|
||||
const output = fs.createWriteStream(backupedFilePath)
|
||||
|
||||
// 创建 archiver 实例,启用 ZIP64 支持
|
||||
const archive = archiver('zip', {
|
||||
zlib: { level: 1 }, // 使用最低压缩级别以提高速度
|
||||
zip64: true // 启用 ZIP64 支持以处理大文件
|
||||
})
|
||||
|
||||
let lastProgress = 50
|
||||
let totalEntries = 0
|
||||
let processedEntries = 0
|
||||
let totalBytes = 0
|
||||
let processedBytes = 0
|
||||
|
||||
// 首先计算总文件数和总大小
|
||||
const calculateTotals = async (dirPath: string) => {
|
||||
const items = await fs.readdir(dirPath, { withFileTypes: true })
|
||||
for (const item of items) {
|
||||
const fullPath = path.join(dirPath, item.name)
|
||||
if (item.isDirectory()) {
|
||||
await calculateTotals(fullPath)
|
||||
} else {
|
||||
totalEntries++
|
||||
const stats = await fs.stat(fullPath)
|
||||
totalBytes += stats.size
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await calculateTotals(this.tempDir)
|
||||
|
||||
// 监听文件添加事件
|
||||
archive.on('entry', () => {
|
||||
processedEntries++
|
||||
if (totalEntries > 0) {
|
||||
const progressPercent = Math.min(55, 50 + Math.floor((processedEntries / totalEntries) * 5))
|
||||
if (progressPercent > lastProgress) {
|
||||
lastProgress = progressPercent
|
||||
onProgress({ stage: 'compressing', progress: progressPercent, total: 100 })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 监听数据写入事件
|
||||
archive.on('data', (chunk) => {
|
||||
processedBytes += chunk.length
|
||||
if (totalBytes > 0) {
|
||||
const progressPercent = Math.min(99, 55 + Math.floor((processedBytes / totalBytes) * 44))
|
||||
if (progressPercent > lastProgress) {
|
||||
lastProgress = progressPercent
|
||||
onProgress({ stage: 'compressing', progress: progressPercent, total: 100 })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 使用 Promise 等待压缩完成
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
output.on('close', () => {
|
||||
onProgress({ stage: 'compressing', progress: 100, total: 100 })
|
||||
resolve()
|
||||
})
|
||||
archive.on('error', reject)
|
||||
archive.on('warning', (err: any) => {
|
||||
if (err.code !== 'ENOENT') {
|
||||
Logger.warn('[BackupManager] Archive warning:', err)
|
||||
}
|
||||
})
|
||||
|
||||
// 将输出流连接到压缩器
|
||||
archive.pipe(output)
|
||||
|
||||
// 添加整个临时目录到压缩文件
|
||||
archive.directory(this.tempDir, false)
|
||||
|
||||
// 完成压缩
|
||||
archive.finalize()
|
||||
})
|
||||
|
||||
// 清理临时目录
|
||||
await fs.remove(this.tempDir)
|
||||
@@ -132,6 +210,8 @@ class BackupManager {
|
||||
return backupedFilePath
|
||||
} catch (error) {
|
||||
Logger.error('[BackupManager] Backup failed:', error)
|
||||
// 确保清理临时目录
|
||||
await fs.remove(this.tempDir).catch(() => {})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -150,16 +230,22 @@ class BackupManager {
|
||||
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 })
|
||||
|
||||
// 使用 extract-zip 解压
|
||||
await extract(backupPath, {
|
||||
dir: this.tempDir,
|
||||
onEntry: () => {
|
||||
// 这里可以处理进度,但 extract-zip 不提供总条目数信息
|
||||
onProgress({ stage: 'extracting', progress: 15, total: 100 })
|
||||
}
|
||||
})
|
||||
onProgress({ stage: 'extracting', progress: 25, 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 })
|
||||
onProgress({ stage: 'reading_data', progress: 35, total: 100 })
|
||||
|
||||
Logger.log('[backup] step 3: restore Data directory')
|
||||
// 恢复 Data 目录
|
||||
@@ -176,7 +262,7 @@ class BackupManager {
|
||||
// 使用流式复制
|
||||
await this.copyDirWithProgress(sourcePath, destPath, (size) => {
|
||||
copiedSize += size
|
||||
const progress = Math.min(90, 40 + Math.floor((copiedSize / totalSize) * 50))
|
||||
const progress = Math.min(85, 35 + Math.floor((copiedSize / totalSize) * 50))
|
||||
onProgress({ stage: 'copying_files', progress, total: 100 })
|
||||
})
|
||||
|
||||
@@ -309,6 +395,16 @@ class BackupManager {
|
||||
const webdavClient = new WebDav(webdavConfig)
|
||||
return await webdavClient.createDirectory(path, options)
|
||||
}
|
||||
|
||||
async deleteWebdavFile(_: Electron.IpcMainInvokeEvent, fileName: string, webdavConfig: WebDavConfig) {
|
||||
try {
|
||||
const webdavClient = new WebDav(webdavConfig)
|
||||
return await webdavClient.deleteFile(fileName)
|
||||
} catch (error: any) {
|
||||
Logger.error('Failed to delete WebDAV file:', error)
|
||||
throw new Error(error.message || 'Failed to delete backup file')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default BackupManager
|
||||
|
||||
@@ -14,7 +14,9 @@ enum ConfigKeys {
|
||||
ZoomFactor = 'ZoomFactor',
|
||||
Shortcuts = 'shortcuts',
|
||||
ClickTrayToShowQuickAssistant = 'clickTrayToShowQuickAssistant',
|
||||
EnableQuickAssistant = 'enableQuickAssistant'
|
||||
EnableQuickAssistant = 'enableQuickAssistant',
|
||||
AutoUpdate = 'autoUpdate',
|
||||
EnableDataCollection = 'enableDataCollection'
|
||||
}
|
||||
|
||||
export class ConfigManager {
|
||||
@@ -42,6 +44,14 @@ export class ConfigManager {
|
||||
this.set(ConfigKeys.Theme, theme)
|
||||
}
|
||||
|
||||
getCustomCss(): string {
|
||||
return this.store.get('customCss', '') as string
|
||||
}
|
||||
|
||||
setCustomCss(css: string) {
|
||||
this.store.set('customCss', css)
|
||||
}
|
||||
|
||||
getLaunchToTray(): boolean {
|
||||
return !!this.get(ConfigKeys.LaunchToTray, false)
|
||||
}
|
||||
@@ -128,6 +138,22 @@ export class ConfigManager {
|
||||
this.set(ConfigKeys.EnableQuickAssistant, value)
|
||||
}
|
||||
|
||||
getAutoUpdate(): boolean {
|
||||
return this.get<boolean>(ConfigKeys.AutoUpdate, true)
|
||||
}
|
||||
|
||||
setAutoUpdate(value: boolean) {
|
||||
this.set(ConfigKeys.AutoUpdate, value)
|
||||
}
|
||||
|
||||
getEnableDataCollection(): boolean {
|
||||
return this.get<boolean>(ConfigKeys.EnableDataCollection, true)
|
||||
}
|
||||
|
||||
setEnableDataCollection(value: boolean) {
|
||||
this.set(ConfigKeys.EnableDataCollection, value)
|
||||
}
|
||||
|
||||
set(key: string, value: unknown) {
|
||||
this.store.set(key, value)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import axios, { AxiosRequestConfig } from 'axios'
|
||||
import { AxiosRequestConfig } from 'axios'
|
||||
import { app, safeStorage } from 'electron'
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
|
||||
import aoxisProxy from './AxiosProxy'
|
||||
|
||||
// 配置常量,集中管理
|
||||
const CONFIG = {
|
||||
GITHUB_CLIENT_ID: 'Iv1.b507a08c87ecfe98',
|
||||
@@ -93,7 +95,7 @@ class CopilotService {
|
||||
}
|
||||
}
|
||||
|
||||
const response = await axios.get(CONFIG.API_URLS.GITHUB_USER, config)
|
||||
const response = await aoxisProxy.axios.get(CONFIG.API_URLS.GITHUB_USER, config)
|
||||
return {
|
||||
login: response.data.login,
|
||||
avatar: response.data.avatar_url
|
||||
@@ -114,7 +116,7 @@ class CopilotService {
|
||||
try {
|
||||
this.updateHeaders(headers)
|
||||
|
||||
const response = await axios.post<AuthResponse>(
|
||||
const response = await aoxisProxy.axios.post<AuthResponse>(
|
||||
CONFIG.API_URLS.GITHUB_DEVICE_CODE,
|
||||
{
|
||||
client_id: CONFIG.GITHUB_CLIENT_ID,
|
||||
@@ -146,7 +148,7 @@ class CopilotService {
|
||||
await this.delay(currentDelay)
|
||||
|
||||
try {
|
||||
const response = await axios.post<TokenResponse>(
|
||||
const response = await aoxisProxy.axios.post<TokenResponse>(
|
||||
CONFIG.API_URLS.GITHUB_ACCESS_TOKEN,
|
||||
{
|
||||
client_id: CONFIG.GITHUB_CLIENT_ID,
|
||||
@@ -208,7 +210,7 @@ class CopilotService {
|
||||
}
|
||||
}
|
||||
|
||||
const response = await axios.get<CopilotTokenResponse>(CONFIG.API_URLS.COPILOT_TOKEN, config)
|
||||
const response = await aoxisProxy.axios.get<CopilotTokenResponse>(CONFIG.API_URLS.COPILOT_TOKEN, config)
|
||||
|
||||
return response.data
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FileMetadataResponse, FileState, GoogleAIFileManager } from '@google/generative-ai/server'
|
||||
import { File, FileState, GoogleGenAI, Pager } from '@google/genai'
|
||||
import { FileType } from '@types'
|
||||
import fs from 'fs'
|
||||
|
||||
@@ -8,11 +8,15 @@ 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
|
||||
static async uploadFile(_: Electron.IpcMainInvokeEvent, file: FileType, apiKey: string): Promise<File> {
|
||||
const sdk = new GoogleGenAI({ vertexai: false, apiKey })
|
||||
const uploadResult = await sdk.files.upload({
|
||||
file: file.path,
|
||||
config: {
|
||||
mimeType: 'application/pdf',
|
||||
name: file.id,
|
||||
displayName: file.origin_name
|
||||
}
|
||||
})
|
||||
return uploadResult
|
||||
}
|
||||
@@ -24,40 +28,42 @@ export class GeminiService {
|
||||
}
|
||||
}
|
||||
|
||||
static async retrieveFile(
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
file: FileType,
|
||||
apiKey: string
|
||||
): Promise<FileMetadataResponse | undefined> {
|
||||
const fileManager = new GoogleAIFileManager(apiKey)
|
||||
|
||||
static async retrieveFile(_: Electron.IpcMainInvokeEvent, file: FileType, apiKey: string): Promise<File | undefined> {
|
||||
const sdk = new GoogleGenAI({ vertexai: false, apiKey })
|
||||
const cachedResponse = CacheService.get<any>(GeminiService.FILE_LIST_CACHE_KEY)
|
||||
if (cachedResponse) {
|
||||
return GeminiService.processResponse(cachedResponse, file)
|
||||
}
|
||||
|
||||
const response = await fileManager.listFiles()
|
||||
const response = await sdk.files.list()
|
||||
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)
|
||||
private static async processResponse(response: Pager<File>, file: FileType) {
|
||||
for await (const f of response) {
|
||||
if (f.state === FileState.ACTIVE) {
|
||||
if (f.displayName === file.origin_name && Number(f.sizeBytes) === file.size) {
|
||||
return f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
static async listFiles(_: Electron.IpcMainInvokeEvent, apiKey: string) {
|
||||
const fileManager = new GoogleAIFileManager(apiKey)
|
||||
return await fileManager.listFiles()
|
||||
static async listFiles(_: Electron.IpcMainInvokeEvent, apiKey: string): Promise<File[]> {
|
||||
const sdk = new GoogleGenAI({ vertexai: false, apiKey })
|
||||
const files: File[] = []
|
||||
for await (const f of await sdk.files.list()) {
|
||||
files.push(f)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
static async deleteFile(_: Electron.IpcMainInvokeEvent, apiKey: string, fileId: string) {
|
||||
const fileManager = new GoogleAIFileManager(apiKey)
|
||||
await fileManager.deleteFile(fileId)
|
||||
static async deleteFile(_: Electron.IpcMainInvokeEvent, fileId: string, apiKey: string) {
|
||||
const sdk = new GoogleGenAI({ vertexai: false, apiKey })
|
||||
await sdk.files.delete({ name: fileId })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import crypto from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
@@ -6,16 +8,65 @@ import { createInMemoryMCPServer } from '@main/mcpServers/factory'
|
||||
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 { SSEClientTransport, SSEClientTransportOptions } from '@modelcontextprotocol/sdk/client/sse.js'
|
||||
import { getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
||||
import {
|
||||
StreamableHTTPClientTransport,
|
||||
type StreamableHTTPClientTransportOptions
|
||||
} from '@modelcontextprotocol/sdk/client/streamableHttp'
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory'
|
||||
import { nanoid } from '@reduxjs/toolkit'
|
||||
import { MCPServer, MCPTool } from '@types'
|
||||
import {
|
||||
GetMCPPromptResponse,
|
||||
GetResourceResponse,
|
||||
MCPCallToolResponse,
|
||||
MCPPrompt,
|
||||
MCPResource,
|
||||
MCPServer,
|
||||
MCPTool
|
||||
} from '@types'
|
||||
import { app } from 'electron'
|
||||
import Logger from 'electron-log'
|
||||
import { EventEmitter } from 'events'
|
||||
import { memoize } from 'lodash'
|
||||
|
||||
import { CacheService } from './CacheService'
|
||||
import { StreamableHTTPClientTransport, type StreamableHTTPClientTransportOptions } from './MCPStreamableHttpClient'
|
||||
import { CallBackServer } from './mcp/oauth/callback'
|
||||
import { McpOAuthClientProvider } from './mcp/oauth/provider'
|
||||
|
||||
// Generic type for caching wrapped functions
|
||||
type CachedFunction<T extends unknown[], R> = (...args: T) => Promise<R>
|
||||
|
||||
/**
|
||||
* Higher-order function to add caching capability to any async function
|
||||
* @param fn The original function to be wrapped with caching
|
||||
* @param getCacheKey Function to generate a cache key from the function arguments
|
||||
* @param ttl Time to live for the cache entry in milliseconds
|
||||
* @param logPrefix Prefix for log messages
|
||||
* @returns The wrapped function with caching capability
|
||||
*/
|
||||
function withCache<T extends unknown[], R>(
|
||||
fn: (...args: T) => Promise<R>,
|
||||
getCacheKey: (...args: T) => string,
|
||||
ttl: number,
|
||||
logPrefix: string
|
||||
): CachedFunction<T, R> {
|
||||
return async (...args: T): Promise<R> => {
|
||||
const cacheKey = getCacheKey(...args)
|
||||
|
||||
if (CacheService.has(cacheKey)) {
|
||||
Logger.info(`${logPrefix} loaded from cache`)
|
||||
const cachedData = CacheService.get<R>(cacheKey)
|
||||
if (cachedData) {
|
||||
return cachedData
|
||||
}
|
||||
}
|
||||
|
||||
const result = await fn(...args)
|
||||
CacheService.set(cacheKey, result, ttl)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
class McpService {
|
||||
private clients: Map<string, Client> = new Map()
|
||||
@@ -35,10 +86,15 @@ class McpService {
|
||||
this.initClient = this.initClient.bind(this)
|
||||
this.listTools = this.listTools.bind(this)
|
||||
this.callTool = this.callTool.bind(this)
|
||||
this.listPrompts = this.listPrompts.bind(this)
|
||||
this.getPrompt = this.getPrompt.bind(this)
|
||||
this.listResources = this.listResources.bind(this)
|
||||
this.getResource = this.getResource.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)
|
||||
this.cleanup = this.cleanup.bind(this)
|
||||
}
|
||||
|
||||
async initClient(server: MCPServer): Promise<Client> {
|
||||
@@ -68,9 +124,17 @@ class McpService {
|
||||
|
||||
const args = [...(server.args || [])]
|
||||
|
||||
let transport: StdioClientTransport | SSEClientTransport | InMemoryTransport | StreamableHTTPClientTransport
|
||||
// let transport: StdioClientTransport | SSEClientTransport | InMemoryTransport | StreamableHTTPClientTransport
|
||||
const authProvider = new McpOAuthClientProvider({
|
||||
serverUrlHash: crypto
|
||||
.createHash('md5')
|
||||
.update(server.baseUrl || '')
|
||||
.digest('hex')
|
||||
})
|
||||
|
||||
try {
|
||||
const initTransport = async (): Promise<
|
||||
StdioClientTransport | SSEClientTransport | InMemoryTransport | StreamableHTTPClientTransport
|
||||
> => {
|
||||
// Create appropriate transport based on configuration
|
||||
if (server.type === 'inMemory') {
|
||||
Logger.info(`[MCP] Using in-memory transport for server: ${server.name}`)
|
||||
@@ -80,27 +144,39 @@ class McpService {
|
||||
try {
|
||||
await inMemoryServer.connect(serverTransport)
|
||||
Logger.info(`[MCP] In-memory server started: ${server.name}`)
|
||||
} catch (error) {
|
||||
} catch (error: Error | any) {
|
||||
Logger.error(`[MCP] Error starting in-memory server: ${error}`)
|
||||
throw new Error(`Failed to start in-memory server: ${error}`)
|
||||
throw new Error(`Failed to start in-memory server: ${error.message}`)
|
||||
}
|
||||
// set the client transport to the client
|
||||
transport = clientTransport
|
||||
return clientTransport
|
||||
} else if (server.baseUrl) {
|
||||
if (server.type === 'streamableHttp') {
|
||||
transport = new StreamableHTTPClientTransport(
|
||||
new URL(server.baseUrl!),
|
||||
{} as StreamableHTTPClientTransportOptions
|
||||
)
|
||||
const options: StreamableHTTPClientTransportOptions = {
|
||||
requestInit: {
|
||||
headers: server.headers || {}
|
||||
},
|
||||
authProvider
|
||||
}
|
||||
return new StreamableHTTPClientTransport(new URL(server.baseUrl!), options)
|
||||
} else if (server.type === 'sse') {
|
||||
transport = new SSEClientTransport(new URL(server.baseUrl!))
|
||||
const options: SSEClientTransportOptions = {
|
||||
eventSourceInit: {
|
||||
fetch: (url, init) => fetch(url, { ...init, headers: server.headers || {} })
|
||||
},
|
||||
requestInit: {
|
||||
headers: server.headers || {}
|
||||
},
|
||||
authProvider
|
||||
}
|
||||
return new SSEClientTransport(new URL(server.baseUrl!), options)
|
||||
} else {
|
||||
throw new Error('Invalid server type')
|
||||
}
|
||||
} else if (server.command) {
|
||||
let cmd = server.command
|
||||
|
||||
if (server.command === 'npx' || server.command === 'bun' || server.command === 'bunx') {
|
||||
if (server.command === 'npx') {
|
||||
cmd = await getBinaryPath('bun')
|
||||
Logger.info(`[MCP] Using command: ${cmd}`)
|
||||
|
||||
@@ -140,20 +216,82 @@ class McpService {
|
||||
Logger.info(`[MCP] Starting server with command: ${cmd} ${args ? args.join(' ') : ''}`)
|
||||
// Logger.info(`[MCP] Environment variables for server:`, server.env)
|
||||
|
||||
transport = new StdioClientTransport({
|
||||
const stdioTransport = new StdioClientTransport({
|
||||
command: cmd,
|
||||
args,
|
||||
env: {
|
||||
...getDefaultEnvironment(),
|
||||
PATH: this.getEnhancedPath(process.env.PATH || ''),
|
||||
PATH: await this.getEnhancedPath(process.env.PATH || ''),
|
||||
...server.env
|
||||
}
|
||||
},
|
||||
stderr: 'pipe'
|
||||
})
|
||||
stdioTransport.stderr?.on('data', (data) =>
|
||||
Logger.info(`[MCP] Stdio stderr for server: ${server.name} `, data.toString())
|
||||
)
|
||||
return stdioTransport
|
||||
} else {
|
||||
throw new Error('Either baseUrl or command must be provided')
|
||||
}
|
||||
}
|
||||
|
||||
await client.connect(transport)
|
||||
const handleAuth = async (client: Client, transport: SSEClientTransport | StreamableHTTPClientTransport) => {
|
||||
Logger.info(`[MCP] Starting OAuth flow for server: ${server.name}`)
|
||||
// Create an event emitter for the OAuth callback
|
||||
const events = new EventEmitter()
|
||||
|
||||
// Create a callback server
|
||||
const callbackServer = new CallBackServer({
|
||||
port: authProvider.config.callbackPort,
|
||||
path: authProvider.config.callbackPath || '/oauth/callback',
|
||||
events
|
||||
})
|
||||
|
||||
// Set a timeout to close the callback server
|
||||
const timeoutId = setTimeout(() => {
|
||||
Logger.warn(`[MCP] OAuth flow timed out for server: ${server.name}`)
|
||||
callbackServer.close()
|
||||
}, 300000) // 5 minutes timeout
|
||||
|
||||
try {
|
||||
// Wait for the authorization code
|
||||
const authCode = await callbackServer.waitForAuthCode()
|
||||
Logger.info(`[MCP] Received auth code: ${authCode}`)
|
||||
|
||||
// Complete the OAuth flow
|
||||
await transport.finishAuth(authCode)
|
||||
|
||||
Logger.info(`[MCP] OAuth flow completed for server: ${server.name}`)
|
||||
|
||||
const newTransport = await initTransport()
|
||||
// Try to connect again
|
||||
await client.connect(newTransport)
|
||||
|
||||
Logger.info(`[MCP] Successfully authenticated with server: ${server.name}`)
|
||||
} catch (oauthError) {
|
||||
Logger.error(`[MCP] OAuth authentication failed for server ${server.name}:`, oauthError)
|
||||
throw new Error(
|
||||
`OAuth authentication failed: ${oauthError instanceof Error ? oauthError.message : String(oauthError)}`
|
||||
)
|
||||
} finally {
|
||||
// Clear the timeout and close the callback server
|
||||
clearTimeout(timeoutId)
|
||||
callbackServer.close()
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const transport = await initTransport()
|
||||
try {
|
||||
await client.connect(transport)
|
||||
} catch (error: Error | any) {
|
||||
if (error instanceof Error && (error.name === 'UnauthorizedError' || error.message.includes('Unauthorized'))) {
|
||||
Logger.info(`[MCP] Authentication required for server: ${server.name}`)
|
||||
await handleAuth(client, transport as SSEClientTransport | StreamableHTTPClientTransport)
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Store the new client in the cache
|
||||
this.clients.set(serverKey, client)
|
||||
@@ -162,7 +300,7 @@ class McpService {
|
||||
return client
|
||||
} catch (error: any) {
|
||||
Logger.error(`[MCP] Error activating server ${server.name}:`, error)
|
||||
throw error
|
||||
throw new Error(`[MCP] Error activating server ${server.name}: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,31 +339,50 @@ class McpService {
|
||||
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
|
||||
async cleanup() {
|
||||
for (const [key] of this.clients) {
|
||||
try {
|
||||
await this.closeClient(key)
|
||||
} catch (error) {
|
||||
Logger.error(`[MCP] Failed to close client: ${error}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async listToolsImpl(server: MCPServer): Promise<MCPTool[]> {
|
||||
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
|
||||
const client = await this.initClient(server)
|
||||
try {
|
||||
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)
|
||||
})
|
||||
return serverTools
|
||||
} catch (error) {
|
||||
Logger.error(`[MCP] Failed to list tools for server: ${server.name}`, error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async listTools(_: Electron.IpcMainInvokeEvent, server: MCPServer) {
|
||||
const cachedListTools = withCache<[MCPServer], MCPTool[]>(
|
||||
this.listToolsImpl.bind(this),
|
||||
(server) => {
|
||||
const serverKey = this.getServerKey(server)
|
||||
return `mcp:list_tool:${serverKey}`
|
||||
},
|
||||
5 * 60 * 1000, // 5 minutes TTL
|
||||
`[MCP] Tools from ${server.name}`
|
||||
)
|
||||
|
||||
return cachedListTools(server)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -234,12 +391,12 @@ class McpService {
|
||||
public async callTool(
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
{ server, name, args }: { server: MCPServer; name: string; args: any }
|
||||
): Promise<any> {
|
||||
): Promise<MCPCallToolResponse> {
|
||||
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
|
||||
return result as MCPCallToolResponse
|
||||
} catch (error) {
|
||||
Logger.error(`[MCP] Error calling tool ${name} on ${server.name}:`, error)
|
||||
throw error
|
||||
@@ -255,13 +412,243 @@ class McpService {
|
||||
return { dir, uvPath, bunPath }
|
||||
}
|
||||
|
||||
/**
|
||||
* List prompts available on an MCP server
|
||||
*/
|
||||
private async listPromptsImpl(server: MCPServer): Promise<MCPPrompt[]> {
|
||||
Logger.info(`[MCP] Listing prompts for server: ${server.name}`)
|
||||
const client = await this.initClient(server)
|
||||
try {
|
||||
const { prompts } = await client.listPrompts()
|
||||
const serverPrompts = prompts.map((prompt: any) => ({
|
||||
...prompt,
|
||||
id: `p${nanoid()}`,
|
||||
serverId: server.id,
|
||||
serverName: server.name
|
||||
}))
|
||||
return serverPrompts
|
||||
} catch (error) {
|
||||
Logger.error(`[MCP] Failed to list prompts for server: ${server.name}`, error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List prompts available on an MCP server with caching
|
||||
*/
|
||||
public async listPrompts(_: Electron.IpcMainInvokeEvent, server: MCPServer): Promise<MCPPrompt[]> {
|
||||
const cachedListPrompts = withCache<[MCPServer], MCPPrompt[]>(
|
||||
this.listPromptsImpl.bind(this),
|
||||
(server) => {
|
||||
const serverKey = this.getServerKey(server)
|
||||
return `mcp:list_prompts:${serverKey}`
|
||||
},
|
||||
60 * 60 * 1000, // 60 minutes TTL
|
||||
`[MCP] Prompts from ${server.name}`
|
||||
)
|
||||
return cachedListPrompts(server)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific prompt from an MCP server (implementation)
|
||||
*/
|
||||
private async getPromptImpl(
|
||||
server: MCPServer,
|
||||
name: string,
|
||||
args?: Record<string, any>
|
||||
): Promise<GetMCPPromptResponse> {
|
||||
Logger.info(`[MCP] Getting prompt ${name} from server: ${server.name}`)
|
||||
const client = await this.initClient(server)
|
||||
return await client.getPrompt({ name, arguments: args })
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific prompt from an MCP server with caching
|
||||
*/
|
||||
public async getPrompt(
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
{ server, name, args }: { server: MCPServer; name: string; args?: Record<string, any> }
|
||||
): Promise<GetMCPPromptResponse> {
|
||||
const cachedGetPrompt = withCache<[MCPServer, string, Record<string, any> | undefined], GetMCPPromptResponse>(
|
||||
this.getPromptImpl.bind(this),
|
||||
(server, name, args) => {
|
||||
const serverKey = this.getServerKey(server)
|
||||
const argsKey = args ? JSON.stringify(args) : 'no-args'
|
||||
return `mcp:get_prompt:${serverKey}:${name}:${argsKey}`
|
||||
},
|
||||
30 * 60 * 1000, // 30 minutes TTL
|
||||
`[MCP] Prompt ${name} from ${server.name}`
|
||||
)
|
||||
return await cachedGetPrompt(server, name, args)
|
||||
}
|
||||
|
||||
/**
|
||||
* List resources available on an MCP server (implementation)
|
||||
*/
|
||||
private async listResourcesImpl(server: MCPServer): Promise<MCPResource[]> {
|
||||
Logger.info(`[MCP] Listing resources for server: ${server.name}`)
|
||||
const client = await this.initClient(server)
|
||||
try {
|
||||
const result = await client.listResources()
|
||||
const resources = result.resources || []
|
||||
const serverResources = (Array.isArray(resources) ? resources : []).map((resource: any) => ({
|
||||
...resource,
|
||||
serverId: server.id,
|
||||
serverName: server.name
|
||||
}))
|
||||
return serverResources
|
||||
} catch (error) {
|
||||
Logger.error(`[MCP] Failed to list resources for server: ${server.name}`, error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List resources available on an MCP server with caching
|
||||
*/
|
||||
public async listResources(_: Electron.IpcMainInvokeEvent, server: MCPServer): Promise<MCPResource[]> {
|
||||
const cachedListResources = withCache<[MCPServer], MCPResource[]>(
|
||||
this.listResourcesImpl.bind(this),
|
||||
(server) => {
|
||||
const serverKey = this.getServerKey(server)
|
||||
return `mcp:list_resources:${serverKey}`
|
||||
},
|
||||
60 * 60 * 1000, // 60 minutes TTL
|
||||
`[MCP] Resources from ${server.name}`
|
||||
)
|
||||
return cachedListResources(server)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific resource from an MCP server (implementation)
|
||||
*/
|
||||
private async getResourceImpl(server: MCPServer, uri: string): Promise<GetResourceResponse> {
|
||||
Logger.info(`[MCP] Getting resource ${uri} from server: ${server.name}`)
|
||||
const client = await this.initClient(server)
|
||||
try {
|
||||
const result = await client.readResource({ uri: uri })
|
||||
const contents: MCPResource[] = []
|
||||
if (result.contents && result.contents.length > 0) {
|
||||
result.contents.forEach((content: any) => {
|
||||
contents.push({
|
||||
...content,
|
||||
serverId: server.id,
|
||||
serverName: server.name
|
||||
})
|
||||
})
|
||||
}
|
||||
return {
|
||||
contents: contents
|
||||
}
|
||||
} catch (error: Error | any) {
|
||||
Logger.error(`[MCP] Failed to get resource ${uri} from server: ${server.name}`, error)
|
||||
throw new Error(`Failed to get resource ${uri} from server: ${server.name}: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific resource from an MCP server with caching
|
||||
*/
|
||||
public async getResource(
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
{ server, uri }: { server: MCPServer; uri: string }
|
||||
): Promise<GetResourceResponse> {
|
||||
const cachedGetResource = withCache<[MCPServer, string], GetResourceResponse>(
|
||||
this.getResourceImpl.bind(this),
|
||||
(server, uri) => {
|
||||
const serverKey = this.getServerKey(server)
|
||||
return `mcp:get_resource:${serverKey}:${uri}`
|
||||
},
|
||||
30 * 60 * 1000, // 30 minutes TTL
|
||||
`[MCP] Resource ${uri} from ${server.name}`
|
||||
)
|
||||
return await cachedGetResource(server, uri)
|
||||
}
|
||||
|
||||
private getSystemPath = memoize(async (): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
let command: string
|
||||
let shell: string
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
shell = 'powershell.exe'
|
||||
command = '$env:PATH'
|
||||
} else {
|
||||
// 尝试获取当前用户的默认 shell
|
||||
|
||||
let userShell = process.env.SHELL
|
||||
if (!userShell) {
|
||||
if (fs.existsSync('/bin/zsh')) {
|
||||
userShell = '/bin/zsh'
|
||||
} else if (fs.existsSync('/bin/bash')) {
|
||||
userShell = '/bin/bash'
|
||||
} else if (fs.existsSync('/bin/fish')) {
|
||||
userShell = '/bin/fish'
|
||||
} else {
|
||||
userShell = '/bin/sh'
|
||||
}
|
||||
}
|
||||
shell = userShell
|
||||
|
||||
// 根据不同的 shell 构建不同的命令
|
||||
if (userShell.includes('zsh')) {
|
||||
command =
|
||||
'source /etc/zshenv 2>/dev/null || true; source ~/.zshenv 2>/dev/null || true; source /etc/zprofile 2>/dev/null || true; source ~/.zprofile 2>/dev/null || true; source /etc/zshrc 2>/dev/null || true; source ~/.zshrc 2>/dev/null || true; source /etc/zlogin 2>/dev/null || true; source ~/.zlogin 2>/dev/null || true; echo $PATH'
|
||||
} else if (userShell.includes('bash')) {
|
||||
command =
|
||||
'source /etc/profile 2>/dev/null || true; source ~/.bash_profile 2>/dev/null || true; source ~/.bash_login 2>/dev/null || true; source ~/.profile 2>/dev/null || true; source ~/.bashrc 2>/dev/null || true; echo $PATH'
|
||||
} else if (userShell.includes('fish')) {
|
||||
command =
|
||||
'source /etc/fish/config.fish 2>/dev/null || true; source ~/.config/fish/config.fish 2>/dev/null || true; source ~/.config/fish/config.local.fish 2>/dev/null || true; echo $PATH'
|
||||
} else {
|
||||
// 默认使用 zsh
|
||||
shell = '/bin/zsh'
|
||||
command =
|
||||
'source /etc/zshenv 2>/dev/null || true; source ~/.zshenv 2>/dev/null || true; source /etc/zprofile 2>/dev/null || true; source ~/.zprofile 2>/dev/null || true; source /etc/zshrc 2>/dev/null || true; source ~/.zshrc 2>/dev/null || true; source /etc/zlogin 2>/dev/null || true; source ~/.zlogin 2>/dev/null || true; echo $PATH'
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Using shell: ${shell} with command: ${command}`)
|
||||
const child = require('child_process').spawn(shell, ['-c', command], {
|
||||
env: { ...process.env },
|
||||
cwd: app.getPath('home')
|
||||
})
|
||||
|
||||
let path = ''
|
||||
child.stdout.on('data', (data: Buffer) => {
|
||||
path += data.toString()
|
||||
})
|
||||
|
||||
child.stderr.on('data', (data: Buffer) => {
|
||||
console.error('Error getting PATH:', data.toString())
|
||||
})
|
||||
|
||||
child.on('close', (code: number) => {
|
||||
if (code === 0) {
|
||||
const trimmedPath = path.trim()
|
||||
resolve(trimmedPath)
|
||||
} else {
|
||||
reject(new Error(`Failed to get system PATH, exit code: ${code}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Get enhanced PATH including common tool locations
|
||||
*/
|
||||
private getEnhancedPath(originalPath: string): string {
|
||||
private async getEnhancedPath(originalPath: string): Promise<string> {
|
||||
let systemPath = ''
|
||||
try {
|
||||
systemPath = await this.getSystemPath()
|
||||
} catch (error) {
|
||||
Logger.error('[MCP] Failed to get system PATH:', error)
|
||||
}
|
||||
// 将原始 PATH 按分隔符分割成数组
|
||||
const pathSeparator = process.platform === 'win32' ? ';' : ':'
|
||||
const existingPaths = new Set(originalPath.split(pathSeparator).filter(Boolean))
|
||||
const existingPaths = new Set(
|
||||
[...systemPath.split(pathSeparator), ...originalPath.split(pathSeparator)].filter(Boolean)
|
||||
)
|
||||
const homeDir = process.env.HOME || process.env.USERPROFILE || ''
|
||||
|
||||
// 定义要添加的新路径
|
||||
@@ -320,4 +707,5 @@ class McpService {
|
||||
}
|
||||
}
|
||||
|
||||
export default new McpService()
|
||||
const mcpService = new McpService()
|
||||
export default mcpService
|
||||
|
||||
@@ -1,365 +0,0 @@
|
||||
import { auth, AuthResult, OAuthClientProvider, UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
|
||||
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
|
||||
import { JSONRPCMessage, JSONRPCMessageSchema } from '@modelcontextprotocol/sdk/types.js'
|
||||
|
||||
export class StreamableHTTPError extends Error {
|
||||
constructor(
|
||||
public readonly code: number | undefined,
|
||||
message: string | undefined,
|
||||
public readonly event: ErrorEvent
|
||||
) {
|
||||
super(`Streamable HTTP error: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration options for the `StreamableHTTPClientTransport`.
|
||||
*/
|
||||
export type StreamableHTTPClientTransportOptions = {
|
||||
/**
|
||||
* An OAuth client provider to use for authentication.
|
||||
*
|
||||
* When an `authProvider` is specified and the connection is started:
|
||||
* 1. The connection is attempted with any existing access token from the `authProvider`.
|
||||
* 2. If the access token has expired, the `authProvider` is used to refresh the token.
|
||||
* 3. If token refresh fails or no access token exists, and auth is required, `OAuthClientProvider.redirectToAuthorization` is called, and an `UnauthorizedError` will be thrown from `connect`/`start`.
|
||||
*
|
||||
* After the user has finished authorizing via their user agent, and is redirected back to the MCP client application, call `StreamableHTTPClientTransport.finishAuth` with the authorization code before retrying the connection.
|
||||
*
|
||||
* If an `authProvider` is not provided, and auth is required, an `UnauthorizedError` will be thrown.
|
||||
*
|
||||
* `UnauthorizedError` might also be thrown when sending any message over the transport, indicating that the session has expired, and needs to be re-authed and reconnected.
|
||||
*/
|
||||
authProvider?: OAuthClientProvider
|
||||
|
||||
/**
|
||||
* Customizes HTTP requests to the server.
|
||||
*/
|
||||
requestInit?: RequestInit
|
||||
}
|
||||
|
||||
/**
|
||||
* Client transport for Streamable HTTP: this implements the MCP Streamable HTTP transport specification.
|
||||
* It will connect to a server using HTTP POST for sending messages and HTTP GET with Server-Sent Events
|
||||
* for receiving messages.
|
||||
*/
|
||||
export class StreamableHTTPClientTransport implements Transport {
|
||||
private _activeStreams: Map<string, ReadableStreamDefaultReader<Uint8Array>> = new Map()
|
||||
private _abortController?: AbortController
|
||||
private _url: URL
|
||||
private _requestInit?: RequestInit
|
||||
private _authProvider?: OAuthClientProvider
|
||||
private _sessionId?: string
|
||||
private _lastEventId?: string
|
||||
|
||||
onclose?: () => void
|
||||
onerror?: (error: Error) => void
|
||||
onmessage?: (message: JSONRPCMessage) => void
|
||||
|
||||
constructor(url: URL, opts?: StreamableHTTPClientTransportOptions) {
|
||||
this._url = url
|
||||
this._requestInit = opts?.requestInit
|
||||
this._authProvider = opts?.authProvider
|
||||
}
|
||||
|
||||
private async _authThenStart(): Promise<void> {
|
||||
if (!this._authProvider) {
|
||||
throw new UnauthorizedError('No auth provider')
|
||||
}
|
||||
|
||||
let result: AuthResult
|
||||
try {
|
||||
result = await auth(this._authProvider, { serverUrl: this._url })
|
||||
} catch (error) {
|
||||
this.onerror?.(error as Error)
|
||||
throw error
|
||||
}
|
||||
|
||||
if (result !== 'AUTHORIZED') {
|
||||
throw new UnauthorizedError()
|
||||
}
|
||||
|
||||
return await this._startOrAuth()
|
||||
}
|
||||
|
||||
private async _commonHeaders(): Promise<HeadersInit> {
|
||||
const headers: HeadersInit = {}
|
||||
if (this._authProvider) {
|
||||
const tokens = await this._authProvider.tokens()
|
||||
if (tokens) {
|
||||
headers['Authorization'] = `Bearer ${tokens.access_token}`
|
||||
}
|
||||
}
|
||||
|
||||
if (this._sessionId) {
|
||||
headers['mcp-session-id'] = this._sessionId
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
private async _startOrAuth(): Promise<void> {
|
||||
try {
|
||||
// Try to open an initial SSE stream with GET to listen for server messages
|
||||
// This is optional according to the spec - server may not support it
|
||||
const commonHeaders = await this._commonHeaders()
|
||||
const headers = new Headers(commonHeaders)
|
||||
headers.set('Accept', 'text/event-stream')
|
||||
|
||||
// Include Last-Event-ID header for resumable streams
|
||||
if (this._lastEventId) {
|
||||
headers.set('last-event-id', this._lastEventId)
|
||||
}
|
||||
|
||||
const response = await fetch(this._url, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
signal: this._abortController?.signal
|
||||
})
|
||||
|
||||
if (response.status === 405) {
|
||||
// Server doesn't support GET for SSE, which is allowed by the spec
|
||||
// We'll rely on SSE responses to POST requests for communication
|
||||
return
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 && this._authProvider) {
|
||||
// Need to authenticate
|
||||
return await this._authThenStart()
|
||||
}
|
||||
|
||||
const error = new Error(`Failed to open SSE stream: ${response.status} ${response.statusText}`)
|
||||
this.onerror?.(error)
|
||||
throw error
|
||||
}
|
||||
|
||||
// Successful connection, handle the SSE stream as a standalone listener
|
||||
const streamId = `initial-${Date.now()}`
|
||||
this._handleSseStream(response.body, streamId)
|
||||
} catch (error) {
|
||||
this.onerror?.(error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this._activeStreams.size > 0) {
|
||||
throw new Error(
|
||||
'StreamableHTTPClientTransport already started! If using Client class, note that connect() calls start() automatically.'
|
||||
)
|
||||
}
|
||||
|
||||
this._abortController = new AbortController()
|
||||
return await this._startOrAuth()
|
||||
}
|
||||
|
||||
/**
|
||||
* Call this method after the user has finished authorizing via their user agent and is redirected back to the MCP client application. This will exchange the authorization code for an access token, enabling the next connection attempt to successfully auth.
|
||||
*/
|
||||
async finishAuth(authorizationCode: string): Promise<void> {
|
||||
if (!this._authProvider) {
|
||||
throw new UnauthorizedError('No auth provider')
|
||||
}
|
||||
|
||||
const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode })
|
||||
if (result !== 'AUTHORIZED') {
|
||||
throw new UnauthorizedError('Failed to authorize')
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
// Close all active streams
|
||||
for (const reader of this._activeStreams.values()) {
|
||||
try {
|
||||
reader.cancel()
|
||||
} catch (error) {
|
||||
this.onerror?.(error as Error)
|
||||
}
|
||||
}
|
||||
this._activeStreams.clear()
|
||||
|
||||
// Abort any pending requests
|
||||
this._abortController?.abort()
|
||||
|
||||
// If we have a session ID, send a DELETE request to explicitly terminate the session
|
||||
if (this._sessionId) {
|
||||
try {
|
||||
const commonHeaders = await this._commonHeaders()
|
||||
const response = await fetch(this._url, {
|
||||
method: 'DELETE',
|
||||
headers: commonHeaders,
|
||||
signal: this._abortController?.signal
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
// Server might respond with 405 if it doesn't support explicit session termination
|
||||
// We don't throw an error in that case
|
||||
if (response.status !== 405) {
|
||||
const text = await response.text().catch(() => null)
|
||||
throw new Error(`Error terminating session (HTTP ${response.status}): ${text}`)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// We still want to invoke onclose even if the session termination fails
|
||||
this.onerror?.(error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
this.onclose?.()
|
||||
}
|
||||
|
||||
async send(message: JSONRPCMessage | JSONRPCMessage[]): Promise<void> {
|
||||
try {
|
||||
const commonHeaders = await this._commonHeaders()
|
||||
const headers = new Headers({ ...commonHeaders, ...this._requestInit?.headers })
|
||||
headers.set('content-type', 'application/json')
|
||||
headers.set('accept', 'application/json, text/event-stream')
|
||||
|
||||
const init = {
|
||||
...this._requestInit,
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(message),
|
||||
signal: this._abortController?.signal
|
||||
}
|
||||
|
||||
const response = await fetch(this._url, init)
|
||||
|
||||
// Handle session ID received during initialization
|
||||
const sessionId = response.headers.get('mcp-session-id')
|
||||
if (sessionId) {
|
||||
this._sessionId = sessionId
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 && this._authProvider) {
|
||||
const result = await auth(this._authProvider, { serverUrl: this._url })
|
||||
if (result !== 'AUTHORIZED') {
|
||||
throw new UnauthorizedError()
|
||||
}
|
||||
|
||||
// Purposely _not_ awaited, so we don't call onerror twice
|
||||
return this.send(message)
|
||||
}
|
||||
|
||||
const text = await response.text().catch(() => null)
|
||||
throw new Error(`Error POSTing to endpoint (HTTP ${response.status}): ${text}`)
|
||||
}
|
||||
|
||||
// If the response is 202 Accepted, there's no body to process
|
||||
if (response.status === 202) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get original message(s) for detecting request IDs
|
||||
const messages = Array.isArray(message) ? message : [message]
|
||||
|
||||
// Extract IDs from request messages for tracking responses
|
||||
const requestIds = messages
|
||||
.filter((msg) => 'method' in msg && 'id' in msg)
|
||||
.map((msg) => ('id' in msg ? msg.id : undefined))
|
||||
.filter((id) => id !== undefined)
|
||||
|
||||
// If we have request IDs and an SSE response, create a unique stream ID
|
||||
const hasRequests = requestIds.length > 0
|
||||
|
||||
// Check the response type
|
||||
const contentType = response.headers.get('content-type')
|
||||
|
||||
if (hasRequests) {
|
||||
if (contentType?.includes('text/event-stream')) {
|
||||
// For streaming responses, create a unique stream ID based on request IDs
|
||||
const streamId = `req-${requestIds.join('-')}-${Date.now()}`
|
||||
this._handleSseStream(response.body, streamId)
|
||||
} else if (contentType?.includes('application/json')) {
|
||||
// For non-streaming servers, we might get direct JSON responses
|
||||
const data = await response.json()
|
||||
const responseMessages = Array.isArray(data)
|
||||
? data.map((msg) => JSONRPCMessageSchema.parse(msg))
|
||||
: [JSONRPCMessageSchema.parse(data)]
|
||||
|
||||
for (const msg of responseMessages) {
|
||||
this.onmessage?.(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.onerror?.(error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private _handleSseStream(stream: ReadableStream<Uint8Array> | null, streamId: string): void {
|
||||
if (!stream) {
|
||||
return
|
||||
}
|
||||
|
||||
// Set up stream handling for server-sent events
|
||||
const reader = stream.getReader()
|
||||
this._activeStreams.set(streamId, reader)
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
const processStream = async () => {
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) {
|
||||
// Stream closed by server
|
||||
this._activeStreams.delete(streamId)
|
||||
break
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
|
||||
// Process SSE messages in the buffer
|
||||
const events = buffer.split('\n\n')
|
||||
buffer = events.pop() || ''
|
||||
|
||||
for (const event of events) {
|
||||
const lines = event.split('\n')
|
||||
let id: string | undefined
|
||||
let eventType: string | undefined
|
||||
let data: string | undefined
|
||||
|
||||
// Parse SSE message according to the format
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('id:')) {
|
||||
id = line.slice(3).trim()
|
||||
} else if (line.startsWith('event:')) {
|
||||
eventType = line.slice(6).trim()
|
||||
} else if (line.startsWith('data:')) {
|
||||
data = line.slice(5).trim()
|
||||
}
|
||||
}
|
||||
|
||||
// Update last event ID if provided by server
|
||||
// As per spec: the ID MUST be globally unique across all streams within that session
|
||||
if (id) {
|
||||
this._lastEventId = id
|
||||
}
|
||||
|
||||
// Handle message event
|
||||
if (data) {
|
||||
// Default event type is 'message' per SSE spec if not specified
|
||||
if (!eventType || eventType === 'message') {
|
||||
try {
|
||||
const message = JSONRPCMessageSchema.parse(JSON.parse(data))
|
||||
this.onmessage?.(message)
|
||||
} catch (error) {
|
||||
this.onerror?.(error as Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this._activeStreams.delete(streamId)
|
||||
this.onerror?.(error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
processStream()
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import { XMLParser } from 'fast-xml-parser'
|
||||
import { isNil, partial } from 'lodash'
|
||||
import { type FileStat } from 'webdav'
|
||||
|
||||
import { createOAuthUrl, decryptSecret } from '../integration/nutstore/sso/lib/index.mjs'
|
||||
|
||||
interface OAuthResponse {
|
||||
username: string
|
||||
userid: string
|
||||
@@ -30,18 +32,18 @@ interface WebDAVResponse {
|
||||
}
|
||||
|
||||
export async function getNutstoreSSOUrl() {
|
||||
const { createOAuthUrl } = await import('../integration/nutstore/sso/lib')
|
||||
|
||||
const url = createOAuthUrl({
|
||||
const url = await 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)
|
||||
const decrypted = await decryptSecret({
|
||||
app: 'cherrystudio',
|
||||
s: token
|
||||
})
|
||||
return JSON.parse(decrypted) as OAuthResponse
|
||||
} catch (error) {
|
||||
console.error('解密失败:', error)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ProxyConfig as _ProxyConfig, session } from 'electron'
|
||||
import { socksDispatcher } from 'fetch-socks'
|
||||
import { getSystemProxy } from 'os-proxy-config'
|
||||
import { ProxyAgent as GeneralProxyAgent } from 'proxy-agent'
|
||||
import { ProxyAgent, setGlobalDispatcher } from 'undici'
|
||||
|
||||
@@ -70,15 +71,14 @@ export class ProxyManager {
|
||||
|
||||
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()
|
||||
const currentProxy = await getSystemProxy()
|
||||
if (!currentProxy || currentProxy.proxyUrl === this.config.url) {
|
||||
return
|
||||
}
|
||||
await this.setSessionsProxy({ mode: 'system' })
|
||||
this.config.url = currentProxy.proxyUrl.toLowerCase()
|
||||
this.setEnvironment(this.config.url)
|
||||
this.proxyAgent = new GeneralProxyAgent()
|
||||
} catch (error) {
|
||||
console.error('Failed to set system proxy:', error)
|
||||
throw error
|
||||
|
||||
82
src/main/services/SearchService.ts
Normal file
82
src/main/services/SearchService.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { is } from '@electron-toolkit/utils'
|
||||
import { BrowserWindow } from 'electron'
|
||||
|
||||
export class SearchService {
|
||||
private static instance: SearchService | null = null
|
||||
private searchWindows: Record<string, BrowserWindow> = {}
|
||||
public static getInstance(): SearchService {
|
||||
if (!SearchService.instance) {
|
||||
SearchService.instance = new SearchService()
|
||||
}
|
||||
return SearchService.instance
|
||||
}
|
||||
|
||||
constructor() {
|
||||
// Initialize the service
|
||||
}
|
||||
|
||||
private async createNewSearchWindow(uid: string): Promise<BrowserWindow> {
|
||||
const newWindow = new BrowserWindow({
|
||||
width: 800,
|
||||
height: 600,
|
||||
show: false,
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
contextIsolation: false,
|
||||
devTools: is.dev
|
||||
}
|
||||
})
|
||||
newWindow.webContents.session.webRequest.onBeforeSendHeaders({ urls: ['*://*/*'] }, (details, callback) => {
|
||||
const headers = {
|
||||
...details.requestHeaders,
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
||||
}
|
||||
callback({ requestHeaders: headers })
|
||||
})
|
||||
this.searchWindows[uid] = newWindow
|
||||
newWindow.on('closed', () => {
|
||||
delete this.searchWindows[uid]
|
||||
})
|
||||
return newWindow
|
||||
}
|
||||
|
||||
public async openSearchWindow(uid: string): Promise<void> {
|
||||
await this.createNewSearchWindow(uid)
|
||||
}
|
||||
|
||||
public async closeSearchWindow(uid: string): Promise<void> {
|
||||
const window = this.searchWindows[uid]
|
||||
if (window) {
|
||||
window.close()
|
||||
delete this.searchWindows[uid]
|
||||
}
|
||||
}
|
||||
|
||||
public async openUrlInSearchWindow(uid: string, url: string): Promise<any> {
|
||||
let window = this.searchWindows[uid]
|
||||
if (window) {
|
||||
await window.loadURL(url)
|
||||
} else {
|
||||
window = await this.createNewSearchWindow(uid)
|
||||
await window.loadURL(url)
|
||||
}
|
||||
|
||||
// Get the page content after loading the URL
|
||||
// Wait for the page to fully load before getting the content
|
||||
await new Promise<void>((resolve) => {
|
||||
const loadTimeout = setTimeout(() => resolve(), 10000) // 10 second timeout
|
||||
window.webContents.once('did-finish-load', () => {
|
||||
clearTimeout(loadTimeout)
|
||||
// Small delay to ensure JavaScript has executed
|
||||
setTimeout(resolve, 500)
|
||||
})
|
||||
})
|
||||
|
||||
// Get the page content after ensuring it's fully loaded
|
||||
const content = await window.webContents.executeJavaScript('document.documentElement.outerHTML')
|
||||
return content
|
||||
}
|
||||
}
|
||||
|
||||
export const searchService = SearchService.getInstance()
|
||||
@@ -26,6 +26,7 @@ export default class WebDav {
|
||||
this.putFileContents = this.putFileContents.bind(this)
|
||||
this.getFileContents = this.getFileContents.bind(this)
|
||||
this.createDirectory = this.createDirectory.bind(this)
|
||||
this.deleteFile = this.deleteFile.bind(this)
|
||||
}
|
||||
|
||||
public putFileContents = async (
|
||||
@@ -98,4 +99,19 @@ export default class WebDav {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public deleteFile = async (filename: string) => {
|
||||
if (!this.instance) {
|
||||
throw new Error('WebDAV client not initialized')
|
||||
}
|
||||
|
||||
const remoteFilePath = `${this.webdavPath}/${filename}`
|
||||
|
||||
try {
|
||||
return await this.instance.deleteFile(remoteFilePath)
|
||||
} catch (error) {
|
||||
Logger.error('[WebDAV] Error deleting file on WebDAV:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
35
src/main/services/WebviewService.ts
Normal file
35
src/main/services/WebviewService.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { session, shell, webContents } from 'electron'
|
||||
|
||||
/**
|
||||
* init the useragent of the webview session
|
||||
* remove the CherryStudio and Electron from the useragent
|
||||
*/
|
||||
export function initSessionUserAgent() {
|
||||
const wvSession = session.fromPartition('persist:webview')
|
||||
const newChromeVersion = '135.0.7049.96'
|
||||
const originUA = wvSession.getUserAgent()
|
||||
const newUA = originUA
|
||||
.replace(/CherryStudio\/\S+\s/, '')
|
||||
.replace(/Electron\/\S+\s/, '')
|
||||
.replace(/Chrome\/\d+\.\d+\.\d+\.\d+/, `Chrome/${newChromeVersion}`)
|
||||
|
||||
wvSession.setUserAgent(newUA)
|
||||
}
|
||||
|
||||
/**
|
||||
* WebviewService handles the behavior of links opened from webview elements
|
||||
* It controls whether links should be opened within the application or in an external browser
|
||||
*/
|
||||
export function setOpenLinkExternal(webviewId: number, isExternal: boolean) {
|
||||
const webview = webContents.fromId(webviewId)
|
||||
if (!webview) return
|
||||
|
||||
webview.setWindowOpenHandler(({ url }) => {
|
||||
if (isExternal) {
|
||||
shell.openExternal(url)
|
||||
return { action: 'deny' }
|
||||
} else {
|
||||
return { action: 'allow' }
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -11,13 +11,13 @@ import icon from '../../../build/icon.png?asset'
|
||||
import { titleBarOverlayDark, titleBarOverlayLight } from '../config'
|
||||
import { locales } from '../utils/locales'
|
||||
import { configManager } from './ConfigManager'
|
||||
import { initSessionUserAgent } from './WebviewService'
|
||||
|
||||
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
|
||||
@@ -41,7 +41,9 @@ export class WindowService {
|
||||
|
||||
const mainWindowState = windowStateKeeper({
|
||||
defaultWidth: 1080,
|
||||
defaultHeight: 670
|
||||
defaultHeight: 670,
|
||||
fullScreen: false,
|
||||
maximize: false
|
||||
})
|
||||
|
||||
const theme = configManager.getTheme()
|
||||
@@ -53,7 +55,7 @@ export class WindowService {
|
||||
height: mainWindowState.height,
|
||||
minWidth: 1080,
|
||||
minHeight: 600,
|
||||
show: false, // 初始不显示
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
transparent: isMac,
|
||||
vibrancy: 'sidebar',
|
||||
@@ -80,12 +82,16 @@ export class WindowService {
|
||||
this.miniWindow = this.createMiniWindow(true)
|
||||
}
|
||||
|
||||
//init the MinApp webviews' useragent
|
||||
initSessionUserAgent()
|
||||
|
||||
return this.mainWindow
|
||||
}
|
||||
|
||||
private setupMainWindow(mainWindow: BrowserWindow, mainWindowState: any) {
|
||||
mainWindowState.manage(mainWindow)
|
||||
|
||||
this.setupMaximize(mainWindow, mainWindowState.isMaximized)
|
||||
this.setupContextMenu(mainWindow)
|
||||
this.setupWindowEvents(mainWindow)
|
||||
this.setupWebContentsHandlers(mainWindow)
|
||||
@@ -93,6 +99,17 @@ export class WindowService {
|
||||
this.loadMainWindowContent(mainWindow)
|
||||
}
|
||||
|
||||
private setupMaximize(mainWindow: BrowserWindow, isMaximized: boolean) {
|
||||
if (isMaximized) {
|
||||
// 如果是从托盘启动,则需要延迟最大化,否则显示的就不是重启前的最大化窗口了
|
||||
configManager.getLaunchToTray()
|
||||
? mainWindow.once('show', () => {
|
||||
mainWindow.maximize()
|
||||
})
|
||||
: mainWindow.maximize()
|
||||
}
|
||||
}
|
||||
|
||||
private setupContextMenu(mainWindow: BrowserWindow) {
|
||||
if (!this.contextMenu) {
|
||||
const locale = locales[configManager.getLanguage()]
|
||||
@@ -138,12 +155,10 @@ export class WindowService {
|
||||
|
||||
// 处理全屏相关事件
|
||||
mainWindow.on('enter-full-screen', () => {
|
||||
this.wasFullScreen = true
|
||||
mainWindow.webContents.send(IpcChannel.FullscreenStatusChanged, true)
|
||||
})
|
||||
|
||||
mainWindow.on('leave-full-screen', () => {
|
||||
this.wasFullScreen = false
|
||||
mainWindow.webContents.send(IpcChannel.FullscreenStatusChanged, false)
|
||||
})
|
||||
|
||||
@@ -193,9 +208,11 @@ export class WindowService {
|
||||
|
||||
const oauthProviderUrls = [
|
||||
'https://account.siliconflow.cn/oauth',
|
||||
'https://cloud.siliconflow.cn/bills',
|
||||
'https://cloud.siliconflow.cn/expensebill',
|
||||
'https://aihubmix.com/token',
|
||||
'https://aihubmix.com/topup'
|
||||
'https://aihubmix.com/topup',
|
||||
'https://aihubmix.com/statistics'
|
||||
]
|
||||
|
||||
if (oauthProviderUrls.some((link) => url.startsWith(link))) {
|
||||
@@ -245,6 +262,7 @@ export class WindowService {
|
||||
private loadMainWindowContent(mainWindow: BrowserWindow) {
|
||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
||||
// mainWindow.webContents.openDevTools()
|
||||
} else {
|
||||
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
||||
}
|
||||
@@ -274,19 +292,14 @@ export class WindowService {
|
||||
}
|
||||
}
|
||||
|
||||
//上述逻辑以下,是“开启托盘+设置关闭时最小化到托盘”的情况
|
||||
// 如果是Windows或Linux,且处于全屏状态,则退出应用
|
||||
if (this.wasFullScreen) {
|
||||
if (isWin || isLinux) {
|
||||
return app.quit()
|
||||
} else {
|
||||
event.preventDefault()
|
||||
mainWindow.setFullScreen(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 上述逻辑以下:
|
||||
* win/linux: 是“开启托盘+设置关闭时最小化到托盘”的情况
|
||||
* mac: 任何情况都会到这里,因此需要单独处理mac
|
||||
*/
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
mainWindow.hide()
|
||||
|
||||
//for mac users, should hide dock icon if close to tray
|
||||
@@ -316,16 +329,33 @@ export class WindowService {
|
||||
this.mainWindow.restore()
|
||||
return
|
||||
}
|
||||
//[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
|
||||
|
||||
// [Linux] Known Issue
|
||||
// setVisibleOnAllWorkspaces 在 Linux 环境下(特别是 KDE Wayland)会导致窗口进入"假弹出"状态
|
||||
// 因此在 Linux 环境下不执行这两行代码
|
||||
/**
|
||||
* About setVisibleOnAllWorkspaces
|
||||
*
|
||||
* [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
|
||||
*
|
||||
* [Linux] Known Issue
|
||||
* setVisibleOnAllWorkspaces 在 Linux 环境下(特别是 KDE Wayland)会导致窗口进入"假弹出"状态
|
||||
* 因此在 Linux 环境下不执行这两行代码
|
||||
*/
|
||||
if (!isLinux) {
|
||||
this.mainWindow.setVisibleOnAllWorkspaces(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* [macOS] After being closed in fullscreen, the fullscreen behavior will become strange when window shows again
|
||||
* So we need to set it to FALSE explicitly.
|
||||
* althougle other platforms don't have the issue, but it's a good practice to do so
|
||||
*
|
||||
* Check if window is visible to prevent interrupting fullscreen state when clicking dock icon
|
||||
*/
|
||||
if (this.mainWindow.isFullScreen() && !this.mainWindow.isVisible()) {
|
||||
this.mainWindow.setFullScreen(false)
|
||||
}
|
||||
|
||||
this.mainWindow.show()
|
||||
this.mainWindow.focus()
|
||||
if (!isLinux) {
|
||||
@@ -338,7 +368,9 @@ export class WindowService {
|
||||
|
||||
public toggleMainWindow() {
|
||||
// should not toggle main window when in full screen
|
||||
if (this.wasFullScreen) {
|
||||
// but if the main window is close to tray when it's in full screen, we can show it again
|
||||
// (it's a bug in macos, because we can close the window when it's in full screen, and the state will be remained)
|
||||
if (this.mainWindow?.isFullScreen() && this.mainWindow?.isVisible()) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -392,7 +424,8 @@ export class WindowService {
|
||||
//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)
|
||||
//[mac] level higher than 'floating' will cover the pinyin input method
|
||||
this.miniWindow.setAlwaysOnTop(true, 'floating')
|
||||
|
||||
this.miniWindow.on('ready-to-show', () => {
|
||||
if (isPreload) {
|
||||
|
||||
76
src/main/services/mcp/oauth/callback.ts
Normal file
76
src/main/services/mcp/oauth/callback.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import Logger from 'electron-log'
|
||||
import EventEmitter from 'events'
|
||||
import http from 'http'
|
||||
import { URL } from 'url'
|
||||
|
||||
import { OAuthCallbackServerOptions } from './types'
|
||||
|
||||
export class CallBackServer {
|
||||
private server: Promise<http.Server>
|
||||
private events: EventEmitter
|
||||
|
||||
constructor(options: OAuthCallbackServerOptions) {
|
||||
const { port, path, events } = options
|
||||
this.events = events
|
||||
this.server = this.initialize(port, path)
|
||||
}
|
||||
|
||||
initialize(port: number, path: string): Promise<http.Server> {
|
||||
const server = http.createServer((req, res) => {
|
||||
// Only handle requests to the callback path
|
||||
if (req.url?.startsWith(path)) {
|
||||
try {
|
||||
// Parse the URL to extract the authorization code
|
||||
const url = new URL(req.url, `http://localhost:${port}`)
|
||||
const code = url.searchParams.get('code')
|
||||
if (code) {
|
||||
// Emit the code event
|
||||
this.events.emit('auth-code-received', code)
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error('Error processing OAuth callback:', error)
|
||||
res.writeHead(500, { 'Content-Type': 'text/plain' })
|
||||
res.end('Internal Server Error')
|
||||
}
|
||||
} else {
|
||||
// Not a callback request
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' })
|
||||
res.end('Not Found')
|
||||
}
|
||||
})
|
||||
|
||||
// Handle server errors
|
||||
server.on('error', (error) => {
|
||||
Logger.error('OAuth callback server error:', error)
|
||||
})
|
||||
|
||||
const runningServer = new Promise<http.Server>((resolve, reject) => {
|
||||
server.listen(port, () => {
|
||||
Logger.info(`OAuth callback server listening on port ${port}`)
|
||||
resolve(server)
|
||||
})
|
||||
|
||||
server.on('error', (error) => {
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
return runningServer
|
||||
}
|
||||
|
||||
get getServer(): Promise<http.Server> {
|
||||
return this.server
|
||||
}
|
||||
|
||||
async close() {
|
||||
const server = await this.server
|
||||
server.close()
|
||||
}
|
||||
|
||||
async waitForAuthCode(): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
this.events.once('auth-code-received', (code) => {
|
||||
resolve(code)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
78
src/main/services/mcp/oauth/provider.ts
Normal file
78
src/main/services/mcp/oauth/provider.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import path from 'node:path'
|
||||
|
||||
import { getConfigDir } from '@main/utils/file'
|
||||
import { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth'
|
||||
import { OAuthClientInformation, OAuthClientInformationFull, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth'
|
||||
import Logger from 'electron-log'
|
||||
import open from 'open'
|
||||
|
||||
import { JsonFileStorage } from './storage'
|
||||
import { OAuthProviderOptions } from './types'
|
||||
|
||||
export class McpOAuthClientProvider implements OAuthClientProvider {
|
||||
private storage: JsonFileStorage
|
||||
public readonly config: Required<OAuthProviderOptions>
|
||||
|
||||
constructor(options: OAuthProviderOptions) {
|
||||
const configDir = path.join(getConfigDir(), 'mcp', 'oauth')
|
||||
this.config = {
|
||||
serverUrlHash: options.serverUrlHash,
|
||||
callbackPort: options.callbackPort || 12346,
|
||||
callbackPath: options.callbackPath || '/oauth/callback',
|
||||
configDir: options.configDir || configDir,
|
||||
clientName: options.clientName || 'Cherry Studio',
|
||||
clientUri: options.clientUri || 'https://github.com/CherryHQ/cherry-studio'
|
||||
}
|
||||
this.storage = new JsonFileStorage(this.config.serverUrlHash, this.config.configDir)
|
||||
}
|
||||
|
||||
get redirectUrl(): string {
|
||||
return `http://localhost:${this.config.callbackPort}${this.config.callbackPath}`
|
||||
}
|
||||
|
||||
get clientMetadata() {
|
||||
return {
|
||||
redirect_uris: [this.redirectUrl],
|
||||
token_endpoint_auth_method: 'none',
|
||||
grant_types: ['authorization_code', 'refresh_token'],
|
||||
response_types: ['code'],
|
||||
client_name: this.config.clientName,
|
||||
client_uri: this.config.clientUri
|
||||
}
|
||||
}
|
||||
|
||||
async clientInformation(): Promise<OAuthClientInformation | undefined> {
|
||||
return this.storage.getClientInformation()
|
||||
}
|
||||
|
||||
async saveClientInformation(info: OAuthClientInformationFull): Promise<void> {
|
||||
await this.storage.saveClientInformation(info)
|
||||
}
|
||||
|
||||
async tokens(): Promise<OAuthTokens | undefined> {
|
||||
return this.storage.getTokens()
|
||||
}
|
||||
|
||||
async saveTokens(tokens: OAuthTokens): Promise<void> {
|
||||
await this.storage.saveTokens(tokens)
|
||||
}
|
||||
|
||||
async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
|
||||
try {
|
||||
// Open the browser to the authorization URL
|
||||
await open(authorizationUrl.toString())
|
||||
Logger.info('Browser opened automatically.')
|
||||
} catch (error) {
|
||||
Logger.error('Could not open browser automatically.')
|
||||
throw error // Let caller handle the error
|
||||
}
|
||||
}
|
||||
|
||||
async saveCodeVerifier(codeVerifier: string): Promise<void> {
|
||||
await this.storage.saveCodeVerifier(codeVerifier)
|
||||
}
|
||||
|
||||
async codeVerifier(): Promise<string> {
|
||||
return this.storage.getCodeVerifier()
|
||||
}
|
||||
}
|
||||
120
src/main/services/mcp/oauth/storage.ts
Normal file
120
src/main/services/mcp/oauth/storage.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import {
|
||||
OAuthClientInformation,
|
||||
OAuthClientInformationFull,
|
||||
OAuthTokens
|
||||
} from '@modelcontextprotocol/sdk/shared/auth.js'
|
||||
import Logger from 'electron-log'
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
|
||||
import { IOAuthStorage, OAuthStorageData, OAuthStorageSchema } from './types'
|
||||
|
||||
export class JsonFileStorage implements IOAuthStorage {
|
||||
private readonly filePath: string
|
||||
private cache: OAuthStorageData | null = null
|
||||
|
||||
constructor(
|
||||
readonly serverUrlHash: string,
|
||||
configDir: string
|
||||
) {
|
||||
this.filePath = path.join(configDir, `${serverUrlHash}_oauth.json`)
|
||||
}
|
||||
|
||||
private async readStorage(): Promise<OAuthStorageData> {
|
||||
if (this.cache) {
|
||||
return this.cache
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fs.readFile(this.filePath, 'utf-8')
|
||||
const parsed = JSON.parse(data)
|
||||
const validated = OAuthStorageSchema.parse(parsed)
|
||||
this.cache = validated
|
||||
return validated
|
||||
} catch (error) {
|
||||
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
||||
// File doesn't exist, return initial state
|
||||
const initial: OAuthStorageData = { lastUpdated: Date.now() }
|
||||
await this.writeStorage(initial)
|
||||
return initial
|
||||
}
|
||||
Logger.error('Error reading OAuth storage:', error)
|
||||
throw new Error(`Failed to read OAuth storage: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
private async writeStorage(data: OAuthStorageData): Promise<void> {
|
||||
try {
|
||||
// Ensure directory exists
|
||||
await fs.mkdir(path.dirname(this.filePath), { recursive: true })
|
||||
|
||||
// Update timestamp
|
||||
data.lastUpdated = Date.now()
|
||||
|
||||
// Write file atomically
|
||||
const tempPath = `${this.filePath}.tmp`
|
||||
await fs.writeFile(tempPath, JSON.stringify(data, null, 2))
|
||||
await fs.rename(tempPath, this.filePath)
|
||||
|
||||
// Update cache
|
||||
this.cache = data
|
||||
} catch (error) {
|
||||
Logger.error('Error writing OAuth storage:', error)
|
||||
throw new Error(`Failed to write OAuth storage: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
async getClientInformation(): Promise<OAuthClientInformation | undefined> {
|
||||
const data = await this.readStorage()
|
||||
return data.clientInfo
|
||||
}
|
||||
|
||||
async saveClientInformation(info: OAuthClientInformationFull): Promise<void> {
|
||||
const data = await this.readStorage()
|
||||
await this.writeStorage({
|
||||
...data,
|
||||
clientInfo: info
|
||||
})
|
||||
}
|
||||
|
||||
async getTokens(): Promise<OAuthTokens | undefined> {
|
||||
const data = await this.readStorage()
|
||||
return data.tokens
|
||||
}
|
||||
|
||||
async saveTokens(tokens: OAuthTokens): Promise<void> {
|
||||
const data = await this.readStorage()
|
||||
await this.writeStorage({
|
||||
...data,
|
||||
tokens
|
||||
})
|
||||
}
|
||||
|
||||
async getCodeVerifier(): Promise<string> {
|
||||
const data = await this.readStorage()
|
||||
if (!data.codeVerifier) {
|
||||
throw new Error('No code verifier saved for session')
|
||||
}
|
||||
return data.codeVerifier
|
||||
}
|
||||
|
||||
async saveCodeVerifier(codeVerifier: string): Promise<void> {
|
||||
const data = await this.readStorage()
|
||||
await this.writeStorage({
|
||||
...data,
|
||||
codeVerifier
|
||||
})
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
try {
|
||||
await fs.unlink(this.filePath)
|
||||
this.cache = null
|
||||
} catch (error) {
|
||||
if (error instanceof Error && 'code' in error && error.code !== 'ENOENT') {
|
||||
Logger.error('Error clearing OAuth storage:', error)
|
||||
throw new Error(`Failed to clear OAuth storage: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
61
src/main/services/mcp/oauth/types.ts
Normal file
61
src/main/services/mcp/oauth/types.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
OAuthClientInformation,
|
||||
OAuthClientInformationFull,
|
||||
OAuthTokens
|
||||
} from '@modelcontextprotocol/sdk/shared/auth.js'
|
||||
import EventEmitter from 'events'
|
||||
import { z } from 'zod'
|
||||
|
||||
export interface OAuthStorageData {
|
||||
clientInfo?: OAuthClientInformation
|
||||
tokens?: OAuthTokens
|
||||
codeVerifier?: string
|
||||
lastUpdated: number
|
||||
}
|
||||
|
||||
export const OAuthStorageSchema = z.object({
|
||||
clientInfo: z.any().optional(),
|
||||
tokens: z.any().optional(),
|
||||
codeVerifier: z.string().optional(),
|
||||
lastUpdated: z.number()
|
||||
})
|
||||
|
||||
export interface IOAuthStorage {
|
||||
getClientInformation(): Promise<OAuthClientInformation | undefined>
|
||||
saveClientInformation(info: OAuthClientInformationFull): Promise<void>
|
||||
getTokens(): Promise<OAuthTokens | undefined>
|
||||
saveTokens(tokens: OAuthTokens): Promise<void>
|
||||
getCodeVerifier(): Promise<string>
|
||||
saveCodeVerifier(codeVerifier: string): Promise<void>
|
||||
clear(): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth callback server setup options
|
||||
*/
|
||||
export interface OAuthCallbackServerOptions {
|
||||
/** Port for the callback server */
|
||||
port: number
|
||||
/** Path for the callback endpoint */
|
||||
path: string
|
||||
/** Event emitter to signal when auth code is received */
|
||||
events: EventEmitter
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for creating an OAuth client provider
|
||||
*/
|
||||
export interface OAuthProviderOptions {
|
||||
/** Server URL to connect to */
|
||||
serverUrlHash: string
|
||||
/** Port for the OAuth callback server */
|
||||
callbackPort?: number
|
||||
/** Path for the OAuth callback endpoint */
|
||||
callbackPath?: string
|
||||
/** Directory to store OAuth credentials */
|
||||
configDir?: string
|
||||
/** Client name to use for OAuth registration */
|
||||
clientName?: string
|
||||
/** Client URI to use for OAuth registration */
|
||||
clientUri?: string
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import * as fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { isPortable } from '@main/constant'
|
||||
import { audioExts, documentExts, imageExts, textExts, videoExts } from '@shared/config/constant'
|
||||
import { FileType, FileTypes } from '@types'
|
||||
import { app } from 'electron'
|
||||
@@ -79,3 +80,16 @@ export function getFilesDir() {
|
||||
export function getConfigDir() {
|
||||
return path.join(os.homedir(), '.cherrystudio', 'config')
|
||||
}
|
||||
|
||||
export function getAppConfigDir(name: string) {
|
||||
return path.join(getConfigDir(), name)
|
||||
}
|
||||
|
||||
export function setAppDataDir() {
|
||||
if (isPortable) {
|
||||
const dir = path.join(path.dirname(app.getPath('exe')), 'data')
|
||||
if (fs.existsSync(dir)) {
|
||||
app.setPath('appData', dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
46
src/preload/index.d.ts
vendored
46
src/preload/index.d.ts
vendored
@@ -1,7 +1,7 @@
|
||||
import { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
|
||||
import { ElectronAPI } from '@electron-toolkit/preload'
|
||||
import type { FileMetadataResponse, ListFilesResponse, UploadFileResponse } from '@google/generative-ai/server'
|
||||
import type { MCPServer, MCPTool } from '@renderer/types'
|
||||
import type { File } from '@google/genai'
|
||||
import type { GetMCPPromptResponse, MCPPrompt, MCPResource, MCPServer, MCPTool } from '@renderer/types'
|
||||
import { AppInfo, FileType, KnowledgeBaseParams, KnowledgeItem, LanguageVarious, WebDavConfig } from '@renderer/types'
|
||||
import type { LoaderReturn } from '@shared/config/types'
|
||||
import type { OpenDialogOptions } from 'electron'
|
||||
@@ -29,10 +29,13 @@ declare global {
|
||||
setTrayOnClose: (isActive: boolean) => void
|
||||
restartTray: () => void
|
||||
setTheme: (theme: 'light' | 'dark') => void
|
||||
setCustomCss: (css: string) => void
|
||||
setAutoUpdate: (isActive: boolean) => void
|
||||
reload: () => void
|
||||
clearCache: () => Promise<{ success: boolean; error?: string }>
|
||||
system: {
|
||||
getDeviceType: () => Promise<'mac' | 'windows' | 'linux'>
|
||||
getHostname: () => Promise<string>
|
||||
}
|
||||
zip: {
|
||||
compress: (text: string) => Promise<Buffer>
|
||||
@@ -46,6 +49,7 @@ declare global {
|
||||
listWebdavFiles: (webdavConfig: WebDavConfig) => Promise<BackupFile[]>
|
||||
checkConnection: (webdavConfig: WebDavConfig) => Promise<boolean>
|
||||
createDirectory: (webdavConfig: WebDavConfig, path: string, options?: CreateDirectoryOptions) => Promise<void>
|
||||
deleteWebdavFile: (fileName: string, webdavConfig: WebDavConfig) => Promise<boolean>
|
||||
}
|
||||
file: {
|
||||
select: (options?: OpenDialogOptions) => Promise<FileType[] | null>
|
||||
@@ -118,11 +122,11 @@ declare global {
|
||||
resetMinimumSize: () => Promise<void>
|
||||
}
|
||||
gemini: {
|
||||
uploadFile: (file: FileType, apiKey: string) => Promise<UploadFileResponse>
|
||||
retrieveFile: (file: FileType, apiKey: string) => Promise<FileMetadataResponse | undefined>
|
||||
uploadFile: (file: FileType, apiKey: string) => Promise<File>
|
||||
retrieveFile: (file: FileType, apiKey: string) => Promise<File | undefined>
|
||||
base64File: (file: FileType) => Promise<{ data: string; mimeType: string }>
|
||||
listFiles: (apiKey: string) => Promise<ListFilesResponse>
|
||||
deleteFile: (apiKey: string, fileId: string) => Promise<void>
|
||||
listFiles: (apiKey: string) => Promise<File[]>
|
||||
deleteFile: (fileId: string, apiKey: string) => Promise<void>
|
||||
}
|
||||
selectionMenu: {
|
||||
action: (action: string) => Promise<void>
|
||||
@@ -150,7 +154,27 @@ declare global {
|
||||
restartServer: (server: MCPServer) => Promise<void>
|
||||
stopServer: (server: MCPServer) => Promise<void>
|
||||
listTools: (server: MCPServer) => Promise<MCPTool[]>
|
||||
callTool: ({ server, name, args }: { server: MCPServer; name: string; args: any }) => Promise<any>
|
||||
callTool: ({
|
||||
server,
|
||||
name,
|
||||
args
|
||||
}: {
|
||||
server: MCPServer
|
||||
name: string
|
||||
args: any
|
||||
}) => Promise<MCPCallToolResponse>
|
||||
listPrompts: (server: MCPServer) => Promise<MCPPrompt[]>
|
||||
getPrompt: ({
|
||||
server,
|
||||
name,
|
||||
args
|
||||
}: {
|
||||
server: MCPServer
|
||||
name: string
|
||||
args?: Record<string, any>
|
||||
}) => Promise<GetMCPPromptResponse>
|
||||
listResources: (server: MCPServer) => Promise<MCPResource[]>
|
||||
getResource: ({ server, uri }: { server: MCPServer; uri: string }) => Promise<GetResourceResponse>
|
||||
getInstallInfo: () => Promise<{ dir: string; uvPath: string; bunPath: string }>
|
||||
}
|
||||
copilot: {
|
||||
@@ -175,6 +199,14 @@ declare global {
|
||||
decryptToken: (token: string) => Promise<{ username: string; access_token: string }>
|
||||
getDirectoryContents: (token: string, path: string) => Promise<any>
|
||||
}
|
||||
searchService: {
|
||||
openSearchWindow: (uid: string) => Promise<string>
|
||||
closeSearchWindow: (uid: string) => Promise<string>
|
||||
openUrlInSearchWindow: (uid: string, url: string) => Promise<string>
|
||||
}
|
||||
webview: {
|
||||
setOpenLinkExternal: (webviewId: number, isExternal: boolean) => Promise<void>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,10 +19,13 @@ const api = {
|
||||
setTrayOnClose: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTrayOnClose, isActive),
|
||||
restartTray: () => ipcRenderer.invoke(IpcChannel.App_RestartTray),
|
||||
setTheme: (theme: 'light' | 'dark') => ipcRenderer.invoke(IpcChannel.App_SetTheme, theme),
|
||||
setCustomCss: (css: string) => ipcRenderer.invoke(IpcChannel.App_SetCustomCss, css),
|
||||
setAutoUpdate: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetAutoUpdate, isActive),
|
||||
openWebsite: (url: string) => ipcRenderer.invoke(IpcChannel.Open_Website, url),
|
||||
clearCache: () => ipcRenderer.invoke(IpcChannel.App_ClearCache),
|
||||
system: {
|
||||
getDeviceType: () => ipcRenderer.invoke(IpcChannel.System_GetDeviceType)
|
||||
getDeviceType: () => ipcRenderer.invoke(IpcChannel.System_GetDeviceType),
|
||||
getHostname: () => ipcRenderer.invoke(IpcChannel.System_GetHostname)
|
||||
},
|
||||
zip: {
|
||||
compress: (text: string) => ipcRenderer.invoke(IpcChannel.Zip_Compress, text),
|
||||
@@ -41,7 +44,9 @@ const api = {
|
||||
checkConnection: (webdavConfig: WebDavConfig) =>
|
||||
ipcRenderer.invoke(IpcChannel.Backup_CheckConnection, webdavConfig),
|
||||
createDirectory: (webdavConfig: WebDavConfig, path: string, options?: CreateDirectoryOptions) =>
|
||||
ipcRenderer.invoke(IpcChannel.Backup_CreateDirectory, webdavConfig, path, options)
|
||||
ipcRenderer.invoke(IpcChannel.Backup_CreateDirectory, webdavConfig, path, options),
|
||||
deleteWebdavFile: (fileName: string, webdavConfig: WebDavConfig) =>
|
||||
ipcRenderer.invoke(IpcChannel.Backup_DeleteWebdavFile, fileName, webdavConfig)
|
||||
},
|
||||
file: {
|
||||
select: (options?: OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.File_Select, options),
|
||||
@@ -130,8 +135,14 @@ const api = {
|
||||
restartServer: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_RestartServer, server),
|
||||
stopServer: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_StopServer, server),
|
||||
listTools: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_ListTools, server),
|
||||
callTool: ({ server, name, args }: { server: MCPServer; name: string; args: any }) =>
|
||||
callTool: ({ server, name, args }: { server: MCPServer; name: string; args?: Record<string, any> }) =>
|
||||
ipcRenderer.invoke(IpcChannel.Mcp_CallTool, { server, name, args }),
|
||||
listPrompts: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_ListPrompts, server),
|
||||
getPrompt: ({ server, name, args }: { server: MCPServer; name: string; args?: Record<string, any> }) =>
|
||||
ipcRenderer.invoke(IpcChannel.Mcp_GetPrompt, { server, name, args }),
|
||||
listResources: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_ListResources, server),
|
||||
getResource: ({ server, uri }: { server: MCPServer; uri: string }) =>
|
||||
ipcRenderer.invoke(IpcChannel.Mcp_GetResource, { server, uri }),
|
||||
getInstallInfo: () => ipcRenderer.invoke(IpcChannel.Mcp_GetInstallInfo)
|
||||
},
|
||||
shell: {
|
||||
@@ -169,6 +180,15 @@ const api = {
|
||||
decryptToken: (token: string) => ipcRenderer.invoke(IpcChannel.Nutstore_DecryptToken, token),
|
||||
getDirectoryContents: (token: string, path: string) =>
|
||||
ipcRenderer.invoke(IpcChannel.Nutstore_GetDirectoryContents, token, path)
|
||||
},
|
||||
searchService: {
|
||||
openSearchWindow: (uid: string) => ipcRenderer.invoke(IpcChannel.SearchWindow_Open, uid),
|
||||
closeSearchWindow: (uid: string) => ipcRenderer.invoke(IpcChannel.SearchWindow_Close, uid),
|
||||
openUrlInSearchWindow: (uid: string, url: string) => ipcRenderer.invoke(IpcChannel.SearchWindow_OpenUrl, uid, url)
|
||||
},
|
||||
webview: {
|
||||
setOpenLinkExternal: (webviewId: number, isExternal: boolean) =>
|
||||
ipcRenderer.invoke(IpcChannel.Webview_SetOpenLinkExternal, webviewId, isExternal)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
BIN
src/renderer/src/assets/images/apps/zai.png
Normal file
BIN
src/renderer/src/assets/images/apps/zai.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
55
src/renderer/src/assets/images/cherry-text-logo.svg
Normal file
55
src/renderer/src/assets/images/cherry-text-logo.svg
Normal file
@@ -0,0 +1,55 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="_图层_2" data-name="图层_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 198.45 66.73">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #ea5e5d;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #23af69;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #ea5756;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="_图层_1-2" data-name="图层_1">
|
||||
<g>
|
||||
<g>
|
||||
<g>
|
||||
<path class="cls-1" d="M16.72,51.21c-4.45,0-8.64-1.78-11.81-5.01-3.17-3.23-4.91-7.51-4.91-12.04s1.74-8.81,4.91-12.04,7.36-5.01,11.81-5.01,8.71,1.82,11.82,4.99c2.32,2.36,2.32,6.2,0,8.56-2.32,2.36-6.08,2.36-8.4,0-.9-.92-2.15-1.45-3.43-1.45-2.63,0-4.85,2.26-4.85,4.94s2.22,4.94,4.85,4.94c1.28,0,2.52-.53,3.43-1.45,2.32-2.36,6.08-2.36,8.4,0,2.32,2.36,2.32,6.2,0,8.56-3.11,3.17-7.42,4.99-11.82,4.99Z"/>
|
||||
<path class="cls-1" d="M32.05,66.73c-4.45,0-8.64-1.78-11.81-5.01s-4.91-7.51-4.91-12.04,1.79-8.88,4.9-12.06c2.32-2.36,6.08-2.36,8.4,0,2.32,2.36,2.32,6.2,0,8.56-.9.92-1.42,2.19-1.42,3.49,0,2.68,2.22,4.94,4.85,4.94s4.85-2.26,4.85-4.94c0-.95-.23-2.31-1.32-3.43-3.13-3.19-4.92-7.6-4.92-12.09s1.74-8.81,4.91-12.04,7.36-5.01,11.81-5.01,8.64,1.78,11.81,5.01,4.91,7.51,4.91,12.04-1.79,8.88-4.9,12.06c-2.32,2.36-6.08,2.36-8.4,0-2.32-2.36-2.32-6.2,0-8.56.9-.92,1.42-2.19,1.42-3.49,0-2.68-2.22-4.94-4.85-4.94s-4.85,2.26-4.85,4.94c0,1.31.53,2.6,1.45,3.53,3.1,3.16,4.8,7.42,4.8,11.99s-1.74,8.81-4.91,12.04c-3.17,3.23-7.36,5.01-11.81,5.01Z"/>
|
||||
</g>
|
||||
<path class="cls-2" d="M32.05,19.09l-9.72-9.12c-1.5-1.4-1.57-3.75-.17-5.25,1.4-1.49,3.75-1.57,5.25-.17l3.89,3.65,5.53-6.83c1.29-1.59,3.63-1.84,5.22-.55,1.59,1.29,1.84,3.63.55,5.22l-10.56,13.05Z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path class="cls-3" d="M93.93,24.6l.55-.39c.69-.4,1.17-.61,1.46-.61.63,0,1.3.57,2.03,1.7.44.71.67,1.27.67,1.7s-.14.78-.41,1.06c-.27.28-.59.54-.96.76-.36.22-.71.43-1.05.64-.33.2-1.02.47-2.05.79-1.03.32-2.03.49-2.99.49s-1.93-.13-2.91-.38c-.98-.25-1.99-.68-3.03-1.27-1.04-.6-1.98-1.32-2.81-2.18-.83-.86-1.51-1.96-2.05-3.31-.54-1.35-.8-2.81-.8-4.38s.26-3.01.79-4.29c.53-1.28,1.2-2.35,2.02-3.19.82-.84,1.75-1.54,2.81-2.11,1.98-1.09,3.97-1.64,5.98-1.64.95,0,1.92.15,2.9.44.98.29,1.72.59,2.23.9l.73.42c.36.22.65.4.85.55.53.42.79.91.79,1.44s-.21,1.1-.64,1.68c-.79,1.09-1.5,1.64-2.12,1.64-.36,0-.88-.22-1.55-.67-.85-.69-1.98-1.03-3.4-1.03-1.31,0-2.61.46-3.88,1.36-.61.44-1.11,1.07-1.52,1.88-.4.81-.61,1.72-.61,2.75s.2,1.94.61,2.75c.4.81.92,1.45,1.55,1.91,1.23.89,2.52,1.34,3.85,1.34.63,0,1.22-.08,1.77-.24.56-.16.96-.32,1.2-.49Z"/>
|
||||
<path class="cls-3" d="M114.38,9.07c.16-.3.43-.52.82-.64.38-.12.87-.18,1.46-.18s1.05.05,1.4.15c.34.1.61.22.79.36.18.14.32.34.42.61.1.34.15.87.15,1.58v16.84c0,.47-.02.81-.05,1.05-.03.23-.13.5-.29.8-.28.55-1.07.82-2.37.82-1.42,0-2.25-.37-2.49-1.12-.12-.34-.18-.87-.18-1.58v-6.16h-8.04v6.19c0,.47-.02.81-.05,1.05-.03.23-.13.5-.29.8-.28.55-1.07.82-2.37.82-1.42,0-2.25-.37-2.49-1.12-.12-.34-.18-.87-.18-1.58V10.92c0-.46.02-.81.05-1.05.03-.23.13-.5.29-.8.28-.55,1.07-.82,2.37-.82,1.42,0,2.25.37,2.52,1.12.1.34.15.87.15,1.58v6.19h8.04v-6.22c0-.46.02-.81.05-1.05.03-.23.13-.5.29-.8Z"/>
|
||||
<path class="cls-3" d="M127.21,25.1h9.34c.47,0,.81.02,1.05.05.23.03.5.13.8.29.55.28.82,1.07.82,2.37,0,1.42-.37,2.25-1.12,2.49-.34.12-.87.18-1.58.18h-12.01c-1.42,0-2.25-.38-2.49-1.15-.12-.32-.18-.84-.18-1.55V10.9c0-1.03.19-1.73.58-2.11.38-.37,1.11-.56,2.18-.56h11.95c.47,0,.81.02,1.05.05.23.03.5.13.8.29.55.28.82,1.07.82,2.37,0,1.42-.37,2.25-1.12,2.49-.34.12-.87.18-1.58.18h-9.31v3.06h6.01c.46,0,.81.02,1.05.05.23.03.5.13.8.29.55.28.82,1.07.82,2.37,0,1.42-.38,2.25-1.15,2.49-.34.12-.87.18-1.58.18h-5.95v3.06Z"/>
|
||||
<path class="cls-3" d="M196.96,8.79c.99.69,1.49,1.35,1.49,2,0,.38-.23.92-.7,1.61l-6.55,9.8v5.79c0,.47-.02.81-.05,1.05-.03.23-.13.5-.29.8-.16.3-.43.52-.82.64-.38.12-.9.18-1.55.18s-1.16-.06-1.55-.18c-.38-.12-.66-.34-.82-.65-.16-.31-.26-.59-.29-.82-.03-.23-.05-.59-.05-1.08v-5.73l-6.55-9.8c-.47-.69-.7-1.22-.7-1.61,0-.65.44-1.27,1.33-1.87.89-.6,1.53-.9,1.91-.9s.69.08.91.24c.34.22.71.64,1.09,1.24l4.7,7.52,4.7-7.52c.38-.61.72-1.01,1-1.2s.61-.29.99-.29.97.25,1.77.76Z"/>
|
||||
<g>
|
||||
<path class="cls-3" d="M81.93,56.63c-.53-.65-.79-1.23-.79-1.74s.43-1.2,1.3-2.05c.51-.49,1.04-.73,1.61-.73s1.36.51,2.37,1.52c.28.34.69.67,1.21.99.53.31,1.01.47,1.46.47,1.88,0,2.82-.77,2.82-2.31,0-.46-.26-.85-.77-1.17-.52-.31-1.16-.54-1.93-.68-.77-.14-1.6-.37-2.49-.68-.89-.31-1.72-.68-2.49-1.11-.77-.42-1.41-1.1-1.93-2.02-.52-.92-.77-2.03-.77-3.32,0-1.78.66-3.33,1.99-4.66s3.13-1.99,5.42-1.99c1.21,0,2.32.16,3.32.47,1,.31,1.69.63,2.08.96l.76.58c.63.59.94,1.08.94,1.49s-.24.96-.73,1.67c-.69,1.01-1.4,1.52-2.12,1.52-.42,0-.95-.2-1.58-.61-.06-.04-.18-.14-.35-.3-.17-.16-.33-.29-.47-.39-.42-.26-.97-.39-1.62-.39s-1.2.16-1.64.47c-.43.31-.65.75-.65,1.3s.26,1.01.77,1.35c.52.34,1.16.58,1.93.7.77.12,1.61.31,2.52.56.91.25,1.75.56,2.52.93.77.36,1.41,1,1.93,1.9.52.9.77,2.01.77,3.32s-.26,2.47-.79,3.47c-.53,1-1.21,1.77-2.06,2.32-1.64,1.07-3.39,1.61-5.25,1.61-.95,0-1.85-.12-2.7-.35-.85-.23-1.54-.52-2.06-.86-1.07-.65-1.82-1.27-2.24-1.88l-.27-.33Z"/>
|
||||
<path class="cls-3" d="M100.74,37.49h16.87c.65,0,1.12.08,1.43.23.3.15.51.39.61.71.1.32.15.75.15,1.27s-.05.95-.15,1.26c-.1.31-.27.53-.52.65-.36.18-.88.27-1.55.27h-5.79v15.26c0,.47-.02.81-.05,1.03s-.12.48-.27.77c-.15.29-.42.5-.8.62-.38.12-.89.18-1.52.18s-1.13-.06-1.5-.18c-.37-.12-.64-.33-.79-.62-.15-.29-.24-.56-.27-.79-.03-.23-.05-.58-.05-1.05v-15.23h-5.82c-.65,0-1.12-.08-1.43-.23-.3-.15-.51-.39-.61-.71-.1-.32-.15-.75-.15-1.27s.05-.95.15-1.26c.1-.31.27-.53.52-.65.36-.18.88-.27,1.55-.27Z"/>
|
||||
<path class="cls-3" d="M135.99,38.34c.2-.32.5-.55.88-.67.38-.12.86-.18,1.44-.18s1.04.05,1.38.15c.34.1.61.22.79.36.18.14.31.35.39.64.12.34.18.87.18,1.58v9.16c0,2.67-.83,5.1-2.49,7.28-.81,1.03-1.85,1.87-3.12,2.5s-2.68.96-4.23.96-2.95-.32-4.22-.97c-1.26-.65-2.29-1.5-3.08-2.55-1.64-2.14-2.46-4.57-2.46-7.28v-9.13c0-.49.02-.84.05-1.08.03-.23.13-.5.29-.8.16-.3.43-.52.82-.64.38-.12.9-.18,1.55-.18s1.16.06,1.55.18c.38.12.65.33.79.64.24.47.36,1.1.36,1.91v9.1c0,1.23.3,2.41.91,3.52.3.57.76,1.02,1.37,1.36.61.34,1.32.52,2.15.52,1.48,0,2.58-.55,3.31-1.64.73-1.09,1.09-2.36,1.09-3.79v-9.28c0-.79.1-1.34.3-1.67Z"/>
|
||||
<path class="cls-3" d="M146.18,37.49l5.61.03c2.93,0,5.51,1.06,7.74,3.17,2.22,2.11,3.34,4.71,3.34,7.8s-1.09,5.73-3.26,7.93c-2.17,2.2-4.81,3.31-7.9,3.31h-5.55c-1.23,0-2-.25-2.31-.76-.24-.42-.36-1.07-.36-1.94v-16.87c0-.49.02-.84.05-1.06s.13-.49.29-.79c.28-.55,1.07-.82,2.37-.82ZM151.79,54.35c1.46,0,2.77-.54,3.94-1.62,1.17-1.08,1.76-2.44,1.76-4.08s-.57-3.01-1.71-4.11c-1.14-1.1-2.48-1.65-4.02-1.65h-2.91v11.47h2.94Z"/>
|
||||
<path class="cls-3" d="M164.84,40.19c0-.46.02-.81.05-1.05.03-.23.13-.5.29-.8.28-.55,1.07-.82,2.37-.82,1.42,0,2.25.37,2.52,1.12.1.34.15.87.15,1.58v16.87c0,.49-.02.84-.05,1.06s-.13.49-.29.79c-.28.55-1.07.82-2.37.82-1.42,0-2.25-.38-2.49-1.15-.12-.32-.18-.84-.18-1.55v-16.87Z"/>
|
||||
<path class="cls-3" d="M183.07,37.24c2.99,0,5.59,1.08,7.8,3.25,2.2,2.16,3.31,4.85,3.31,8.05s-1.05,5.94-3.16,8.19c-2.1,2.26-4.69,3.38-7.77,3.38s-5.69-1.11-7.84-3.34c-2.15-2.22-3.23-4.87-3.23-7.95,0-1.68.3-3.25.91-4.72.61-1.47,1.42-2.7,2.43-3.69,1.01-.99,2.17-1.77,3.49-2.34,1.31-.57,2.67-.85,4.07-.85ZM177.55,48.68c0,1.8.58,3.26,1.74,4.38,1.16,1.12,2.46,1.68,3.9,1.68s2.73-.55,3.88-1.64c1.15-1.09,1.73-2.56,1.73-4.4s-.58-3.32-1.74-4.43c-1.16-1.11-2.46-1.67-3.9-1.67s-2.73.56-3.88,1.68c-1.15,1.12-1.73,2.58-1.73,4.38Z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path class="cls-3" d="M176.92,11.06c-.03-.23-.13-.5-.29-.8-.28-.55-1.07-.82-2.37-.82h-6.55c-1.78,0-3.51.65-5.19,1.94-.81.63-1.48,1.48-2,2.55-.53,1.07-.79,2.27-.79,3.58,0,2.29.76,4.17,2.28,5.64-.44,1.07-1.13,2.66-2.06,4.76-.3.73-.45,1.25-.45,1.58,0,.77.63,1.42,1.88,1.94.65.28,1.17.43,1.56.43s.72-.1.97-.29c.25-.19.44-.39.56-.59.2-.38.99-2.21,2.37-5.49l.94.06h3.82v3.43c0,.47.02.81.05,1.05.03.23.13.5.29.8.28.55,1.07.82,2.37.82,1.42,0,2.25-.37,2.49-1.12.12-.34.18-.87.18-1.58V12.11c0-.46-.02-.81-.05-1.05ZM172.81,19.44c-.09.14-.48.77-1.24.91-.2.04-.37.03-.48.02-.02.14-.04.26-.06.38-.16.83-.38,1.05-.57,1.07-.29.05-.51-.35-.93-.9-.23.01-.46.02-.69.02-.51,0-1.01-.03-1.49-.09-.25-.03-.5-.07-.74-.11-1.18-.32-2.03-1.27-2.03-2.4v-1.37c0-1.13.86-2.08,2.03-2.4.24-.04.49-.08.74-.11.48-.06.98-.09,1.49-.09s1.01.03,1.49.09c.25.03.5.07.74.11.6.16,1.12.49,1.49.93.34.41.55.92.55,1.47v1.37c0,.23-.01.66-.29,1.1Z"/>
|
||||
<circle class="cls-2" cx="167.24" cy="17.67" r=".49"/>
|
||||
<circle class="cls-2" cx="168.88" cy="17.71" r=".49"/>
|
||||
<circle class="cls-2" cx="170.59" cy="17.71" r=".49"/>
|
||||
</g>
|
||||
<g>
|
||||
<path class="cls-3" d="M141.01,8.24c.03-.23.13-.5.29-.8.28-.55,1.07-.82,2.37-.82h6.55c1.78,0,3.51.65,5.19,1.94.81.63,1.48,1.48,2,2.55.53,1.07.79,2.27.79,3.58,0,2.29-.76,4.17-2.28,5.64.44,1.07,1.13,2.66,2.06,4.76.3.73.45,1.25.45,1.58,0,.77-.63,1.42-1.88,1.94-.65.28-1.17.43-1.56.43s-.72-.1-.97-.29c-.25-.19-.44-.39-.56-.59-.2-.38-.99-2.21-2.37-5.49l-.94.06h-3.82v3.43c0,.47-.02.81-.05,1.05-.03.23-.13.5-.29.8-.28.55-1.07.82-2.37.82-1.42,0-2.25-.37-2.49-1.12-.12-.34-.18-.87-.18-1.58V9.28c0-.46.02-.81.05-1.05ZM145.12,16.62c.09.14.48.77,1.24.91.2.04.37.03.48.02.02.14.04.26.06.38.16.83.38,1.05.57,1.07.29.05.51-.35.93-.9.23.01.46.02.69.02.51,0,1.01-.03,1.49-.09.25-.03.5-.07.74-.11,1.18-.32,2.03-1.27,2.03-2.4v-1.37c0-1.13-.86-2.08-2.03-2.4-.24-.04-.49-.08-.74-.11-.48-.06-.98-.09-1.49-.09s-1.01.03-1.49.09c-.25.03-.5.07-.74.11-.6.16-1.12.49-1.49.93-.34.41-.55.92-.55,1.47v1.37c0,.23.01.66.29,1.1Z"/>
|
||||
<circle class="cls-2" cx="150.69" cy="14.84" r=".49"/>
|
||||
<circle class="cls-2" cx="149.05" cy="14.89" r=".49"/>
|
||||
<circle class="cls-2" cx="147.35" cy="14.89" r=".49"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9.5 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 34 KiB |
BIN
src/renderer/src/assets/images/providers/aihubmix.webp
Normal file
BIN
src/renderer/src/assets/images/providers/aihubmix.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.0 KiB |
@@ -16,3 +16,40 @@
|
||||
--pulse-size: 8px;
|
||||
animation: animation-pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
// Modal动画
|
||||
@keyframes animation-move-down-in {
|
||||
0% {
|
||||
transform: translate3d(0, 100%, 0);
|
||||
transform-origin: 0 0;
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: translate3d(0, 0, 0);
|
||||
transform-origin: 0 0;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes animation-move-down-out {
|
||||
0% {
|
||||
transform: translate3d(0, 0, 0);
|
||||
transform-origin: 0 0;
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translate3d(0, 100%, 0);
|
||||
transform-origin: 0 0;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.animation-move-down-enter,
|
||||
.animation-move-down-appear {
|
||||
animation-name: animation-move-down-in;
|
||||
animation-fill-mode: both;
|
||||
animation-duration: 0.25s;
|
||||
}
|
||||
.animation-move-down-leave {
|
||||
animation-name: animation-move-down-out;
|
||||
animation-fill-mode: both;
|
||||
animation-duration: 0.25s;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ant-tabs-tab-btn {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ant-segmented-group {
|
||||
gap: 4px;
|
||||
}
|
||||
@@ -199,3 +203,11 @@
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.ant-collapse {
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.ant-collapse-content {
|
||||
border-top: 1px solid var(--color-border) !important;
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
--color-border-soft: #ffffff10;
|
||||
--color-border-mute: #ffffff05;
|
||||
--color-error: #f44336;
|
||||
--color-link: #1677ff;
|
||||
--color-link: #338cff;
|
||||
--color-code-background: #323232;
|
||||
--color-hover: rgba(40, 40, 40, 1);
|
||||
--color-active: rgba(55, 55, 55, 1);
|
||||
@@ -260,6 +260,7 @@ body,
|
||||
.markdown,
|
||||
.anticon,
|
||||
.iconfont,
|
||||
.lucide,
|
||||
.message-tokens {
|
||||
color: var(--chat-text-user) !important;
|
||||
}
|
||||
@@ -281,3 +282,7 @@ body,
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
.lucide {
|
||||
color: var(--color-icon);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Collapse } from 'antd'
|
||||
import { merge } from 'lodash'
|
||||
import { FC, memo } from 'react'
|
||||
|
||||
interface CustomCollapseProps {
|
||||
@@ -9,6 +10,11 @@ interface CustomCollapseProps {
|
||||
defaultActiveKey?: string[]
|
||||
activeKey?: string[]
|
||||
collapsible?: 'header' | 'icon' | 'disabled'
|
||||
style?: React.CSSProperties
|
||||
styles?: {
|
||||
header?: React.CSSProperties
|
||||
body?: React.CSSProperties
|
||||
}
|
||||
}
|
||||
|
||||
const CustomCollapse: FC<CustomCollapseProps> = ({
|
||||
@@ -18,14 +24,17 @@ const CustomCollapse: FC<CustomCollapseProps> = ({
|
||||
destroyInactivePanel = false,
|
||||
defaultActiveKey = ['1'],
|
||||
activeKey,
|
||||
collapsible = undefined
|
||||
collapsible = undefined,
|
||||
style,
|
||||
styles
|
||||
}) => {
|
||||
const CollapseStyle = {
|
||||
const defaultCollapseStyle = {
|
||||
width: '100%',
|
||||
background: 'transparent',
|
||||
border: '0.5px solid var(--color-border)'
|
||||
}
|
||||
const CollapseItemStyles = {
|
||||
|
||||
const defaultCollapseItemStyles = {
|
||||
header: {
|
||||
padding: '8px 16px',
|
||||
alignItems: 'center',
|
||||
@@ -35,20 +44,24 @@ const CustomCollapse: FC<CustomCollapseProps> = ({
|
||||
borderTopRightRadius: '8px'
|
||||
},
|
||||
body: {
|
||||
borderTop: '0.5px solid var(--color-border)'
|
||||
borderTop: 'none'
|
||||
}
|
||||
}
|
||||
|
||||
const collapseStyle = merge({}, defaultCollapseStyle, style)
|
||||
const collapseItemStyles = merge({}, defaultCollapseItemStyles, styles)
|
||||
|
||||
return (
|
||||
<Collapse
|
||||
bordered={false}
|
||||
style={CollapseStyle}
|
||||
style={collapseStyle}
|
||||
defaultActiveKey={defaultActiveKey}
|
||||
activeKey={activeKey}
|
||||
destroyInactivePanel={destroyInactivePanel}
|
||||
collapsible={collapsible}
|
||||
items={[
|
||||
{
|
||||
styles: CollapseItemStyles,
|
||||
styles: collapseItemStyles,
|
||||
key: '1',
|
||||
label,
|
||||
extra,
|
||||
|
||||
49
src/renderer/src/components/EmojiIcon.tsx
Normal file
49
src/renderer/src/components/EmojiIcon.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { getLeadingEmoji } from '@renderer/utils'
|
||||
import { FC } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface EmojiIconProps {
|
||||
emoji: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
const EmojiIcon: FC<EmojiIconProps> = ({ emoji, className }) => {
|
||||
const _emoji = getLeadingEmoji(emoji || '⭐️') || '⭐️'
|
||||
|
||||
return (
|
||||
<Container className={className}>
|
||||
<EmojiBackground>{_emoji}</EmojiBackground>
|
||||
{_emoji}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 15px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
margin-right: 3px;
|
||||
`
|
||||
|
||||
const EmojiBackground = styled.div`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 200%;
|
||||
transform: scale(1.5);
|
||||
filter: blur(5px);
|
||||
opacity: 0.4;
|
||||
`
|
||||
|
||||
export default EmojiIcon
|
||||
@@ -5,11 +5,12 @@ import styled from 'styled-components'
|
||||
|
||||
interface Props {
|
||||
app: MinAppType
|
||||
sidebar?: boolean
|
||||
size?: number
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
const MinAppIcon: FC<Props> = ({ app, size = 48, style }) => {
|
||||
const MinAppIcon: FC<Props> = ({ app, size = 48, style, sidebar = false }) => {
|
||||
const _app = DEFAULT_MIN_APPS.find((item) => item.id === app.id)
|
||||
|
||||
if (!_app) {
|
||||
@@ -24,7 +25,7 @@ const MinAppIcon: FC<Props> = ({ app, size = 48, style }) => {
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
backgroundColor: _app.background,
|
||||
...app.style,
|
||||
...(sidebar ? {} : app.style),
|
||||
...style
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { EyeOutlined } from '@ant-design/icons'
|
||||
import { Tooltip } from 'antd'
|
||||
import { ImageIcon } from 'lucide-react'
|
||||
import React, { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
@@ -10,7 +10,7 @@ const VisionIcon: FC<React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>,
|
||||
return (
|
||||
<Container>
|
||||
<Tooltip title={t('models.type.vision')} placement="top">
|
||||
<Icon {...(props as any)} />
|
||||
<Icon size={15} {...(props as any)} />
|
||||
</Tooltip>
|
||||
</Container>
|
||||
)
|
||||
@@ -22,9 +22,8 @@ const Container = styled.div`
|
||||
align-items: center;
|
||||
`
|
||||
|
||||
const Icon = styled(EyeOutlined)`
|
||||
const Icon = styled(ImageIcon)`
|
||||
color: var(--color-primary);
|
||||
font-size: 15px;
|
||||
margin-right: 6px;
|
||||
`
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import styled from 'styled-components'
|
||||
interface ListItemProps {
|
||||
active?: boolean
|
||||
icon?: ReactNode
|
||||
title: string
|
||||
title: ReactNode
|
||||
subtitle?: string
|
||||
titleStyle?: React.CSSProperties
|
||||
onClick?: () => void
|
||||
@@ -52,7 +52,7 @@ const ListItemContainer = styled.div`
|
||||
const ListItemContent = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
gap: 2px;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
`
|
||||
@@ -65,6 +65,7 @@ const IconWrapper = styled.span`
|
||||
`
|
||||
|
||||
const TextContainer = styled.div`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
CodeOutlined,
|
||||
CopyOutlined,
|
||||
ExportOutlined,
|
||||
LinkOutlined,
|
||||
MinusOutlined,
|
||||
PushpinOutlined,
|
||||
ReloadOutlined
|
||||
@@ -12,7 +13,11 @@ import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
||||
import { useBridge } from '@renderer/hooks/useBridge'
|
||||
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||
import { useMinapps } from '@renderer/hooks/useMinapps'
|
||||
import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor'
|
||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { setMinappsOpenLinkExternal } from '@renderer/store/settings'
|
||||
import { MinAppType } from '@renderer/types'
|
||||
import { delay } from '@renderer/utils'
|
||||
import { Avatar, Drawer, Tooltip } from 'antd'
|
||||
@@ -38,6 +43,8 @@ const MinappPopupContainer: React.FC = () => {
|
||||
const { closeMinapp, hideMinappPopup } = useMinappPopup()
|
||||
const { pinned, updatePinnedMinapps } = useMinapps()
|
||||
const { t } = useTranslation()
|
||||
const backgroundColor = useNavBackgroundColor()
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
/** control the drawer open or close */
|
||||
const [isPopupShow, setIsPopupShow] = useState(true)
|
||||
@@ -55,6 +62,8 @@ const MinappPopupContainer: React.FC = () => {
|
||||
const webviewRefs = useRef<Map<string, WebviewTag | null>>(new Map())
|
||||
/** indicate whether the webview has loaded */
|
||||
const webviewLoadedRefs = useRef<Map<string, boolean>>(new Map())
|
||||
/** whether the minapps open link external is enabled */
|
||||
const { minappsOpenLinkExternal } = useSettings()
|
||||
|
||||
const isInDevelopment = process.env.NODE_ENV === 'development'
|
||||
|
||||
@@ -105,9 +114,14 @@ const MinappPopupContainer: React.FC = () => {
|
||||
webviewLoadedRefs.current.forEach((_, appid) => {
|
||||
if (!webviewRefs.current.has(appid)) {
|
||||
webviewLoadedRefs.current.delete(appid)
|
||||
} else if (appid === currentMinappId) {
|
||||
const webviewId = webviewRefs.current.get(appid)?.getWebContentsId()
|
||||
if (webviewId) {
|
||||
window.api.webview.setOpenLinkExternal(webviewId, minappsOpenLinkExternal)
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [currentMinappId])
|
||||
}, [currentMinappId, minappsOpenLinkExternal])
|
||||
|
||||
/** only the keepalive minapp can be minimized */
|
||||
const canMinimize = !(openedOneOffMinapp && openedOneOffMinapp.id == currentMinappId)
|
||||
@@ -173,6 +187,10 @@ const MinappPopupContainer: React.FC = () => {
|
||||
/** the callback function to set the webviews loaded indicator */
|
||||
const handleWebviewLoaded = (appid: string) => {
|
||||
webviewLoadedRefs.current.set(appid, true)
|
||||
const webviewId = webviewRefs.current.get(appid)?.getWebContentsId()
|
||||
if (webviewId) {
|
||||
window.api.webview.setOpenLinkExternal(webviewId, minappsOpenLinkExternal)
|
||||
}
|
||||
if (appid == currentMinappId) {
|
||||
setTimeout(() => setIsReady(true), 200)
|
||||
}
|
||||
@@ -218,6 +236,11 @@ const MinappPopupContainer: React.FC = () => {
|
||||
updatePinnedMinapps(newPinned)
|
||||
}
|
||||
|
||||
/** set the open external status */
|
||||
const handleToggleOpenExternal = () => {
|
||||
dispatch(setMinappsOpenLinkExternal(!minappsOpenLinkExternal))
|
||||
}
|
||||
|
||||
/** Title bar of the popup */
|
||||
const Title = ({ appInfo, url }: { appInfo: AppInfo | null; url: string | null }) => {
|
||||
if (!appInfo) return null
|
||||
@@ -236,7 +259,7 @@ const MinappPopupContainer: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<TitleContainer style={{ justifyContent: 'space-between' }}>
|
||||
<TitleContainer style={{ backgroundColor: backgroundColor }}>
|
||||
<Tooltip
|
||||
title={
|
||||
<TitleTextTooltip>
|
||||
@@ -254,6 +277,14 @@ const MinappPopupContainer: React.FC = () => {
|
||||
}}>
|
||||
<TitleText onContextMenu={(e) => handleCopyUrl(e, url ?? appInfo.url)}>{appInfo.name}</TitleText>
|
||||
</Tooltip>
|
||||
{appInfo.canOpenExternalLink && (
|
||||
<Tooltip title={t('minapp.popup.openExternal')} mouseEnterDelay={0.8} placement="bottom">
|
||||
<Button onClick={() => handleOpenLink(url ?? appInfo.url)}>
|
||||
<ExportOutlined />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Spacer />
|
||||
<ButtonsGroup className={isWindows ? 'windows' : ''}>
|
||||
<Tooltip title={t('minapp.popup.refresh')} mouseEnterDelay={0.8} placement="bottom">
|
||||
<Button onClick={() => handleReload(appInfo.id)}>
|
||||
@@ -270,13 +301,18 @@ const MinappPopupContainer: React.FC = () => {
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{appInfo.canOpenExternalLink && (
|
||||
<Tooltip title={t('minapp.popup.openExternal')} mouseEnterDelay={0.8} placement="bottom">
|
||||
<Button onClick={() => handleOpenLink(url ?? appInfo.url)}>
|
||||
<ExportOutlined />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip
|
||||
title={
|
||||
minappsOpenLinkExternal
|
||||
? t('minapp.popup.open_link_external_on')
|
||||
: t('minapp.popup.open_link_external_off')
|
||||
}
|
||||
mouseEnterDelay={0.8}
|
||||
placement="bottom">
|
||||
<Button onClick={handleToggleOpenExternal} className={minappsOpenLinkExternal ? 'open-external' : ''}>
|
||||
<LinkOutlined />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{isInDevelopment && (
|
||||
<Tooltip title={t('minapp.popup.devtools')} mouseEnterDelay={0.8} placement="bottom">
|
||||
<Button onClick={() => handleOpenDevTools(appInfo.id)}>
|
||||
@@ -331,7 +367,7 @@ const MinappPopupContainer: React.FC = () => {
|
||||
height={'100%'}
|
||||
maskClosable={false}
|
||||
closeIcon={null}
|
||||
style={{ marginLeft: 'var(--sidebar-width)' }}>
|
||||
style={{ marginLeft: 'var(--sidebar-width)', backgroundColor: 'var(--color-background)' }}>
|
||||
{!isReady && (
|
||||
<EmptyView>
|
||||
<Avatar
|
||||
@@ -365,8 +401,8 @@ const TitleText = styled.div`
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-1);
|
||||
margin-right: 10px;
|
||||
-webkit-app-region: no-drag;
|
||||
margin-right: 5px;
|
||||
`
|
||||
|
||||
const TitleTextTooltip = styled.span`
|
||||
@@ -405,6 +441,7 @@ const Button = styled.div`
|
||||
color: var(--color-text-2);
|
||||
transition: all 0.2s ease;
|
||||
font-size: 14px;
|
||||
-webkit-app-region: no-drag;
|
||||
&:hover {
|
||||
color: var(--color-text-1);
|
||||
background-color: var(--color-background-mute);
|
||||
@@ -413,6 +450,10 @@ const Button = styled.div`
|
||||
color: var(--color-primary);
|
||||
background-color: var(--color-primary-bg);
|
||||
}
|
||||
&.open-external {
|
||||
color: var(--color-primary);
|
||||
background-color: var(--color-primary-bg);
|
||||
}
|
||||
`
|
||||
|
||||
const EmptyView = styled.div`
|
||||
@@ -426,4 +467,8 @@ const EmptyView = styled.div`
|
||||
background-color: var(--color-background);
|
||||
`
|
||||
|
||||
const Spacer = styled.div`
|
||||
flex: 1;
|
||||
`
|
||||
|
||||
export default MinappPopupContainer
|
||||
|
||||
@@ -75,7 +75,7 @@ const WebviewContainer = memo(
|
||||
const WebviewStyle: React.CSSProperties = {
|
||||
width: 'calc(100vw - var(--sidebar-width))',
|
||||
height: 'calc(100vh - var(--navbar-height))',
|
||||
backgroundColor: 'white',
|
||||
backgroundColor: 'var(--color-background)',
|
||||
display: 'inline-flex'
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ interface Props extends ButtonProps {
|
||||
|
||||
const OAuthButton: FC<Props> = ({ provider, onSuccess, ...buttonProps }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const onAuth = () => {
|
||||
const handleSuccess = (key: string) => {
|
||||
if (key.trim()) {
|
||||
@@ -29,8 +30,8 @@ const OAuthButton: FC<Props> = ({ provider, onSuccess, ...buttonProps }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Button onClick={onAuth} {...buttonProps}>
|
||||
{t('auth.get_key')}
|
||||
<Button type="primary" onClick={onAuth} shape="round" {...buttonProps}>
|
||||
{t('settings.provider.oauth.button', { provider: t(`provider.${provider.id}`) })}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -131,6 +131,8 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
|
||||
folder: ''
|
||||
})
|
||||
|
||||
// 是否手动编辑过标题
|
||||
const [hasTitleBeenManuallyEdited, setHasTitleBeenManuallyEdited] = useState(false)
|
||||
const [vaults, setVaults] = useState<Array<{ path: string; name: string }>>([])
|
||||
const [files, setFiles] = useState<FileInfo[]>([])
|
||||
const [fileTreeData, setFileTreeData] = useState<any[]>([])
|
||||
@@ -255,6 +257,12 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
|
||||
setState((prevState) => ({ ...prevState, [key]: value }))
|
||||
}
|
||||
|
||||
// 处理title输入变化
|
||||
const handleTitleInputChange = (newTitle: string) => {
|
||||
handleChange('title', newTitle)
|
||||
setHasTitleBeenManuallyEdited(true)
|
||||
}
|
||||
|
||||
const handleVaultChange = (value: string) => {
|
||||
setSelectedVault(value)
|
||||
// 文件夹会通过useEffect自动获取
|
||||
@@ -278,11 +286,17 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
|
||||
const fileName = selectedFile.name
|
||||
const titleWithoutExt = fileName.endsWith('.md') ? fileName.substring(0, fileName.length - 3) : fileName
|
||||
handleChange('title', titleWithoutExt)
|
||||
// 重置手动编辑标记,因为这是非用户设置的title
|
||||
setHasTitleBeenManuallyEdited(false)
|
||||
handleChange('processingMethod', '1')
|
||||
} else {
|
||||
// 如果是文件夹,自动设置标题为话题名并设置处理方式为3(新建)
|
||||
handleChange('processingMethod', '3')
|
||||
handleChange('title', title)
|
||||
// 仅当用户未手动编辑过 title 时,才将其重置为 props.title
|
||||
if (!hasTitleBeenManuallyEdited) {
|
||||
// title 是 props.title
|
||||
handleChange('title', title)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -309,7 +323,7 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
|
||||
<Form.Item label={i18n.t('chat.topics.export.obsidian_title')}>
|
||||
<Input
|
||||
value={state.title}
|
||||
onChange={(e) => handleChange('title', e.target.value)}
|
||||
onChange={(e) => handleTitleInputChange(e.target.value)}
|
||||
placeholder={i18n.t('chat.topics.export.obsidian_title_placeholder')}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { SearchOutlined } from '@ant-design/icons'
|
||||
import { TopView } from '@renderer/components/TopView'
|
||||
import { useAgents } from '@renderer/hooks/useAgents'
|
||||
import { useAssistants, useDefaultAssistant } from '@renderer/hooks/useAssistant'
|
||||
@@ -9,10 +8,12 @@ import { Agent, Assistant } from '@renderer/types'
|
||||
import { uuid } from '@renderer/utils'
|
||||
import { Divider, Input, InputRef, Modal, Tag } from 'antd'
|
||||
import { take } from 'lodash'
|
||||
import { Search } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import EmojiIcon from '../EmojiIcon'
|
||||
import { HStack } from '../Layout'
|
||||
import Scrollbar from '../Scrollbar'
|
||||
|
||||
@@ -98,6 +99,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
setSelectedIndex((prev) => (prev <= 0 ? displayedAgents.length - 1 : prev - 1))
|
||||
break
|
||||
case 'Enter':
|
||||
case 'NumpadEnter':
|
||||
// 如果焦点在输入框且有搜索内容,则默认选择第一项
|
||||
if (document.activeElement === inputRef.current?.input && searchText.trim()) {
|
||||
e.preventDefault()
|
||||
@@ -163,7 +165,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
<Input
|
||||
prefix={
|
||||
<SearchIcon>
|
||||
<SearchOutlined />
|
||||
<Search size={14} />
|
||||
</SearchIcon>
|
||||
}
|
||||
ref={inputRef}
|
||||
@@ -177,7 +179,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
size="middle"
|
||||
/>
|
||||
</HStack>
|
||||
<Divider style={{ margin: 0, borderBlockStartWidth: 0.5 }} />
|
||||
<Divider style={{ margin: 0, marginTop: 4, borderBlockStartWidth: 0.5 }} />
|
||||
<Container ref={containerRef}>
|
||||
{take(agents, 100).map((agent, index) => (
|
||||
<AgentItem
|
||||
@@ -185,12 +187,9 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
onClick={() => onCreateAssistant(agent)}
|
||||
className={`agent-item ${agent.id === 'default' ? 'default' : ''} ${index === selectedIndex ? 'keyboard-selected' : ''}`}
|
||||
onMouseEnter={() => setSelectedIndex(index)}>
|
||||
<HStack
|
||||
alignItems="center"
|
||||
gap={5}
|
||||
style={{ overflow: 'hidden', maxWidth: '100%' }}
|
||||
className="text-nowrap">
|
||||
{agent.emoji} {agent.name}
|
||||
<HStack alignItems="center" gap={5} style={{ overflow: 'hidden', maxWidth: '100%' }}>
|
||||
<EmojiIcon emoji={agent.emoji || ''} />
|
||||
<span className="text-nowrap">{agent.name}</span>
|
||||
</HStack>
|
||||
{agent.id === 'default' && <Tag color="green">{t('agents.tag.system')}</Tag>}
|
||||
{agent.type === 'agent' && <Tag color="orange">{t('agents.tag.agent')}</Tag>}
|
||||
@@ -219,13 +218,11 @@ const AgentItem = styled.div`
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
border: 1px solid transparent;
|
||||
&.default {
|
||||
background-color: var(--color-background-mute);
|
||||
}
|
||||
&.keyboard-selected {
|
||||
background-color: var(--color-background-mute);
|
||||
border: 1px solid var(--color-primary);
|
||||
}
|
||||
.anticon {
|
||||
font-size: 16px;
|
||||
@@ -237,8 +234,8 @@ const AgentItem = styled.div`
|
||||
`
|
||||
|
||||
const SearchIcon = styled.div`
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
@@ -57,6 +57,8 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
|
||||
BackupPopup.hide = onCancel
|
||||
|
||||
const isDisabled = progressData ? progressData.stage !== 'completed' : false
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('backup.title')}
|
||||
@@ -64,8 +66,10 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
onOk={onOk}
|
||||
onCancel={onCancel}
|
||||
afterClose={onClose}
|
||||
transitionName="ant-move-down"
|
||||
okButtonProps={{ disabled: isDisabled }}
|
||||
cancelButtonProps={{ disabled: isDisabled }}
|
||||
okText={t('backup.confirm.button')}
|
||||
maskClosable={false}
|
||||
centered>
|
||||
{!progressData && <div>{t('backup.content')}</div>}
|
||||
{progressData && (
|
||||
|
||||
@@ -48,7 +48,7 @@ const MinAppsPopover: FC<Props> = ({ children }) => {
|
||||
))}
|
||||
{isEmpty(minapps) && (
|
||||
<Center>
|
||||
<Empty />
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
</Center>
|
||||
)}
|
||||
</AppsContainer>
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
import { DownOutlined, UpOutlined } from '@ant-design/icons'
|
||||
import CopyIcon from '@renderer/components/Icons/CopyIcon'
|
||||
import { TopView } from '@renderer/components/TopView'
|
||||
import {
|
||||
isEmbeddingModel,
|
||||
isFunctionCallingModel,
|
||||
isReasoningModel,
|
||||
isVisionModel,
|
||||
isWebSearchModel
|
||||
} from '@renderer/config/models'
|
||||
import { useProvider } from '@renderer/hooks/useProvider'
|
||||
import { Model, ModelType } from '@renderer/types'
|
||||
import { getDefaultGroupName } from '@renderer/utils'
|
||||
import { Button, Checkbox, Divider, Flex, Form, Input, message, Modal } from 'antd'
|
||||
import { CheckboxProps } from 'antd/lib/checkbox'
|
||||
import { FC, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface ModelEditPopupProps {
|
||||
model: Model
|
||||
resolve: (updatedModel?: Model) => void
|
||||
}
|
||||
|
||||
const PopupContainer: FC<ModelEditPopupProps> = ({ model, resolve }) => {
|
||||
const [open, setOpen] = useState(true)
|
||||
const [form] = Form.useForm()
|
||||
const { t } = useTranslation()
|
||||
const [showModelTypes, setShowModelTypes] = useState(false)
|
||||
const { updateModel } = useProvider(model.provider)
|
||||
|
||||
const onFinish = (values: any) => {
|
||||
const updatedModel = {
|
||||
...model,
|
||||
id: values.id || model.id,
|
||||
name: values.name || model.name,
|
||||
group: values.group || model.group
|
||||
}
|
||||
updateModel(updatedModel)
|
||||
setShowModelTypes(false)
|
||||
setOpen(false)
|
||||
resolve(updatedModel)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setShowModelTypes(false)
|
||||
setOpen(false)
|
||||
resolve()
|
||||
}
|
||||
|
||||
const onUpdateModel = (updatedModel: Model) => {
|
||||
updateModel(updatedModel)
|
||||
// 只更新模型数据,不关闭弹窗,不返回结果
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('models.edit')}
|
||||
open={open}
|
||||
onCancel={handleClose}
|
||||
footer={null}
|
||||
maskClosable={false}
|
||||
centered
|
||||
width={600} // 增加宽度
|
||||
styles={{
|
||||
content: {
|
||||
padding: '20px', // 增加内边距
|
||||
borderRadius: 15 // 增加圆角
|
||||
}
|
||||
}}
|
||||
afterOpenChange={(visible) => {
|
||||
if (visible) {
|
||||
form.getFieldInstance('id')?.focus()
|
||||
} else {
|
||||
setShowModelTypes(false)
|
||||
}
|
||||
}}>
|
||||
<Form
|
||||
form={form}
|
||||
labelCol={{ flex: '120px' }} // 增加标签宽度
|
||||
labelAlign="left"
|
||||
colon={false}
|
||||
style={{ marginTop: 15 }}
|
||||
size="large" // 使表单控件更大
|
||||
initialValues={{
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
group: model.group
|
||||
}}
|
||||
onFinish={onFinish}>
|
||||
<Form.Item
|
||||
name="id"
|
||||
label={t('settings.models.add.model_id')}
|
||||
tooltip={t('settings.models.add.model_id.tooltip')}
|
||||
rules={[{ required: true }]}>
|
||||
<Flex justify="space-between" gap={5}>
|
||||
<Input
|
||||
placeholder={t('settings.models.add.model_id.placeholder')}
|
||||
spellCheck={false}
|
||||
maxLength={200}
|
||||
disabled={true}
|
||||
value={model.id}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
form.setFieldValue('name', value)
|
||||
form.setFieldValue('group', getDefaultGroupName(value))
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CopyIcon />}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(model.id)
|
||||
message.success(t('message.copy.success'))
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label={t('settings.models.add.model_name')}
|
||||
tooltip={t('settings.models.add.model_name.tooltip')}
|
||||
rules={[{ required: true }]}>
|
||||
<Input placeholder={t('settings.models.add.model_name.placeholder')} spellCheck={false} maxLength={200} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="group"
|
||||
label={t('settings.models.add.model_group')}
|
||||
tooltip={t('settings.models.add.model_group.tooltip')}>
|
||||
<Input placeholder={t('settings.models.add.model_group.placeholder')} spellCheck={false} maxLength={200} />
|
||||
</Form.Item>
|
||||
<Form.Item style={{ marginBottom: 20, textAlign: 'center', marginTop: 10 }}>
|
||||
<Flex justify="space-between" align="center" style={{ position: 'relative' }}>
|
||||
<MoreSettingsRow onClick={() => setShowModelTypes(!showModelTypes)}>
|
||||
{t('settings.moresetting')}
|
||||
<ExpandIcon>{showModelTypes ? <UpOutlined /> : <DownOutlined />}</ExpandIcon>
|
||||
</MoreSettingsRow>
|
||||
<Button type="primary" htmlType="submit" size="large">
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
{showModelTypes && (
|
||||
<div>
|
||||
<Divider style={{ margin: '0 0 15px 0' }} />
|
||||
<TypeTitle>{t('models.type.select')}:</TypeTitle>
|
||||
{(() => {
|
||||
const defaultTypes = [
|
||||
...(isVisionModel(model) ? ['vision'] : []),
|
||||
...(isEmbeddingModel(model) ? ['embedding'] : []),
|
||||
...(isReasoningModel(model) ? ['reasoning'] : []),
|
||||
...(isFunctionCallingModel(model) ? ['function_calling'] : []),
|
||||
...(isWebSearchModel(model) ? ['web_search'] : [])
|
||||
] as ModelType[]
|
||||
|
||||
// 合并现有选择和默认类型
|
||||
const selectedTypes = [...new Set([...(model.type || []), ...defaultTypes])]
|
||||
|
||||
const showTypeConfirmModal = (type: string) => {
|
||||
window.modal.confirm({
|
||||
title: t('settings.moresetting.warn'),
|
||||
content: t('settings.moresetting.check.warn'),
|
||||
okText: t('settings.moresetting.check.confirm'),
|
||||
cancelText: t('common.cancel'),
|
||||
okButtonProps: { danger: true },
|
||||
cancelButtonProps: { type: 'primary' },
|
||||
onOk: () => {
|
||||
const updatedModel = { ...model, type: [...selectedTypes, type] as ModelType[] }
|
||||
onUpdateModel(updatedModel)
|
||||
},
|
||||
onCancel: () => {},
|
||||
centered: true
|
||||
})
|
||||
}
|
||||
|
||||
const handleTypeChange = (types: string[]) => {
|
||||
const newType = types.find((type) => !selectedTypes.includes(type as ModelType))
|
||||
|
||||
if (newType) {
|
||||
// 如果有新类型被添加,显示确认对话框
|
||||
showTypeConfirmModal(newType)
|
||||
} else {
|
||||
// 如果没有新类型,只是取消选择了某些类型,直接更新
|
||||
const updatedModel = { ...model, type: types as ModelType[] }
|
||||
onUpdateModel(updatedModel)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Checkbox.Group
|
||||
value={selectedTypes}
|
||||
onChange={handleTypeChange}
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: 15 }}>
|
||||
<StyledCheckbox value="vision">{t('models.type.vision')}</StyledCheckbox>
|
||||
<StyledCheckbox value="web_search">{t('models.type.websearch')}</StyledCheckbox>
|
||||
<StyledCheckbox value="reasoning">{t('models.type.reasoning')}</StyledCheckbox>
|
||||
<StyledCheckbox value="function_calling">{t('models.type.function_calling')}</StyledCheckbox>
|
||||
<StyledCheckbox value="embedding">{t('models.type.embedding')}</StyledCheckbox>
|
||||
<StyledCheckbox value="rerank">{t('models.type.rerank')}</StyledCheckbox>
|
||||
</Checkbox.Group>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const MoreSettingsRow = styled.div`
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px; // 增加间距
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 14px; // 增加字体大小
|
||||
&:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
`
|
||||
|
||||
const ExpandIcon = styled.span`
|
||||
font-size: 12px; // 增加图标大小
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`
|
||||
|
||||
const TypeTitle = styled.div`
|
||||
font-size: 16px; // 增加字体大小
|
||||
margin-bottom: 15px; // 增加下边距
|
||||
font-weight: 500;
|
||||
`
|
||||
|
||||
const StyledCheckbox = styled(Checkbox)<CheckboxProps>`
|
||||
font-size: 14px; // 增加字体大小
|
||||
padding: 5px 0; // 增加内边距
|
||||
|
||||
.ant-checkbox-inner {
|
||||
width: 18px; // 增加复选框大小
|
||||
height: 18px; // 增加复选框大小
|
||||
}
|
||||
|
||||
.ant-checkbox + span {
|
||||
padding-left: 12px; // 增加文字与复选框的间距
|
||||
}
|
||||
`
|
||||
|
||||
export default class ModelEditPopup {
|
||||
static hide() {
|
||||
TopView.hide('ModelEditPopup')
|
||||
}
|
||||
static show(model: Model) {
|
||||
return new Promise<Model | undefined>((resolve) => {
|
||||
TopView.show(<PopupContainer model={model} resolve={resolve} />, 'ModelEditPopup')
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { SettingOutlined } from '@ant-design/icons'
|
||||
import { useProvider } from '@renderer/hooks/useProvider'
|
||||
import { Model } from '@renderer/types'
|
||||
import { Button, Tooltip } from 'antd'
|
||||
import { FC, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import ModelEditPopup from './ModelEditPopup'
|
||||
|
||||
interface ModelSettingsButtonProps {
|
||||
model: Model
|
||||
size?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
const ModelSettingsButton: FC<ModelSettingsButtonProps> = ({ model, size = 16, className }) => {
|
||||
const { t } = useTranslation()
|
||||
const { updateModel } = useProvider(model.provider)
|
||||
|
||||
const handleClick = useCallback(
|
||||
async (e: React.MouseEvent) => {
|
||||
e.stopPropagation() // 防止触发父元素的点击事件
|
||||
const updatedModel = await ModelEditPopup.show(model)
|
||||
if (updatedModel) {
|
||||
updateModel(updatedModel)
|
||||
}
|
||||
},
|
||||
[model, updateModel]
|
||||
)
|
||||
|
||||
return (
|
||||
<Tooltip title={t('models.edit')} placement="top">
|
||||
<StyledButton
|
||||
type="text"
|
||||
icon={<SettingOutlined style={{ fontSize: size }} />}
|
||||
onClick={handleClick}
|
||||
className={className}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
const StyledButton = styled(Button)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px; // 增加内边距
|
||||
margin: 0;
|
||||
height: auto;
|
||||
width: auto;
|
||||
min-width: auto;
|
||||
background: transparent;
|
||||
border: none;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
background: transparent;
|
||||
}
|
||||
`
|
||||
|
||||
export default ModelSettingsButton
|
||||
@@ -26,7 +26,7 @@ const PopupContainer: React.FC<Props> = ({ resolve, fs }) => {
|
||||
<Modal
|
||||
open={open}
|
||||
title={t('settings.data.nutstore.pathSelector.title')}
|
||||
transitionName="ant-move-down"
|
||||
transitionName="animation-move-down"
|
||||
afterClose={onClose}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
|
||||
@@ -57,6 +57,8 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
|
||||
RestorePopup.hide = onCancel
|
||||
|
||||
const isDisabled = progressData ? progressData.stage !== 'completed' : false
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('restore.title')}
|
||||
@@ -64,8 +66,10 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
onOk={onOk}
|
||||
onCancel={onCancel}
|
||||
afterClose={onClose}
|
||||
transitionName="ant-move-down"
|
||||
okText={t('restore.confirm.button')}
|
||||
okButtonProps={{ disabled: isDisabled }}
|
||||
cancelButtonProps={{ disabled: isDisabled }}
|
||||
maskClosable={false}
|
||||
centered>
|
||||
{!progressData && <div>{t('restore.content')}</div>}
|
||||
{progressData && (
|
||||
|
||||
@@ -33,7 +33,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
afterClose={onClose}
|
||||
title={null}
|
||||
width="920px"
|
||||
transitionName="ant-move-down"
|
||||
transitionName="animation-move-down"
|
||||
styles={{
|
||||
content: {
|
||||
padding: 0,
|
||||
|
||||
@@ -1,485 +1,549 @@
|
||||
import { PushpinOutlined, SearchOutlined } from '@ant-design/icons'
|
||||
import { PushpinOutlined } from '@ant-design/icons'
|
||||
import { TopView } from '@renderer/components/TopView'
|
||||
import { getModelLogo, isEmbeddingModel, isRerankModel } from '@renderer/config/models'
|
||||
import db from '@renderer/databases'
|
||||
import { useProviders } from '@renderer/hooks/useProvider'
|
||||
import { getModelUniqId } from '@renderer/services/ModelService'
|
||||
import { Model } from '@renderer/types' // Removed unused 'Provider' import
|
||||
import { Avatar, Divider, Empty, Input, InputRef, Modal, Tooltip } from 'antd'
|
||||
import { Model } from '@renderer/types'
|
||||
import { Avatar, Divider, Empty, Input, InputRef, Menu, MenuProps, Modal } from 'antd'
|
||||
import { first, sortBy } from 'lodash'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' // Added useMemo here
|
||||
import { Search } from 'lucide-react'
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { HStack } from '../Layout'
|
||||
import ModelTags from '../ModelTags'
|
||||
import ModelTagsWithLabel from '../ModelTagsWithLabel'
|
||||
import Scrollbar from '../Scrollbar'
|
||||
import ModelSettingsButton from './ModelSettingsButton'
|
||||
|
||||
type MenuItem = Required<MenuProps>['items'][number]
|
||||
|
||||
interface Props {
|
||||
model?: Model // The currently active model, for highlighting
|
||||
model?: Model
|
||||
}
|
||||
|
||||
interface PopupContainerProps extends Props {
|
||||
resolve: (value: Model | undefined) => void
|
||||
}
|
||||
|
||||
const PINNED_PROVIDER_ID = '__pinned__' // Special ID for pinned section
|
||||
|
||||
const PopupContainer: React.FC<PopupContainerProps> = ({ model: activeModel, resolve }) => {
|
||||
const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
|
||||
const [open, setOpen] = useState(true)
|
||||
const { t } = useTranslation()
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const inputRef = useRef<InputRef>(null)
|
||||
const { providers } = useProviders()
|
||||
const [pinnedModels, setPinnedModels] = useState<string[]>([])
|
||||
const [selectedProviderId, setSelectedProviderId] = useState<string>('all')
|
||||
// 移除未使用的状态
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
const [keyboardSelectedId, setKeyboardSelectedId] = useState<string>('')
|
||||
const menuItemRefs = useRef<Record<string, HTMLElement | null>>({})
|
||||
|
||||
const setMenuItemRef = useCallback(
|
||||
(key: string) => (el: HTMLElement | null) => {
|
||||
if (el) {
|
||||
menuItemRefs.current[key] = el
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// --- Load Pinned Models ---
|
||||
useEffect(() => {
|
||||
const loadPinnedModels = async () => {
|
||||
const setting = await db.settings.get('pinned:models')
|
||||
const savedPinnedModels = setting?.value || []
|
||||
|
||||
// Filter out invalid pinned models
|
||||
const allModelIds = providers.flatMap((p) => p.models || []).map((m) => getModelUniqId(m))
|
||||
const validPinnedModels = savedPinnedModels.filter((id: string) => allModelIds.includes(id))
|
||||
const validPinnedModels = savedPinnedModels.filter((id) => allModelIds.includes(id))
|
||||
|
||||
// Update storage if there were invalid models
|
||||
if (validPinnedModels.length !== savedPinnedModels.length) {
|
||||
await db.settings.put({ id: 'pinned:models', value: validPinnedModels })
|
||||
}
|
||||
setPinnedModels(sortBy(validPinnedModels)) // Keep pinned models sorted if needed
|
||||
|
||||
// Set initial selected provider
|
||||
if (activeModel) {
|
||||
const activeModelId = getModelUniqId(activeModel)
|
||||
if (validPinnedModels.includes(activeModelId)) {
|
||||
setSelectedProviderId(PINNED_PROVIDER_ID)
|
||||
} else {
|
||||
setSelectedProviderId(activeModel.provider)
|
||||
}
|
||||
} else if (validPinnedModels.length > 0) {
|
||||
setSelectedProviderId(PINNED_PROVIDER_ID)
|
||||
} else if (providers.length > 0) {
|
||||
setSelectedProviderId(providers[0].id)
|
||||
}
|
||||
setPinnedModels(sortBy(validPinnedModels, ['group', 'name']))
|
||||
}
|
||||
loadPinnedModels()
|
||||
}, [providers, activeModel]) // Depend on providers and activeModel
|
||||
|
||||
// --- Pin/Unpin Logic ---
|
||||
const togglePin = useCallback(
|
||||
async (modelId: string) => {
|
||||
const newPinnedModels = pinnedModels.includes(modelId)
|
||||
? pinnedModels.filter((id) => id !== modelId)
|
||||
: [...pinnedModels, modelId]
|
||||
|
||||
await db.settings.put({ id: 'pinned:models', value: newPinnedModels })
|
||||
setPinnedModels(sortBy(newPinnedModels)) // Keep sorted
|
||||
|
||||
// If unpinning the last pinned model and currently viewing pinned, switch provider
|
||||
if (newPinnedModels.length === 0 && selectedProviderId === PINNED_PROVIDER_ID) {
|
||||
setSelectedProviderId(providers[0]?.id || 'all')
|
||||
}
|
||||
// If pinning a model while viewing its provider, maybe switch to pinned? (Optional UX decision)
|
||||
// else if (!pinnedModels.includes(modelId) && selectedProviderId !== PINNED_PROVIDER_ID) {
|
||||
// setSelectedProviderId(PINNED_PROVIDER_ID);
|
||||
// }
|
||||
},
|
||||
[pinnedModels, selectedProviderId, providers]
|
||||
)
|
||||
|
||||
// 缓存所有模型列表,只在providers变化时重新计算
|
||||
const allModels = useMemo(() => {
|
||||
return providers.flatMap((p) => p.models || [])
|
||||
.filter((m) => !isEmbeddingModel(m) && !isRerankModel(m))
|
||||
}, [providers])
|
||||
|
||||
// --- Filter Models for Right Column ---
|
||||
const displayedModels = useMemo(() => {
|
||||
let modelsToShow: Model[] = []
|
||||
const togglePin = async (modelId: string) => {
|
||||
const newPinnedModels = pinnedModels.includes(modelId)
|
||||
? pinnedModels.filter((id) => id !== modelId)
|
||||
: [...pinnedModels, modelId]
|
||||
|
||||
// 如果有搜索文本,在所有模型中搜索
|
||||
if (searchText.trim()) {
|
||||
const keywords = searchText.toLowerCase().split(/\s+/).filter(Boolean)
|
||||
modelsToShow = allModels.filter((m) => {
|
||||
const provider = providers.find((p) => p.id === m.provider)
|
||||
const providerName = provider ? (provider.isSystem ? t(`provider.${provider.id}`) : provider.name) : ''
|
||||
const fullName = `${m.name} ${providerName}`.toLowerCase()
|
||||
return keywords.every((keyword) => fullName.includes(keyword))
|
||||
})
|
||||
} else {
|
||||
// 没有搜索文本时,根据选择的供应商筛选
|
||||
if (selectedProviderId === 'all') {
|
||||
// 显示所有模型
|
||||
modelsToShow = allModels
|
||||
} else if (selectedProviderId === PINNED_PROVIDER_ID) {
|
||||
// 显示固定的模型
|
||||
modelsToShow = allModels.filter((m) => pinnedModels.includes(getModelUniqId(m)))
|
||||
} else if (selectedProviderId) {
|
||||
// 显示选中供应商的模型
|
||||
const provider = providers.find((p) => p.id === selectedProviderId)
|
||||
if (provider && provider.models) {
|
||||
modelsToShow = provider.models.filter((m) => !isEmbeddingModel(m) && !isRerankModel(m))
|
||||
await db.settings.put({ id: 'pinned:models', value: newPinnedModels })
|
||||
setPinnedModels(sortBy(newPinnedModels, ['group', 'name']))
|
||||
}
|
||||
|
||||
// 根据输入的文本筛选模型
|
||||
const getFilteredModels = useCallback(
|
||||
(provider) => {
|
||||
let models = provider.models.filter((m) => !isEmbeddingModel(m) && !isRerankModel(m))
|
||||
|
||||
if (searchText.trim()) {
|
||||
const keywords = searchText.toLowerCase().split(/\s+/).filter(Boolean)
|
||||
models = models.filter((m) => {
|
||||
const fullName = provider.isSystem
|
||||
? `${m.name} ${provider.name} ${t('provider.' + provider.id)}`
|
||||
: `${m.name} ${provider.name}`
|
||||
|
||||
const lowerFullName = fullName.toLowerCase()
|
||||
return keywords.every((keyword) => lowerFullName.includes(keyword))
|
||||
})
|
||||
} else {
|
||||
// 如果不是搜索状态,过滤掉已固定的模型
|
||||
models = models.filter((m) => !pinnedModels.includes(getModelUniqId(m)))
|
||||
}
|
||||
|
||||
return sortBy(models, ['group', 'name'])
|
||||
},
|
||||
[searchText, t, pinnedModels]
|
||||
)
|
||||
|
||||
// 递归处理菜单项,为每个项添加ref
|
||||
const processMenuItems = useCallback(
|
||||
(items: MenuItem[]) => {
|
||||
// 内部定义 renderMenuItem 函数
|
||||
const renderMenuItem = (item: any) => {
|
||||
return {
|
||||
...item,
|
||||
label: <div ref={setMenuItemRef(item.key)}>{item.label}</div>
|
||||
}
|
||||
}
|
||||
|
||||
return items.map((item) => {
|
||||
if (item && 'children' in item && item.children) {
|
||||
return {
|
||||
...item,
|
||||
children: (item.children as MenuItem[]).map(renderMenuItem)
|
||||
}
|
||||
}
|
||||
return item
|
||||
})
|
||||
},
|
||||
[setMenuItemRef]
|
||||
)
|
||||
|
||||
const filteredItems: MenuItem[] = providers
|
||||
.filter((p) => p.models && p.models.length > 0)
|
||||
.map((p) => {
|
||||
const filteredModels = getFilteredModels(p).map((m) => ({
|
||||
key: getModelUniqId(m),
|
||||
label: (
|
||||
<ModelItem>
|
||||
<ModelNameRow>
|
||||
<span>{m?.name}</span> <ModelTagsWithLabel model={m} size={11} showLabel={false} />
|
||||
</ModelNameRow>
|
||||
<PinIcon
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
togglePin(getModelUniqId(m))
|
||||
}}
|
||||
isPinned={pinnedModels.includes(getModelUniqId(m))}>
|
||||
<PushpinOutlined />
|
||||
</PinIcon>
|
||||
</ModelItem>
|
||||
),
|
||||
icon: (
|
||||
<Avatar src={getModelLogo(m?.id || '')} size={24}>
|
||||
{first(m?.name)}
|
||||
</Avatar>
|
||||
),
|
||||
onClick: () => {
|
||||
resolve(m)
|
||||
setOpen(false)
|
||||
}
|
||||
}))
|
||||
|
||||
// Only return the group if it has filtered models
|
||||
return filteredModels.length > 0
|
||||
? {
|
||||
key: p.id,
|
||||
label: p.isSystem ? t(`provider.${p.id}`) : p.name,
|
||||
type: 'group',
|
||||
children: filteredModels
|
||||
}
|
||||
: null
|
||||
})
|
||||
.filter(Boolean) as MenuItem[] // Filter out null items
|
||||
|
||||
if (pinnedModels.length > 0 && searchText.length === 0) {
|
||||
const pinnedItems = providers
|
||||
.flatMap((p) =>
|
||||
p.models
|
||||
.filter((m) => pinnedModels.includes(getModelUniqId(m)))
|
||||
.map((m) => ({
|
||||
key: getModelUniqId(m),
|
||||
model: m,
|
||||
provider: p
|
||||
}))
|
||||
)
|
||||
.map((m) => ({
|
||||
key: getModelUniqId(m.model) + '_pinned',
|
||||
label: (
|
||||
<ModelItem>
|
||||
<ModelNameRow>
|
||||
<span>
|
||||
{m.model?.name} | {m.provider.isSystem ? t(`provider.${m.provider.id}`) : m.provider.name}
|
||||
</span>{' '}
|
||||
<ModelTagsWithLabel model={m.model} size={11} showLabel={false} />
|
||||
</ModelNameRow>
|
||||
<PinIcon
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
togglePin(getModelUniqId(m.model))
|
||||
}}
|
||||
isPinned={true}>
|
||||
<PushpinOutlined />
|
||||
</PinIcon>
|
||||
</ModelItem>
|
||||
),
|
||||
icon: (
|
||||
<Avatar src={getModelLogo(m.model?.id || '')} size={24}>
|
||||
{first(m.model?.name)}
|
||||
</Avatar>
|
||||
),
|
||||
onClick: () => {
|
||||
resolve(m.model)
|
||||
setOpen(false)
|
||||
}
|
||||
}))
|
||||
|
||||
if (pinnedItems.length > 0) {
|
||||
filteredItems.unshift({
|
||||
key: 'pinned',
|
||||
label: t('models.pinned'),
|
||||
type: 'group',
|
||||
children: pinnedItems
|
||||
} as MenuItem)
|
||||
}
|
||||
}
|
||||
|
||||
return sortBy(modelsToShow, ['group', 'name'])
|
||||
}, [selectedProviderId, pinnedModels, searchText, allModels, providers, t])
|
||||
// 处理菜单项,添加ref
|
||||
const processedItems = processMenuItems(filteredItems)
|
||||
|
||||
// --- Event Handlers ---
|
||||
const handleProviderSelect = useCallback((providerId: string) => {
|
||||
setSelectedProviderId(providerId)
|
||||
}, [])
|
||||
|
||||
const handleModelSelect = useCallback((model: Model) => {
|
||||
resolve(model)
|
||||
const onCancel = () => {
|
||||
setKeyboardSelectedId('')
|
||||
setOpen(false)
|
||||
}, [resolve, setOpen])
|
||||
}
|
||||
|
||||
const onCancel = useCallback(() => {
|
||||
setOpen(false)
|
||||
}, [])
|
||||
|
||||
const onClose = useCallback(async () => {
|
||||
const onClose = async () => {
|
||||
setKeyboardSelectedId('')
|
||||
resolve(undefined)
|
||||
SelectModelPopup.hide()
|
||||
}, [resolve])
|
||||
}
|
||||
|
||||
// --- Focus Input on Open ---
|
||||
useEffect(() => {
|
||||
open && setTimeout(() => inputRef.current?.focus(), 0)
|
||||
}, [open])
|
||||
|
||||
// --- Provider List for Left Column ---
|
||||
const providerListItems = useMemo(() => {
|
||||
const items: { id: string; name: string }[] = [
|
||||
{ id: 'all', name: t('models.all') || '全部' } // 添加“全部”选项
|
||||
]
|
||||
if (pinnedModels.length > 0) {
|
||||
items.push({ id: PINNED_PROVIDER_ID, name: t('models.pinned') })
|
||||
useEffect(() => {
|
||||
if (open && model) {
|
||||
setTimeout(() => {
|
||||
const modelId = getModelUniqId(model)
|
||||
if (menuItemRefs.current[modelId]) {
|
||||
menuItemRefs.current[modelId]?.scrollIntoView({ block: 'center', behavior: 'auto' })
|
||||
}
|
||||
}, 100) // Small delay to ensure menu is rendered
|
||||
}
|
||||
}, [open, model])
|
||||
|
||||
// 获取所有可见的模型项
|
||||
const getVisibleModelItems = useCallback(() => {
|
||||
const items: { key: string; model: Model }[] = []
|
||||
|
||||
// 如果有置顶模型且没有搜索文本,添加置顶模型
|
||||
if (pinnedModels.length > 0 && searchText.length === 0) {
|
||||
providers
|
||||
.flatMap((p) => p.models || [])
|
||||
.filter((m) => pinnedModels.includes(getModelUniqId(m)))
|
||||
.forEach((m) => items.push({ key: getModelUniqId(m) + '_pinned', model: m }))
|
||||
}
|
||||
|
||||
// 添加其他过滤后的模型
|
||||
providers.forEach((p) => {
|
||||
// Only add provider if it has non-embedding/rerank models
|
||||
if (p.models?.some((m) => !isEmbeddingModel(m) && !isRerankModel(m))) {
|
||||
items.push({ id: p.id, name: p.isSystem ? t(`provider.${p.id}`) : p.name })
|
||||
if (p.models) {
|
||||
getFilteredModels(p).forEach((m) => {
|
||||
const modelId = getModelUniqId(m)
|
||||
const isPinned = pinnedModels.includes(modelId)
|
||||
|
||||
// 搜索状态下,所有匹配的模型都应该可以被选中,包括固定的模型
|
||||
// 非搜索状态下,只添加非固定模型(固定模型已在上面添加)
|
||||
if (searchText.length > 0 || !isPinned) {
|
||||
items.push({
|
||||
key: modelId,
|
||||
model: m
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
return items
|
||||
}, [providers, pinnedModels, t])
|
||||
|
||||
// --- Render ---
|
||||
return items
|
||||
}, [pinnedModels, searchText, providers, getFilteredModels])
|
||||
|
||||
// 添加一个useLayoutEffect来处理滚动
|
||||
useLayoutEffect(() => {
|
||||
if (open && keyboardSelectedId && menuItemRefs.current[keyboardSelectedId]) {
|
||||
// 获取当前选中元素和容器
|
||||
const selectedElement = menuItemRefs.current[keyboardSelectedId]
|
||||
const scrollContainer = scrollContainerRef.current
|
||||
|
||||
if (!scrollContainer) return
|
||||
|
||||
const selectedRect = selectedElement.getBoundingClientRect()
|
||||
const containerRect = scrollContainer.getBoundingClientRect()
|
||||
|
||||
// 计算元素相对于容器的位置
|
||||
const currentScrollTop = scrollContainer.scrollTop
|
||||
const elementTop = selectedRect.top - containerRect.top + currentScrollTop
|
||||
const groupTitleHeight = 30
|
||||
|
||||
// 确定滚动位置
|
||||
if (selectedRect.top < containerRect.top + groupTitleHeight) {
|
||||
// 元素被组标题遮挡,向上滚动
|
||||
scrollContainer.scrollTo({
|
||||
top: elementTop - groupTitleHeight,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
} else if (selectedRect.bottom > containerRect.bottom) {
|
||||
// 元素在视口下方,向下滚动
|
||||
scrollContainer.scrollTo({
|
||||
top: elementTop - containerRect.height + selectedRect.height,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [open, keyboardSelectedId])
|
||||
|
||||
// 处理键盘导航
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
const items = getVisibleModelItems()
|
||||
if (items.length === 0) return
|
||||
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
const currentIndex = items.findIndex((item) => item.key === keyboardSelectedId)
|
||||
let nextIndex
|
||||
|
||||
if (currentIndex === -1) {
|
||||
nextIndex = e.key === 'ArrowDown' ? 0 : items.length - 1
|
||||
} else {
|
||||
nextIndex =
|
||||
e.key === 'ArrowDown' ? (currentIndex + 1) % items.length : (currentIndex - 1 + items.length) % items.length
|
||||
}
|
||||
|
||||
const nextItem = items[nextIndex]
|
||||
setKeyboardSelectedId(nextItem.key)
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault() // 阻止回车的默认行为
|
||||
if (keyboardSelectedId) {
|
||||
const selectedItem = items.find((item) => item.key === keyboardSelectedId)
|
||||
if (selectedItem) {
|
||||
resolve(selectedItem.model)
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[keyboardSelectedId, getVisibleModelItems, resolve, setOpen]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [handleKeyDown])
|
||||
|
||||
// 搜索文本改变时重置键盘选中状态
|
||||
useEffect(() => {
|
||||
setKeyboardSelectedId('')
|
||||
}, [searchText])
|
||||
|
||||
const selectedKeys = keyboardSelectedId ? [keyboardSelectedId] : model ? [getModelUniqId(model)] : []
|
||||
|
||||
return (
|
||||
<Modal
|
||||
centered
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
afterClose={onClose}
|
||||
transitionName=""
|
||||
width={600}
|
||||
transitionName="animation-move-down"
|
||||
styles={{
|
||||
content: {
|
||||
borderRadius: 15, // Adjusted border radius
|
||||
borderRadius: 20,
|
||||
padding: 0,
|
||||
overflow: 'hidden',
|
||||
paddingBottom: 20,
|
||||
border: '1px solid var(--color-border)'
|
||||
},
|
||||
body: {
|
||||
padding: 0 // Remove default body padding
|
||||
}
|
||||
}}
|
||||
closeIcon={null}
|
||||
footer={null}
|
||||
width={900} // 进一步增加宽度,使界面更宽敞
|
||||
>
|
||||
{/* Search Input */}
|
||||
<SearchContainer onClick={() => inputRef.current?.focus()}>
|
||||
<SearchInputContainer>
|
||||
<Input
|
||||
prefix={
|
||||
<SearchIcon>
|
||||
<SearchOutlined />
|
||||
</SearchIcon>
|
||||
footer={null}>
|
||||
<HStack style={{ padding: '0 12px', marginTop: 5 }}>
|
||||
<Input
|
||||
prefix={
|
||||
<SearchIcon>
|
||||
<Search size={15} />
|
||||
</SearchIcon>
|
||||
}
|
||||
ref={inputRef}
|
||||
placeholder={t('models.search')}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
allowClear
|
||||
autoFocus
|
||||
style={{ paddingLeft: 0 }}
|
||||
variant="borderless"
|
||||
size="middle"
|
||||
onKeyDown={(e) => {
|
||||
// 防止上下键移动光标
|
||||
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
}
|
||||
ref={inputRef}
|
||||
placeholder={t('models.search')}
|
||||
value={searchText}
|
||||
onChange={useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value
|
||||
setSearchText(value)
|
||||
// 当搜索时,自动选择"all"供应商,以显示所有匹配的模型
|
||||
if (value.trim() && selectedProviderId !== 'all') {
|
||||
setSelectedProviderId('all')
|
||||
}
|
||||
}, [selectedProviderId, t])}
|
||||
// 移除焦点事件处理
|
||||
allowClear
|
||||
autoFocus
|
||||
style={{
|
||||
paddingLeft: 0,
|
||||
height: '32px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
variant="borderless"
|
||||
size="middle"
|
||||
/>
|
||||
</SearchInputContainer>
|
||||
</SearchContainer>
|
||||
<Divider style={{ margin: 0, borderBlockStartWidth: 0.5, marginTop: -5 }} />
|
||||
|
||||
{/* Two Column Layout */}
|
||||
<TwoColumnContainer>
|
||||
{/* Left Column: Providers */}
|
||||
<ProviderListColumn>
|
||||
<Scrollbar style={{ height: '60vh', paddingRight: '5px' }}>
|
||||
{providerListItems.map((provider, index) => (
|
||||
<React.Fragment key={provider.id}>
|
||||
<Tooltip title={provider.name} placement="right" mouseEnterDelay={0.5}>
|
||||
<ProviderListItem
|
||||
$selected={selectedProviderId === provider.id}
|
||||
onClick={() => handleProviderSelect(provider.id)}>
|
||||
<ProviderName>{provider.name}</ProviderName>
|
||||
{provider.id === PINNED_PROVIDER_ID && <PinnedIcon />}
|
||||
</ProviderListItem>
|
||||
</Tooltip>
|
||||
{/* 在每个供应商之后添加分割线,除了最后一个 */}
|
||||
{index < providerListItems.length - 1 && <ProviderDivider />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Scrollbar>
|
||||
</ProviderListColumn>
|
||||
|
||||
{/* Right Column: Models */}
|
||||
<ModelListColumn>
|
||||
<Scrollbar style={{ height: '60vh', paddingRight: '5px' }}>
|
||||
{displayedModels.length > 0 ? (
|
||||
displayedModels.map((m) => (
|
||||
<ModelListItem
|
||||
key={getModelUniqId(m)}
|
||||
$selected={activeModel ? getModelUniqId(activeModel) === getModelUniqId(m) : false}
|
||||
onClick={() => handleModelSelect(m)}>
|
||||
<Avatar src={getModelLogo(m?.id || '')} size={24}>
|
||||
{first(m?.name)}
|
||||
</Avatar>
|
||||
<ModelDetails>
|
||||
<ModelNameRow>
|
||||
<Tooltip title={m?.name} mouseEnterDelay={0.5}>
|
||||
<span className="model-name">{m?.name}</span>
|
||||
</Tooltip>
|
||||
{/* Show provider only if not in pinned view or if search is active */}
|
||||
{(selectedProviderId !== PINNED_PROVIDER_ID || searchText) && (
|
||||
<Tooltip title={providers.find((p) => p.id === m.provider)?.name ?? m.provider} mouseEnterDelay={0.5}>
|
||||
<span className="provider-name">
|
||||
| {providers.find((p) => p.id === m.provider)?.name ?? m.provider}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
<ModelTags model={m} />
|
||||
</ModelNameRow>
|
||||
</ModelDetails>
|
||||
<ActionButtons>
|
||||
<ModelSettingsButton model={m} size={14} className="settings-button" />
|
||||
<PinButton
|
||||
$isPinned={pinnedModels.includes(getModelUniqId(m))}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation() // Prevent model selection when clicking pin
|
||||
togglePin(getModelUniqId(m))
|
||||
}}>
|
||||
<PushpinOutlined />
|
||||
</PinButton>
|
||||
</ActionButtons>
|
||||
</ModelListItem>
|
||||
))
|
||||
) : (
|
||||
<EmptyState>
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t('models.no_matches')} />
|
||||
</EmptyState>
|
||||
)}
|
||||
</Scrollbar>
|
||||
</ModelListColumn>
|
||||
</TwoColumnContainer>
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
<Divider style={{ margin: 0, marginTop: 4, borderBlockStartWidth: 0.5 }} />
|
||||
<Scrollbar style={{ height: '50vh' }} ref={scrollContainerRef}>
|
||||
<Container>
|
||||
{processedItems.length > 0 ? (
|
||||
<StyledMenu
|
||||
items={processedItems}
|
||||
selectedKeys={selectedKeys}
|
||||
mode="inline"
|
||||
inlineIndent={6}
|
||||
onSelect={({ key }) => {
|
||||
setKeyboardSelectedId(key as string)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState>
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
</EmptyState>
|
||||
)}
|
||||
</Container>
|
||||
</Scrollbar>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Styled Components ---
|
||||
|
||||
const SearchContainer = styled(HStack)`
|
||||
padding: 8px 15px;
|
||||
cursor: pointer;
|
||||
const Container = styled.div`
|
||||
margin-top: 10px;
|
||||
`
|
||||
|
||||
const SearchInputContainer = styled.div`
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
const StyledMenu = styled(Menu)`
|
||||
background-color: transparent;
|
||||
padding: 5px;
|
||||
margin-top: -10px;
|
||||
max-height: calc(60vh - 50px);
|
||||
|
||||
.ant-menu-item-group-title {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
margin: 0 -5px;
|
||||
padding: 5px 10px;
|
||||
padding-left: 18px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
|
||||
/* Scroll-driven animation for sticky header */
|
||||
animation: background-change linear both;
|
||||
animation-timeline: scroll();
|
||||
animation-range: entry 0% entry 1%;
|
||||
}
|
||||
|
||||
/* Simple animation that changes background color when sticky */
|
||||
@keyframes background-change {
|
||||
to {
|
||||
background-color: var(--color-background);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-menu-item {
|
||||
height: 36px;
|
||||
line-height: 36px;
|
||||
|
||||
&.ant-menu-item-selected {
|
||||
background-color: var(--color-background-mute) !important;
|
||||
color: var(--color-text-primary) !important;
|
||||
}
|
||||
|
||||
&:not([data-menu-id^='pinned-']) {
|
||||
.pin-icon {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.pin-icon {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.anticon {
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const SearchIcon = styled.div`
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
const ModelItem = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: var(--color-background-soft);
|
||||
margin-right: 5px;
|
||||
color: var(--color-icon);
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-background-mute);
|
||||
}
|
||||
`
|
||||
|
||||
const TwoColumnContainer = styled.div`
|
||||
display: flex;
|
||||
height: 60vh; // 增加高度
|
||||
`
|
||||
|
||||
const ProviderListColumn = styled.div`
|
||||
width: 200px; // 减小宽度到200px
|
||||
border-right: 0.5px solid var(--color-border);
|
||||
padding: 15px 10px; // 减小内边距
|
||||
box-sizing: border-box;
|
||||
background-color: var(--color-background-soft); // Slight background difference
|
||||
`
|
||||
|
||||
const ProviderListItem = styled.div<{ $selected: boolean }>`
|
||||
padding: 10px 12px; // 增加上下内边距
|
||||
cursor: pointer;
|
||||
border-radius: 8px; // 减小圆角
|
||||
margin-bottom: 8px; // 增加下边距
|
||||
font-size: 14px; // 减小字体大小
|
||||
font-weight: ${(props) => (props.$selected ? '600' : '400')};
|
||||
background-color: ${(props) => (props.$selected ? 'var(--color-background-mute)' : 'transparent')};
|
||||
color: ${(props) => (props.$selected ? 'var(--color-text-primary)' : 'var(--color-text)')};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between; // To push pin icon to the right for "Pinned"
|
||||
overflow: hidden; // 防止文本溢出
|
||||
text-overflow: ellipsis; // 溢出显示省略号
|
||||
white-space: nowrap; // 不换行
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-background-mute);
|
||||
}
|
||||
`
|
||||
|
||||
const ModelListColumn = styled.div`
|
||||
flex: 1;
|
||||
padding: 12px; // 减小内边距
|
||||
box-sizing: border-box;
|
||||
`
|
||||
|
||||
const ModelListItem = styled.div<{ $selected: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px; // 进一步减小内边距
|
||||
margin-bottom: 6px; // 进一步减小下边距
|
||||
border-radius: 6px; // 进一步减小圆角
|
||||
cursor: pointer;
|
||||
background-color: ${(props) => (props.$selected ? 'var(--color-background-mute)' : 'transparent')};
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-background-mute);
|
||||
.pin-button, .settings-button {
|
||||
opacity: 0.5; // Show buttons on hover
|
||||
}
|
||||
}
|
||||
|
||||
.pin-button, .settings-button {
|
||||
opacity: ${(props) => (props.$selected ? 0.5 : 0)}; // Show if selected or hovered
|
||||
transition: opacity 0.2s;
|
||||
&:hover {
|
||||
opacity: 1 !important; // Full opacity on direct hover
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const ModelDetails = styled.div`
|
||||
margin-left: 10px; // 进一步减小左边距
|
||||
flex: 1;
|
||||
overflow: hidden; // Prevent long names from breaking layout
|
||||
position: relative;
|
||||
width: 100%;
|
||||
`
|
||||
|
||||
const ModelNameRow = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px; // 进一步减小间距
|
||||
font-size: 13px; // 进一步减小字体大小
|
||||
|
||||
.model-name {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 160px; // 进一步减小最大宽度
|
||||
}
|
||||
.provider-name {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 11px; // 进一步减小字体大小
|
||||
white-space: nowrap;
|
||||
overflow: hidden; // 防止文本溢出
|
||||
text-overflow: ellipsis; // 溢出显示省略号
|
||||
max-width: 120px; // 增加最大宽度
|
||||
}
|
||||
`
|
||||
const ActionButtons = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px; // 进一步减小间距
|
||||
margin-left: auto; // Push to the right
|
||||
`
|
||||
|
||||
const PinButton = styled.button<{ $isPinned: boolean }>`
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px; // 进一步减小内边距
|
||||
color: ${(props) => (props.$isPinned ? 'var(--color-primary)' : 'var(--color-icon)')};
|
||||
transform: ${(props) => (props.$isPinned ? 'rotate(-45deg)' : 'none')};
|
||||
font-size: 14px; // 进一步减小图标大小
|
||||
line-height: 1; // Ensure icon aligns well
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => (props.$isPinned ? 'var(--color-primary)' : 'var(--color-text-primary)')};
|
||||
}
|
||||
gap: 8px;
|
||||
`
|
||||
|
||||
const EmptyState = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
color: var(--color-text-secondary);
|
||||
height: 200px;
|
||||
`
|
||||
|
||||
const ProviderName = styled.span`
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
const SearchIcon = styled.div`
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: var(--color-background-soft);
|
||||
margin-right: 2px;
|
||||
`
|
||||
|
||||
const PinnedIcon = styled(PushpinOutlined)`
|
||||
const PinIcon = styled.span.attrs({ className: 'pin-icon' })<{ isPinned: boolean }>`
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
padding: 0 8px;
|
||||
opacity: ${(props) => (props.isPinned ? 1 : 'inherit')};
|
||||
transition: opacity 0.2s;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
color: ${(props) => (props.isPinned ? 'var(--color-primary)' : 'inherit')};
|
||||
transform: ${(props) => (props.isPinned ? 'rotate(-45deg)' : 'none')};
|
||||
|
||||
&:hover {
|
||||
opacity: 1 !important;
|
||||
color: ${(props) => (props.isPinned ? 'var(--color-primary)' : 'inherit')};
|
||||
}
|
||||
`
|
||||
|
||||
const ProviderDivider = styled.div`
|
||||
height: 1px;
|
||||
background-color: var(--color-border);
|
||||
margin: 8px 0;
|
||||
opacity: 0.5;
|
||||
`
|
||||
|
||||
// --- Export Class ---
|
||||
export default class SelectModelPopup {
|
||||
static hide() {
|
||||
TopView.hide('SelectModelPopup')
|
||||
}
|
||||
static show(params: Props) {
|
||||
return new Promise<Model | undefined>((resolve) => {
|
||||
// 直接显示新的弹窗,不使用setTimeout
|
||||
TopView.show(<PopupContainer {...params} resolve={resolve} />, 'SelectModelPopup')
|
||||
})
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ const PopupContainer: React.FC<Props> = ({ title, resolve }) => {
|
||||
onOk={onOk}
|
||||
onCancel={onCancel}
|
||||
afterClose={onClose}
|
||||
transitionName="ant-move-down"
|
||||
transitionName="animation-move-down"
|
||||
centered>
|
||||
<Box mb={8}>Name</Box>
|
||||
</Modal>
|
||||
|
||||
@@ -69,7 +69,7 @@ const PopupContainer: React.FC<Props> = ({ text, textareaProps, modalProps, reso
|
||||
title={t('common.edit')}
|
||||
width="60vw"
|
||||
style={{ maxHeight: '70vh' }}
|
||||
transitionName="ant-move-down"
|
||||
transitionName="animation-move-down"
|
||||
okText={t('common.save')}
|
||||
{...modalProps}
|
||||
open={open}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { setAvatar } from '@renderer/store/runtime'
|
||||
import { setUserName } from '@renderer/store/settings'
|
||||
import { compressImage, isEmoji } from '@renderer/utils'
|
||||
import { Avatar, Dropdown, Input, Modal, Popover, Upload } from 'antd'
|
||||
import { useState } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@@ -127,7 +127,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
onOk={onOk}
|
||||
onCancel={onCancel}
|
||||
afterClose={onClose}
|
||||
transitionName="ant-move-down"
|
||||
transitionName="animation-move-down"
|
||||
centered>
|
||||
<Center mt="30px">
|
||||
<VStack alignItems="center" gap="10px">
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { CheckOutlined, RightOutlined } from '@ant-design/icons'
|
||||
import { RightOutlined } from '@ant-design/icons'
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { classNames } from '@renderer/utils'
|
||||
import { Flex } from 'antd'
|
||||
import { theme } from 'antd'
|
||||
import Color from 'color'
|
||||
import { t } from 'i18next'
|
||||
import { Check } from 'lucide-react'
|
||||
import React, { use, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
import * as tinyPinyin from 'tiny-pinyin'
|
||||
@@ -81,14 +82,19 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
return true
|
||||
}
|
||||
|
||||
const pattern = lowerSearchText.split('').join('.*')
|
||||
if (tinyPinyin.isSupported() && /[\u4e00-\u9fa5]/.test(filterText)) {
|
||||
const pinyinText = tinyPinyin.convertToPinyin(filterText, '', true)
|
||||
if (pinyinText.toLowerCase().includes(lowerSearchText)) {
|
||||
try {
|
||||
const pinyinText = tinyPinyin.convertToPinyin(filterText, '', true).toLowerCase()
|
||||
const regex = new RegExp(pattern, 'ig')
|
||||
return regex.test(pinyinText)
|
||||
} catch (error) {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
const regex = new RegExp(pattern, 'ig')
|
||||
return regex.test(filterText.toLowerCase())
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
setIndex(newList.length > 0 ? ctx.defaultIndex || 0 : -1)
|
||||
@@ -205,6 +211,8 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement
|
||||
|
||||
const handleInput = (e: Event) => {
|
||||
if (isComposing.current) return
|
||||
|
||||
const target = e.target as HTMLTextAreaElement
|
||||
const cursorPosition = target.selectionStart
|
||||
const textBeforeCursor = target.value.slice(0, cursorPosition)
|
||||
@@ -224,8 +232,9 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
isComposing.current = true
|
||||
}
|
||||
|
||||
const handleCompositionEnd = () => {
|
||||
const handleCompositionEnd = (e: CompositionEvent) => {
|
||||
isComposing.current = false
|
||||
handleInput(e)
|
||||
}
|
||||
|
||||
textArea.addEventListener('input', handleInput)
|
||||
@@ -350,6 +359,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
break
|
||||
|
||||
case 'Enter':
|
||||
case 'NumpadEnter':
|
||||
if (isComposing.current) return
|
||||
|
||||
if (list?.[index]) {
|
||||
@@ -443,7 +453,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
{item.suffix ? (
|
||||
item.suffix
|
||||
) : item.isSelected ? (
|
||||
<CheckOutlined />
|
||||
<Check />
|
||||
) : (
|
||||
item.isMenu && !item.disabled && <RightOutlined />
|
||||
)}
|
||||
@@ -545,6 +555,7 @@ const QuickPanelBody = styled.div`
|
||||
background-color: rgba(240, 240, 240, 0.5);
|
||||
backdrop-filter: blur(35px) saturate(150%);
|
||||
z-index: -1;
|
||||
border-radius: inherit;
|
||||
|
||||
body[theme-mode='dark'] & {
|
||||
background-color: rgba(40, 40, 40, 0.4);
|
||||
@@ -567,12 +578,12 @@ const QuickPanelFooterTips = styled.div<{ $footerWidth: number }>`
|
||||
justify-content: flex-end;
|
||||
flex-shrink: 0;
|
||||
gap: 16px;
|
||||
font-size: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
`
|
||||
|
||||
const QuickPanelFooterTitle = styled.div`
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -603,6 +614,7 @@ const QuickPanelItem = styled.div`
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s ease;
|
||||
margin-bottom: 1px;
|
||||
font-family: Ubuntu;
|
||||
&.selected {
|
||||
background-color: var(--selected-color);
|
||||
&.focused {
|
||||
@@ -629,13 +641,22 @@ const QuickPanelItemLeft = styled.div`
|
||||
`
|
||||
|
||||
const QuickPanelItemIcon = styled.span`
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
> svg {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
`
|
||||
|
||||
const QuickPanelItemLabel = styled.span`
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
line-height: 16px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -665,4 +686,9 @@ const QuickPanelItemSuffixIcon = styled.span`
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 3px;
|
||||
> svg {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
`
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { LoadingOutlined, TranslationOutlined } from '@ant-design/icons'
|
||||
import { LoadingOutlined } from '@ant-design/icons'
|
||||
import { useDefaultModel } from '@renderer/hooks/useAssistant'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { fetchTranslate } from '@renderer/services/ApiService'
|
||||
import { getDefaultTopic, getDefaultTranslateAssistant } from '@renderer/services/AssistantService'
|
||||
import { getUserMessage } from '@renderer/services/MessagesService'
|
||||
import { Button, Tooltip } from 'antd'
|
||||
import { Languages } from 'lucide-react'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
@@ -82,7 +83,7 @@ const TranslateButton: FC<Props> = ({ text, onTranslated, disabled, style, isLoa
|
||||
title={t('chat.input.translate', { target_language: t(`languages.${targetLanguage.toString()}`) })}
|
||||
arrow>
|
||||
<ToolbarButton onClick={handleTranslate} disabled={disabled || isTranslating} style={style} type="text">
|
||||
{isTranslating ? <LoadingOutlined spin /> : <TranslationOutlined />}
|
||||
{isTranslating ? <LoadingOutlined spin /> : <Languages size={18} />}
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
286
src/renderer/src/components/WebdavBackupManager.tsx
Normal file
286
src/renderer/src/components/WebdavBackupManager.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
import { DeleteOutlined, ExclamationCircleOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import { restoreFromWebdav } from '@renderer/services/BackupService'
|
||||
import { formatFileSize } from '@renderer/utils'
|
||||
import { Button, message, Modal, Table, Tooltip } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface BackupFile {
|
||||
fileName: string
|
||||
modifiedTime: string
|
||||
size: number
|
||||
}
|
||||
|
||||
interface WebdavConfig {
|
||||
webdavHost: string
|
||||
webdavUser: string
|
||||
webdavPass: string
|
||||
webdavPath: string
|
||||
}
|
||||
|
||||
interface WebdavBackupManagerProps {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
webdavConfig: {
|
||||
webdavHost?: string
|
||||
webdavUser?: string
|
||||
webdavPass?: string
|
||||
webdavPath?: string
|
||||
}
|
||||
restoreMethod?: (fileName: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function WebdavBackupManager({ visible, onClose, webdavConfig, restoreMethod }: WebdavBackupManagerProps) {
|
||||
const { t } = useTranslation()
|
||||
const [backupFiles, setBackupFiles] = useState<BackupFile[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([])
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [restoring, setRestoring] = useState(false)
|
||||
const [pagination, setPagination] = useState({
|
||||
current: 1,
|
||||
pageSize: 5,
|
||||
total: 0
|
||||
})
|
||||
|
||||
const { webdavHost, webdavUser, webdavPass, webdavPath } = webdavConfig
|
||||
|
||||
const fetchBackupFiles = useCallback(async () => {
|
||||
if (!webdavHost || !webdavUser || !webdavPass || !webdavPath) {
|
||||
message.error(t('message.error.invalid.webdav'))
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const files = await window.api.backup.listWebdavFiles({
|
||||
webdavHost,
|
||||
webdavUser,
|
||||
webdavPass,
|
||||
webdavPath
|
||||
} as WebdavConfig)
|
||||
setBackupFiles(files)
|
||||
setPagination((prev) => ({
|
||||
...prev,
|
||||
total: files.length
|
||||
}))
|
||||
} catch (error: any) {
|
||||
message.error(`${t('settings.data.webdav.backup.manager.fetch.error')}: ${error.message}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [webdavHost, webdavUser, webdavPass, webdavPath, t])
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
fetchBackupFiles()
|
||||
setSelectedRowKeys([])
|
||||
setPagination((prev) => ({
|
||||
...prev,
|
||||
current: 1
|
||||
}))
|
||||
}
|
||||
}, [visible, fetchBackupFiles])
|
||||
|
||||
const handleTableChange = (pagination: any) => {
|
||||
setPagination(pagination)
|
||||
}
|
||||
|
||||
const handleDeleteSelected = async () => {
|
||||
if (selectedRowKeys.length === 0) {
|
||||
message.warning(t('settings.data.webdav.backup.manager.select.files.delete'))
|
||||
return
|
||||
}
|
||||
|
||||
if (!webdavHost || !webdavUser || !webdavPass || !webdavPath) {
|
||||
message.error(t('message.error.invalid.webdav'))
|
||||
return
|
||||
}
|
||||
|
||||
window.modal.confirm({
|
||||
title: t('settings.data.webdav.backup.manager.delete.confirm.title'),
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: t('settings.data.webdav.backup.manager.delete.confirm.multiple', { count: selectedRowKeys.length }),
|
||||
okText: t('common.confirm'),
|
||||
cancelText: t('common.cancel'),
|
||||
centered: true,
|
||||
onOk: async () => {
|
||||
setDeleting(true)
|
||||
try {
|
||||
// 依次删除选中的文件
|
||||
for (const key of selectedRowKeys) {
|
||||
await window.api.backup.deleteWebdavFile(key.toString(), {
|
||||
webdavHost,
|
||||
webdavUser,
|
||||
webdavPass,
|
||||
webdavPath
|
||||
} as WebdavConfig)
|
||||
}
|
||||
message.success(
|
||||
t('settings.data.webdav.backup.manager.delete.success.multiple', { count: selectedRowKeys.length })
|
||||
)
|
||||
setSelectedRowKeys([])
|
||||
await fetchBackupFiles()
|
||||
} catch (error: any) {
|
||||
message.error(`${t('settings.data.webdav.backup.manager.delete.error')}: ${error.message}`)
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleDeleteSingle = async (fileName: string) => {
|
||||
if (!webdavHost || !webdavUser || !webdavPass || !webdavPath) {
|
||||
message.error(t('message.error.invalid.webdav'))
|
||||
return
|
||||
}
|
||||
|
||||
window.modal.confirm({
|
||||
title: t('settings.data.webdav.backup.manager.delete.confirm.title'),
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: t('settings.data.webdav.backup.manager.delete.confirm.single', { fileName }),
|
||||
okText: t('common.confirm'),
|
||||
cancelText: t('common.cancel'),
|
||||
centered: true,
|
||||
onOk: async () => {
|
||||
setDeleting(true)
|
||||
try {
|
||||
await window.api.backup.deleteWebdavFile(fileName, {
|
||||
webdavHost,
|
||||
webdavUser,
|
||||
webdavPass,
|
||||
webdavPath
|
||||
} as WebdavConfig)
|
||||
message.success(t('settings.data.webdav.backup.manager.delete.success.single'))
|
||||
await fetchBackupFiles()
|
||||
} catch (error: any) {
|
||||
message.error(`${t('settings.data.webdav.backup.manager.delete.error')}: ${error.message}`)
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleRestore = async (fileName: string) => {
|
||||
if (!webdavHost || !webdavUser || !webdavPass || !webdavPath) {
|
||||
message.error(t('message.error.invalid.webdav'))
|
||||
return
|
||||
}
|
||||
|
||||
window.modal.confirm({
|
||||
title: t('settings.data.webdav.restore.confirm.title'),
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: t('settings.data.webdav.restore.confirm.content'),
|
||||
okText: t('common.confirm'),
|
||||
cancelText: t('common.cancel'),
|
||||
centered: true,
|
||||
onOk: async () => {
|
||||
setRestoring(true)
|
||||
try {
|
||||
await (restoreMethod || restoreFromWebdav)(fileName)
|
||||
message.success(t('settings.data.webdav.backup.manager.restore.success'))
|
||||
onClose() // 关闭模态框
|
||||
} catch (error: any) {
|
||||
message.error(`${t('settings.data.webdav.backup.manager.restore.error')}: ${error.message}`)
|
||||
} finally {
|
||||
setRestoring(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('settings.data.webdav.backup.manager.columns.fileName'),
|
||||
dataIndex: 'fileName',
|
||||
key: 'fileName',
|
||||
ellipsis: {
|
||||
showTitle: false
|
||||
},
|
||||
render: (fileName: string) => (
|
||||
<Tooltip placement="topLeft" title={fileName}>
|
||||
{fileName}
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('settings.data.webdav.backup.manager.columns.modifiedTime'),
|
||||
dataIndex: 'modifiedTime',
|
||||
key: 'modifiedTime',
|
||||
width: 180,
|
||||
render: (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm:ss')
|
||||
},
|
||||
{
|
||||
title: t('settings.data.webdav.backup.manager.columns.size'),
|
||||
dataIndex: 'size',
|
||||
key: 'size',
|
||||
width: 120,
|
||||
render: (size: number) => formatFileSize(size)
|
||||
},
|
||||
{
|
||||
title: t('settings.data.webdav.backup.manager.columns.actions'),
|
||||
key: 'action',
|
||||
width: 160,
|
||||
render: (_: any, record: BackupFile) => (
|
||||
<>
|
||||
<Button type="link" onClick={() => handleRestore(record.fileName)} disabled={restoring || deleting}>
|
||||
{t('settings.data.webdav.backup.manager.restore.text')}
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
onClick={() => handleDeleteSingle(record.fileName)}
|
||||
disabled={deleting || restoring}>
|
||||
{t('settings.data.webdav.backup.manager.delete.text')}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
const rowSelection = {
|
||||
selectedRowKeys,
|
||||
onChange: (selectedRowKeys: React.Key[]) => {
|
||||
setSelectedRowKeys(selectedRowKeys)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('settings.data.webdav.backup.manager.title')}
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
width={800}
|
||||
footer={[
|
||||
<Button key="refresh" icon={<ReloadOutlined />} onClick={fetchBackupFiles} disabled={loading}>
|
||||
{t('settings.data.webdav.backup.manager.refresh')}
|
||||
</Button>,
|
||||
<Button
|
||||
key="delete"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={handleDeleteSelected}
|
||||
disabled={selectedRowKeys.length === 0 || deleting}
|
||||
loading={deleting}>
|
||||
{t('settings.data.webdav.backup.manager.delete.selected')} ({selectedRowKeys.length})
|
||||
</Button>,
|
||||
<Button key="close" onClick={onClose}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
]}>
|
||||
<Table
|
||||
rowKey="fileName"
|
||||
columns={columns}
|
||||
dataSource={backupFiles}
|
||||
rowSelection={rowSelection}
|
||||
pagination={pagination}
|
||||
loading={loading}
|
||||
onChange={handleTableChange}
|
||||
size="middle"
|
||||
/>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -42,8 +42,9 @@ export function useWebdavBackupModal({ backupMethod }: { backupMethod?: typeof b
|
||||
const showBackupModal = useCallback(async () => {
|
||||
// 获取默认文件名
|
||||
const deviceType = await window.api.system.getDeviceType()
|
||||
const hostname = await window.api.system.getHostname()
|
||||
const timestamp = dayjs().format('YYYYMMDDHHmmss')
|
||||
const defaultFileName = `cherry-studio.${timestamp}.${deviceType}.zip`
|
||||
const defaultFileName = `cherry-studio.${timestamp}.${hostname}.${deviceType}.zip`
|
||||
setCustomFileName(defaultFileName)
|
||||
setIsModalVisible(true)
|
||||
}, [])
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
import {
|
||||
FileSearchOutlined,
|
||||
FolderOutlined,
|
||||
PictureOutlined,
|
||||
QuestionCircleOutlined,
|
||||
TranslationOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { AppLogo, UserAvatar } from '@renderer/config/env'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
@@ -17,6 +10,19 @@ import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { isEmoji } from '@renderer/utils'
|
||||
import type { MenuProps } from 'antd'
|
||||
import { Avatar, Dropdown, Tooltip } from 'antd'
|
||||
import {
|
||||
CircleHelp,
|
||||
FileSearch,
|
||||
Folder,
|
||||
Languages,
|
||||
LayoutGrid,
|
||||
MessageSquareQuote,
|
||||
Moon,
|
||||
Palette,
|
||||
Settings,
|
||||
Sparkle,
|
||||
Sun
|
||||
} from 'lucide-react'
|
||||
import { FC, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
@@ -84,7 +90,7 @@ const Sidebar: FC = () => {
|
||||
<Menus>
|
||||
<Tooltip title={t('docs.title')} mouseEnterDelay={0.8} placement="right">
|
||||
<Icon theme={theme} onClick={onOpenDocs} className={minappShow && currentMinappId === docsId ? 'active' : ''}>
|
||||
<QuestionCircleOutlined />
|
||||
<CircleHelp size={20} className="icon" />
|
||||
</Icon>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
@@ -92,22 +98,17 @@ const Sidebar: FC = () => {
|
||||
mouseEnterDelay={0.8}
|
||||
placement="right">
|
||||
<Icon theme={theme} onClick={() => toggleTheme()}>
|
||||
{theme === 'dark' ? (
|
||||
<i className="iconfont icon-theme icon-dark1" />
|
||||
) : (
|
||||
<i className="iconfont icon-theme icon-theme-light" />
|
||||
)}
|
||||
{theme === 'dark' ? <Moon size={20} className="icon" /> : <Sun size={20} className="icon" />}
|
||||
</Icon>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('settings.title')} mouseEnterDelay={0.8} placement="right">
|
||||
<StyledLink
|
||||
onClick={async () => {
|
||||
hideMinappPopup()
|
||||
await modelGenerating()
|
||||
await to('/settings/provider')
|
||||
}}>
|
||||
<Icon theme={theme} className={pathname.startsWith('/settings') && !minappShow ? 'active' : ''}>
|
||||
<i className="iconfont icon-setting" />
|
||||
<Settings size={20} className="icon" />
|
||||
</Icon>
|
||||
</StyledLink>
|
||||
</Tooltip>
|
||||
@@ -129,13 +130,13 @@ const MainMenus: FC = () => {
|
||||
const isRoutes = (path: string): string => (pathname.startsWith(path) && !minappShow ? 'active' : '')
|
||||
|
||||
const iconMap = {
|
||||
assistants: <i className="iconfont icon-chat" />,
|
||||
agents: <i className="iconfont icon-business-smart-assistant" />,
|
||||
paintings: <PictureOutlined style={{ fontSize: 16 }} />,
|
||||
translate: <TranslationOutlined />,
|
||||
minapp: <i className="iconfont icon-appstore" />,
|
||||
knowledge: <FileSearchOutlined />,
|
||||
files: <FolderOutlined />
|
||||
assistants: <MessageSquareQuote size={18} className="icon" />,
|
||||
agents: <Sparkle size={18} className="icon" />,
|
||||
paintings: <Palette size={18} className="icon" />,
|
||||
translate: <Languages size={18} className="icon" />,
|
||||
minapp: <LayoutGrid size={18} className="icon" />,
|
||||
knowledge: <FileSearch size={18} className="icon" />,
|
||||
files: <Folder size={17} className="icon" />
|
||||
}
|
||||
|
||||
const pathMap = {
|
||||
@@ -247,7 +248,7 @@ const SidebarOpenedMinappTabs: FC = () => {
|
||||
theme={theme}
|
||||
onClick={() => handleOnClick(app)}
|
||||
className={`${isActive ? 'opened-active' : ''}`}>
|
||||
<MinAppIcon size={20} app={app} style={{ borderRadius: 6 }} />
|
||||
<MinAppIcon size={20} app={app} style={{ borderRadius: 6 }} sidebar />
|
||||
</Icon>
|
||||
</Dropdown>
|
||||
</StyledLink>
|
||||
@@ -289,7 +290,7 @@ const PinnedApps: FC = () => {
|
||||
theme={theme}
|
||||
onClick={() => openMinappKeepAlive(app)}
|
||||
className={`${isActive ? 'active' : ''} ${openedKeepAliveMinapps.some((item) => item.id === app.id) ? 'opened-minapp' : ''}`}>
|
||||
<MinAppIcon size={20} app={app} style={{ borderRadius: 6 }} />
|
||||
<MinAppIcon size={20} app={app} style={{ borderRadius: 6 }} sidebar />
|
||||
</Icon>
|
||||
</Dropdown>
|
||||
</StyledLink>
|
||||
@@ -364,30 +365,19 @@ const Icon = styled.div<{ theme: string }>`
|
||||
box-sizing: border-box;
|
||||
-webkit-app-region: none;
|
||||
border: 0.5px solid transparent;
|
||||
.iconfont,
|
||||
.anticon {
|
||||
color: var(--color-icon);
|
||||
font-size: 20px;
|
||||
text-decoration: none;
|
||||
}
|
||||
.anticon {
|
||||
font-size: 17px;
|
||||
}
|
||||
&:hover {
|
||||
background-color: ${({ theme }) => (theme === 'dark' ? 'var(--color-black)' : 'var(--color-white)')};
|
||||
opacity: 0.8;
|
||||
cursor: pointer;
|
||||
.iconfont,
|
||||
.anticon {
|
||||
.icon {
|
||||
color: var(--color-icon-white);
|
||||
}
|
||||
}
|
||||
&.active {
|
||||
background-color: ${({ theme }) => (theme === 'dark' ? 'var(--color-black)' : 'var(--color-white)')};
|
||||
border: 0.5px solid var(--color-border);
|
||||
.iconfont,
|
||||
.anticon {
|
||||
color: var(--color-icon-white);
|
||||
.icon {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user