Compare commits
165 Commits
feat/model
...
feat/sideb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4434eb7c8 | ||
|
|
762732af9d | ||
|
|
5b9ff3053b | ||
|
|
8340922263 | ||
|
|
a93cab6b43 | ||
|
|
9a81c400ab | ||
|
|
808a22d5c6 | ||
|
|
10e512f32e | ||
|
|
4d75515bd6 | ||
|
|
3d6c84de6d | ||
|
|
3dd393b840 | ||
|
|
8f86c53941 | ||
|
|
a7b78c547a | ||
|
|
bcc1046cdf | ||
|
|
c05c06b7a1 | ||
|
|
446ebae175 | ||
|
|
ba742b7b1f | ||
|
|
7c6db809bb | ||
|
|
855499681f | ||
|
|
92be3c0f56 | ||
|
|
2a72f391b7 | ||
|
|
db642f0837 | ||
|
|
fca93b6c51 | ||
|
|
7e672d86e7 | ||
|
|
e9112cad0f | ||
|
|
ffbd6445df | ||
|
|
dff44f2721 | ||
|
|
3afa81eb5d | ||
|
|
3350c3e2e5 | ||
|
|
f85f46c248 | ||
|
|
05f3b88f30 | ||
|
|
f8c6b5c05f | ||
|
|
97dbfe492e | ||
|
|
186f0ed06f | ||
|
|
daf134f331 | ||
|
|
3f7f78da15 | ||
|
|
1d289621fc | ||
|
|
d7002cda11 | ||
|
|
559fcecf77 | ||
|
|
1d854c232e | ||
|
|
8c6684cbdf | ||
|
|
c7ab71f01f | ||
|
|
9b57351d1e | ||
|
|
f9e88fb6ee | ||
|
|
074ba0ae05 | ||
|
|
4a8a5e8428 | ||
|
|
f7fa665f3a | ||
|
|
e273ddcfb0 | ||
|
|
41d3a1fd55 | ||
|
|
7237ba34db | ||
|
|
fbf89b3f0a | ||
|
|
8f38422e7f | ||
|
|
79a64f0118 | ||
|
|
55648350ed | ||
|
|
2a33a9af64 | ||
|
|
14c5357fa3 | ||
|
|
a343377a43 | ||
|
|
de75992e7b | ||
|
|
fba6c1642d | ||
|
|
4f7ca3ede8 | ||
|
|
8fd59e89de | ||
|
|
da5badc189 | ||
|
|
33da5d31cf | ||
|
|
f506a9d7ac | ||
|
|
915291d780 | ||
|
|
08b9e0788f | ||
|
|
115d2078b9 | ||
|
|
00151f2c67 | ||
|
|
0e670329c8 | ||
|
|
4ac8a38834 | ||
|
|
4111ee4c58 | ||
|
|
05b8afd681 | ||
|
|
2b4ca03376 | ||
|
|
a314a43f0f | ||
|
|
278fd931fb | ||
|
|
1e0f0f47fa | ||
|
|
942faf474b | ||
|
|
9fd2583fd5 | ||
|
|
463ca6185b | ||
|
|
2c5bb5b699 | ||
|
|
40519b48c5 | ||
|
|
7f8ad88c06 | ||
|
|
5d9b47198b | ||
|
|
db1c03f9fa | ||
|
|
bedea8aaaa | ||
|
|
27d959caed | ||
|
|
0609b93a14 | ||
|
|
c7a0b05841 | ||
|
|
5ca0ce682b | ||
|
|
7c3752a8e6 | ||
|
|
7ae10be387 | ||
|
|
7767dfaac7 | ||
|
|
c89ff17b36 | ||
|
|
0810a63fd8 | ||
|
|
ff261fb52b | ||
|
|
8f8deb9275 | ||
|
|
0a7e591f0e | ||
|
|
c1e8f1063a | ||
|
|
4317f4b672 | ||
|
|
202504fd17 | ||
|
|
05727c637f | ||
|
|
c7843ca288 | ||
|
|
facf29e02b | ||
|
|
66d280136c | ||
|
|
91892ea619 | ||
|
|
7d70425c75 | ||
|
|
20b3db0c01 | ||
|
|
f1804bc3a0 | ||
|
|
2ec0c29087 | ||
|
|
20a572aa46 | ||
|
|
142b624001 | ||
|
|
3775562956 | ||
|
|
3544c40d9a | ||
|
|
36fa3af9e9 | ||
|
|
eb832cc25a | ||
|
|
0378f9ceb1 | ||
|
|
99b23e1d5d | ||
|
|
8d23c810fe | ||
|
|
cdfa2ac13a | ||
|
|
58f3edb352 | ||
|
|
edeb9f84f9 | ||
|
|
2757fcf6b9 | ||
|
|
5339f4a9a3 | ||
|
|
e786feb165 | ||
|
|
5794c36d0d | ||
|
|
7d8b88c56e | ||
|
|
d0daeddf14 | ||
|
|
2fe2ae797b | ||
|
|
9bb66227b4 | ||
|
|
5f4736e8c1 | ||
|
|
7a44910847 | ||
|
|
509632030b | ||
|
|
09fe2aa67b | ||
|
|
793c641e1c | ||
|
|
38eb206a8a | ||
|
|
3d9236a09a | ||
|
|
e6fd9b5678 | ||
|
|
d41f175a05 | ||
|
|
6d6a554fd3 | ||
|
|
cb1fcf7d2d | ||
|
|
6ed30fd78a | ||
|
|
69acb2fccd | ||
|
|
d95a4e56f5 | ||
|
|
b15dac9ef4 | ||
|
|
73fced37b4 | ||
|
|
1124090d87 | ||
|
|
a92fa1b1ba | ||
|
|
13fdfc58b6 | ||
|
|
048a9135ac | ||
|
|
b5636646c9 | ||
|
|
d9def89ced | ||
|
|
b16d0069bf | ||
|
|
30823691f9 | ||
|
|
8be98ccbb3 | ||
|
|
922e85754a | ||
|
|
f041f9a231 | ||
|
|
0d9f1882b9 | ||
|
|
26d823e0a5 | ||
|
|
daa89df479 | ||
|
|
527740bf42 | ||
|
|
881e0b4713 | ||
|
|
88e251cee7 | ||
|
|
76387643f7 | ||
|
|
b41c89972b | ||
|
|
11a8154458 |
@@ -1,9 +1,9 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
2
.git-blame-ignore-revs
Normal file
2
.git-blame-ignore-revs
Normal file
@@ -0,0 +1,2 @@
|
||||
# ignore #7923 eol change and code formatting
|
||||
4ac8a388347ff35f34de42c3ef4a2f81f03fb3b1
|
||||
1
.gitattributes
vendored
1
.gitattributes
vendored
@@ -1,2 +1,3 @@
|
||||
* text=auto eol=lf
|
||||
/.yarn/** linguist-vendored
|
||||
/.yarn/releases/* binary
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/#3_others.yml
vendored
2
.github/ISSUE_TEMPLATE/#3_others.yml
vendored
@@ -73,4 +73,4 @@ body:
|
||||
id: additional
|
||||
attributes:
|
||||
label: 附加信息
|
||||
description: 任何能让我们对您的问题有更多了解的信息,包括截图或相关链接
|
||||
description: 任何能让我们对您的问题有更多了解的信息,包括截图或相关链接
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/3_others.yml
vendored
2
.github/ISSUE_TEMPLATE/3_others.yml
vendored
@@ -73,4 +73,4 @@ body:
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional Information
|
||||
description: Any other information that could help us better understand your question, including screenshots or relevant links
|
||||
description: Any other information that could help us better understand your question, including screenshots or relevant links
|
||||
|
||||
90
.github/issue-checker.yml
vendored
90
.github/issue-checker.yml
vendored
@@ -9,115 +9,115 @@ labels:
|
||||
# skips and removes
|
||||
- name: skip all
|
||||
content:
|
||||
regexes: "[Ss]kip (?:[Aa]ll |)[Ll]abels?"
|
||||
regexes: '[Ss]kip (?:[Aa]ll |)[Ll]abels?'
|
||||
- name: remove all
|
||||
content:
|
||||
regexes: "[Rr]emove (?:[Aa]ll |)[Ll]abels?"
|
||||
regexes: '[Rr]emove (?:[Aa]ll |)[Ll]abels?'
|
||||
|
||||
- name: skip kind/bug
|
||||
content:
|
||||
regexes: "[Ss]kip (?:[Ll]abels? |)(?:`|)kind/bug(?:`|)"
|
||||
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)kind/bug(?:`|)'
|
||||
- name: remove kind/bug
|
||||
content:
|
||||
regexes: "[Rr]emove (?:[Ll]abels? |)(?:`|)kind/bug(?:`|)"
|
||||
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)kind/bug(?:`|)'
|
||||
|
||||
- name: skip kind/enhancement
|
||||
content:
|
||||
regexes: "[Ss]kip (?:[Ll]abels? |)(?:`|)kind/enhancement(?:`|)"
|
||||
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)kind/enhancement(?:`|)'
|
||||
- name: remove kind/enhancement
|
||||
content:
|
||||
regexes: "[Rr]emove (?:[Ll]abels? |)(?:`|)kind/enhancement(?:`|)"
|
||||
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)kind/enhancement(?:`|)'
|
||||
|
||||
- name: skip kind/question
|
||||
content:
|
||||
regexes: "[Ss]kip (?:[Ll]abels? |)(?:`|)kind/question(?:`|)"
|
||||
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)kind/question(?:`|)'
|
||||
- name: remove kind/question
|
||||
content:
|
||||
regexes: "[Rr]emove (?:[Ll]abels? |)(?:`|)kind/question(?:`|)"
|
||||
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)kind/question(?:`|)'
|
||||
|
||||
- name: skip area/Connectivity
|
||||
content:
|
||||
regexes: "[Ss]kip (?:[Ll]abels? |)(?:`|)area/Connectivity(?:`|)"
|
||||
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)area/Connectivity(?:`|)'
|
||||
- name: remove area/Connectivity
|
||||
content:
|
||||
regexes: "[Rr]emove (?:[Ll]abels? |)(?:`|)area/Connectivity(?:`|)"
|
||||
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)area/Connectivity(?:`|)'
|
||||
|
||||
- name: skip area/UI/UX
|
||||
content:
|
||||
regexes: "[Ss]kip (?:[Ll]abels? |)(?:`|)area/UI/UX(?:`|)"
|
||||
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)area/UI/UX(?:`|)'
|
||||
- name: remove area/UI/UX
|
||||
content:
|
||||
regexes: "[Rr]emove (?:[Ll]abels? |)(?:`|)area/UI/UX(?:`|)"
|
||||
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)area/UI/UX(?:`|)'
|
||||
|
||||
- name: skip kind/documentation
|
||||
content:
|
||||
regexes: "[Ss]kip (?:[Ll]abels? |)(?:`|)kind/documentation(?:`|)"
|
||||
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)kind/documentation(?:`|)'
|
||||
- name: remove kind/documentation
|
||||
content:
|
||||
regexes: "[Rr]emove (?:[Ll]abels? |)(?:`|)kind/documentation(?:`|)"
|
||||
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)kind/documentation(?:`|)'
|
||||
|
||||
- name: skip client:linux
|
||||
content:
|
||||
regexes: "[Ss]kip (?:[Ll]abels? |)(?:`|)client:linux(?:`|)"
|
||||
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)client:linux(?:`|)'
|
||||
- name: remove client:linux
|
||||
content:
|
||||
regexes: "[Rr]emove (?:[Ll]abels? |)(?:`|)client:linux(?:`|)"
|
||||
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)client:linux(?:`|)'
|
||||
|
||||
- name: skip client:mac
|
||||
content:
|
||||
regexes: "[Ss]kip (?:[Ll]abels? |)(?:`|)client:mac(?:`|)"
|
||||
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)client:mac(?:`|)'
|
||||
- name: remove client:mac
|
||||
content:
|
||||
regexes: "[Rr]emove (?:[Ll]abels? |)(?:`|)client:mac(?:`|)"
|
||||
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)client:mac(?:`|)'
|
||||
|
||||
- name: skip client:win
|
||||
content:
|
||||
regexes: "[Ss]kip (?:[Ll]abels? |)(?:`|)client:win(?:`|)"
|
||||
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)client:win(?:`|)'
|
||||
- name: remove client:win
|
||||
content:
|
||||
regexes: "[Rr]emove (?:[Ll]abels? |)(?:`|)client:win(?:`|)"
|
||||
|
||||
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)client:win(?:`|)'
|
||||
|
||||
- name: skip sig/Assistant
|
||||
content:
|
||||
regexes: "[Ss]kip (?:[Ll]abels? |)(?:`|)sig/Assistant(?:`|)"
|
||||
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)sig/Assistant(?:`|)'
|
||||
- name: remove sig/Assistant
|
||||
content:
|
||||
regexes: "[Rr]emove (?:[Ll]abels? |)(?:`|)sig/Assistant(?:`|)"
|
||||
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)sig/Assistant(?:`|)'
|
||||
|
||||
- name: skip sig/Data
|
||||
content:
|
||||
regexes: "[Ss]kip (?:[Ll]abels? |)(?:`|)sig/Data(?:`|)"
|
||||
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)sig/Data(?:`|)'
|
||||
- name: remove sig/Data
|
||||
content:
|
||||
regexes: "[Rr]emove (?:[Ll]abels? |)(?:`|)sig/Data(?:`|)"
|
||||
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)sig/Data(?:`|)'
|
||||
|
||||
- name: skip sig/MCP
|
||||
content:
|
||||
regexes: "[Ss]kip (?:[Ll]abels? |)(?:`|)sig/MCP(?:`|)"
|
||||
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)sig/MCP(?:`|)'
|
||||
- name: remove sig/MCP
|
||||
content:
|
||||
regexes: "[Rr]emove (?:[Ll]abels? |)(?:`|)sig/MCP(?:`|)"
|
||||
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)sig/MCP(?:`|)'
|
||||
|
||||
- name: skip sig/RAG
|
||||
content:
|
||||
regexes: "[Ss]kip (?:[Ll]abels? |)(?:`|)sig/RAG(?:`|)"
|
||||
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)sig/RAG(?:`|)'
|
||||
- name: remove sig/RAG
|
||||
content:
|
||||
regexes: "[Rr]emove (?:[Ll]abels? |)(?:`|)sig/RAG(?:`|)"
|
||||
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)sig/RAG(?:`|)'
|
||||
|
||||
- name: skip lgtm
|
||||
content:
|
||||
regexes: "[Ss]kip (?:[Ll]abels? |)(?:`|)lgtm(?:`|)"
|
||||
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)lgtm(?:`|)'
|
||||
- name: remove lgtm
|
||||
content:
|
||||
regexes: "[Rr]emove (?:[Ll]abels? |)(?:`|)lgtm(?:`|)"
|
||||
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)lgtm(?:`|)'
|
||||
|
||||
- name: skip License
|
||||
content:
|
||||
regexes: "[Ss]kip (?:[Ll]abels? |)(?:`|)License(?:`|)"
|
||||
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)License(?:`|)'
|
||||
- name: remove License
|
||||
content:
|
||||
regexes: "[Rr]emove (?:[Ll]abels? |)(?:`|)License(?:`|)"
|
||||
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)License(?:`|)'
|
||||
|
||||
# `Dev Team`
|
||||
- name: Dev Team
|
||||
@@ -129,7 +129,7 @@ labels:
|
||||
# Area labels
|
||||
- name: area/Connectivity
|
||||
content: area/Connectivity
|
||||
regexes: "代理|[Pp]roxy"
|
||||
regexes: '代理|[Pp]roxy'
|
||||
skip-if:
|
||||
- skip all
|
||||
- skip area/Connectivity
|
||||
@@ -139,7 +139,7 @@ labels:
|
||||
|
||||
- name: area/UI/UX
|
||||
content: area/UI/UX
|
||||
regexes: "界面|[Uu][Ii]|重叠|按钮|图标|组件|渲染|菜单|栏目|头像|主题|样式|[Cc][Ss][Ss]"
|
||||
regexes: '界面|[Uu][Ii]|重叠|按钮|图标|组件|渲染|菜单|栏目|头像|主题|样式|[Cc][Ss][Ss]'
|
||||
skip-if:
|
||||
- skip all
|
||||
- skip area/UI/UX
|
||||
@@ -150,7 +150,7 @@ labels:
|
||||
# Kind labels
|
||||
- name: kind/documentation
|
||||
content: kind/documentation
|
||||
regexes: "文档|教程|[Dd]oc(s|umentation)|[Rr]eadme"
|
||||
regexes: '文档|教程|[Dd]oc(s|umentation)|[Rr]eadme'
|
||||
skip-if:
|
||||
- skip all
|
||||
- skip kind/documentation
|
||||
@@ -161,7 +161,7 @@ labels:
|
||||
# Client labels
|
||||
- name: client:linux
|
||||
content: client:linux
|
||||
regexes: "(?:[Ll]inux|[Uu]buntu|[Dd]ebian)"
|
||||
regexes: '(?:[Ll]inux|[Uu]buntu|[Dd]ebian)'
|
||||
skip-if:
|
||||
- skip all
|
||||
- skip client:linux
|
||||
@@ -171,7 +171,7 @@ labels:
|
||||
|
||||
- name: client:mac
|
||||
content: client:mac
|
||||
regexes: "(?:[Mm]ac|[Mm]acOS|[Oo]SX)"
|
||||
regexes: '(?:[Mm]ac|[Mm]acOS|[Oo]SX)'
|
||||
skip-if:
|
||||
- skip all
|
||||
- skip client:mac
|
||||
@@ -181,7 +181,7 @@ labels:
|
||||
|
||||
- name: client:win
|
||||
content: client:win
|
||||
regexes: "(?:[Ww]in|[Ww]indows)"
|
||||
regexes: '(?:[Ww]in|[Ww]indows)'
|
||||
skip-if:
|
||||
- skip all
|
||||
- skip client:win
|
||||
@@ -192,7 +192,7 @@ labels:
|
||||
# SIG labels
|
||||
- name: sig/Assistant
|
||||
content: sig/Assistant
|
||||
regexes: "快捷助手|[Aa]ssistant"
|
||||
regexes: '快捷助手|[Aa]ssistant'
|
||||
skip-if:
|
||||
- skip all
|
||||
- skip sig/Assistant
|
||||
@@ -202,7 +202,7 @@ labels:
|
||||
|
||||
- name: sig/Data
|
||||
content: sig/Data
|
||||
regexes: "[Ww]ebdav|坚果云|备份|同步|数据|Obsidian|Notion|Joplin|思源"
|
||||
regexes: '[Ww]ebdav|坚果云|备份|同步|数据|Obsidian|Notion|Joplin|思源'
|
||||
skip-if:
|
||||
- skip all
|
||||
- skip sig/Data
|
||||
@@ -212,7 +212,7 @@ labels:
|
||||
|
||||
- name: sig/MCP
|
||||
content: sig/MCP
|
||||
regexes: "[Mm][Cc][Pp]"
|
||||
regexes: '[Mm][Cc][Pp]'
|
||||
skip-if:
|
||||
- skip all
|
||||
- skip sig/MCP
|
||||
@@ -222,7 +222,7 @@ labels:
|
||||
|
||||
- name: sig/RAG
|
||||
content: sig/RAG
|
||||
regexes: "知识库|[Rr][Aa][Gg]"
|
||||
regexes: '知识库|[Rr][Aa][Gg]'
|
||||
skip-if:
|
||||
- skip all
|
||||
- skip sig/RAG
|
||||
@@ -233,7 +233,7 @@ labels:
|
||||
# Other labels
|
||||
- name: lgtm
|
||||
content: lgtm
|
||||
regexes: "(?:[Ll][Gg][Tt][Mm]|[Ll]ooks [Gg]ood [Tt]o [Mm]e)"
|
||||
regexes: '(?:[Ll][Gg][Tt][Mm]|[Ll]ooks [Gg]ood [Tt]o [Mm]e)'
|
||||
skip-if:
|
||||
- skip all
|
||||
- skip lgtm
|
||||
@@ -243,7 +243,7 @@ labels:
|
||||
|
||||
- name: License
|
||||
content: License
|
||||
regexes: "(?:[Ll]icense|[Cc]opyright|[Mm][Ii][Tt]|[Aa]pache)"
|
||||
regexes: '(?:[Ll]icense|[Cc]opyright|[Mm][Ii][Tt]|[Aa]pache)'
|
||||
skip-if:
|
||||
- skip all
|
||||
- skip License
|
||||
|
||||
6
.github/workflows/issue-checker.yml
vendored
6
.github/workflows/issue-checker.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: "Issue Checker"
|
||||
name: 'Issue Checker'
|
||||
|
||||
on:
|
||||
issues:
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
steps:
|
||||
- uses: MaaAssistantArknights/issue-checker@v1.14
|
||||
with:
|
||||
repo-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
repo-token: '${{ secrets.GITHUB_TOKEN }}'
|
||||
configuration-path: .github/issue-checker.yml
|
||||
not-before: 2022-08-05T00:00:00Z
|
||||
include-title: 1
|
||||
include-title: 1
|
||||
|
||||
20
.github/workflows/issue-management.yml
vendored
20
.github/workflows/issue-management.yml
vendored
@@ -1,8 +1,8 @@
|
||||
name: "Stale Issue Management"
|
||||
name: 'Stale Issue Management'
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 0 * * *"
|
||||
- cron: '0 0 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
@@ -24,18 +24,18 @@ jobs:
|
||||
uses: actions/stale@v9
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
only-labels: "needs-more-info"
|
||||
only-labels: 'needs-more-info'
|
||||
days-before-stale: ${{ env.daysBeforeStale }}
|
||||
days-before-close: 0 # Close immediately after stale
|
||||
stale-issue-label: "inactive"
|
||||
close-issue-label: "closed:no-response"
|
||||
days-before-close: 0 # Close immediately after stale
|
||||
stale-issue-label: 'inactive'
|
||||
close-issue-label: 'closed:no-response'
|
||||
stale-issue-message: |
|
||||
This issue has been labeled as needing more information and has been inactive for ${{ env.daysBeforeStale }} days.
|
||||
It will be closed now due to lack of additional information.
|
||||
|
||||
|
||||
该问题被标记为"需要更多信息"且已经 ${{ env.daysBeforeStale }} 天没有任何活动,将立即关闭。
|
||||
operations-per-run: 50
|
||||
exempt-issue-labels: "pending, Dev Team"
|
||||
exempt-issue-labels: 'pending, Dev Team'
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
|
||||
@@ -45,11 +45,11 @@ jobs:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: ${{ env.daysBeforeStale }}
|
||||
days-before-close: ${{ env.daysBeforeClose }}
|
||||
stale-issue-label: "inactive"
|
||||
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, kind/enhancement"
|
||||
exempt-issue-labels: 'pending, Dev Team, kind/enhancement'
|
||||
days-before-pr-stale: -1 # Completely disable stalling for PRs
|
||||
days-before-pr-close: -1 # Completely disable closing for PRs
|
||||
|
||||
|
||||
11
.github/workflows/release.yml
vendored
11
.github/workflows/release.yml
vendored
@@ -77,9 +77,10 @@ jobs:
|
||||
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
|
||||
- name: Build Mac
|
||||
if: matrix.os == 'macos-latest'
|
||||
@@ -93,10 +94,11 @@ jobs:
|
||||
APPLE_ID: ${{ vars.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ vars.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
|
||||
- name: Build Windows
|
||||
if: matrix.os == 'windows-latest'
|
||||
@@ -105,9 +107,10 @@ jobs:
|
||||
yarn build:win
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
|
||||
- name: Release
|
||||
uses: ncipollo/release-action@v1
|
||||
@@ -117,4 +120,4 @@ jobs:
|
||||
makeLatest: false
|
||||
tag: ${{ steps.get-tag.outputs.tag }}
|
||||
artifacts: 'dist/*.exe,dist/*.zip,dist/*.dmg,dist/*.AppImage,dist/*.snap,dist/*.deb,dist/*.rpm,dist/*.tar.gz,dist/latest*.yml,dist/rc*.yml,dist/*.blockmap'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
2
.vscode/extensions.json
vendored
2
.vscode/extensions.json
vendored
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"recommendations": ["dbaeumer.vscode-eslint"]
|
||||
"recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode", "editorconfig.editorconfig"]
|
||||
}
|
||||
|
||||
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@@ -4,6 +4,7 @@
|
||||
"source.fixAll.eslint": "explicit",
|
||||
"source.organizeImports": "never"
|
||||
},
|
||||
"files.eol": "\n",
|
||||
"search.exclude": {
|
||||
"**/dist/**": true,
|
||||
".yarn/releases/**": true
|
||||
|
||||
12788
.yarn/patches/@google-genai-npm-1.0.1-e26f0f9af7.patch
vendored
12788
.yarn/patches/@google-genai-npm-1.0.1-e26f0f9af7.patch
vendored
File diff suppressed because it is too large
Load Diff
11
docs/technical/db.settings.md
Normal file
11
docs/technical/db.settings.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# 数据库设置字段
|
||||
|
||||
此文档包含部分字段的数据类型说明。
|
||||
|
||||
## 字段
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
| ------------------------------ | ------------------------------ | ------------ |
|
||||
| `translate:target:language` | `LanguageCode` | 翻译目标语言 |
|
||||
| `translate:source:language` | `LanguageCode` | 翻译源语言 |
|
||||
| `translate:bidirectional:pair` | `[LanguageCode, LanguageCode]` | 双向翻译对 |
|
||||
@@ -117,9 +117,8 @@ afterSign: scripts/notarize.js
|
||||
artifactBuildCompleted: scripts/artifact-build-completed.js
|
||||
releaseInfo:
|
||||
releaseNotes: |
|
||||
划词助手:支持 macOS 系统
|
||||
文档处理:增加 MinerU、Doc2x,Mistral 等服务商支持
|
||||
知识库:新的知识库界面,增加扫描版 PDF 支持
|
||||
OCR:macOS 增加系统 OCR 支持
|
||||
服务商:支持一键添加服务商,新增 PH8 大模型开放平台, 支持 PPIO OAuth 登录
|
||||
修复:Linux下数据目录移动问题
|
||||
• [新增] MCP 工具调用自动审批流程
|
||||
• [优化] 输入框快捷弹窗多选交互支持
|
||||
• [新增] 网页内容生成实时预览功能
|
||||
• [支持] Grok-4 大语言模型接入
|
||||
• [修复] Anthropic 模型输出截断缺陷
|
||||
|
||||
@@ -26,7 +26,7 @@ export default defineConfig([
|
||||
'simple-import-sort/exports': 'error',
|
||||
'unused-imports/no-unused-imports': 'error',
|
||||
'@eslint-react/no-prop-types': 'error',
|
||||
'prettier/prettier': ['error', { endOfLine: 'auto' }]
|
||||
'prettier/prettier': ['error']
|
||||
}
|
||||
},
|
||||
// Configuration for ensuring compatibility with the original ESLint(8.x) rules
|
||||
|
||||
32
package.json
32
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "CherryStudio",
|
||||
"version": "1.4.8",
|
||||
"version": "1.4.10",
|
||||
"private": true,
|
||||
"description": "A powerful AI assistant for producer.",
|
||||
"main": "./out/main/index.js",
|
||||
@@ -27,12 +27,12 @@
|
||||
"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 --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 --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:mac": "dotenv npm run build && electron-builder --mac --arm64 --x64",
|
||||
"build:mac:arm64": "dotenv npm run build && electron-builder --mac --arm64",
|
||||
"build:mac:x64": "dotenv npm run build && electron-builder --mac --x64",
|
||||
"build:linux": "dotenv npm run build && electron-builder --linux --x64 --arm64",
|
||||
"build:linux:arm64": "dotenv npm run build && electron-builder --linux --arm64",
|
||||
"build:linux:x64": "dotenv npm run build && electron-builder --linux --x64",
|
||||
"build:npm": "node scripts/build-npm.js",
|
||||
"release": "node scripts/version.js",
|
||||
"publish": "yarn build:check && yarn release patch push",
|
||||
@@ -55,20 +55,23 @@
|
||||
"test:lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts",
|
||||
"format": "prettier --write .",
|
||||
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
|
||||
"prepare": "husky"
|
||||
"prepare": "git config blame.ignoreRevsFile .git-blame-ignore-revs && husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.840.0",
|
||||
"@cherrystudio/pdf-to-img-napi": "^0.0.1",
|
||||
"@libsql/client": "0.14.0",
|
||||
"@libsql/win32-x64-msvc": "^0.4.7",
|
||||
"@strongtz/win32-arm64-msvc": "^0.4.7",
|
||||
"iconv-lite": "^0.6.3",
|
||||
"jschardet": "^3.1.4",
|
||||
"jsdom": "26.1.0",
|
||||
"macos-release": "^3.4.0",
|
||||
"node-stream-zip": "^1.15.0",
|
||||
"notion-helper": "^1.3.22",
|
||||
"os-proxy-config": "^1.1.2",
|
||||
"pdfjs-dist": "4.10.38",
|
||||
"selection-hook": "^1.0.4",
|
||||
"selection-hook": "^1.0.6",
|
||||
"turndown": "7.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -89,6 +92,7 @@
|
||||
"@cherrystudio/embedjs-loader-xml": "^0.1.31",
|
||||
"@cherrystudio/embedjs-ollama": "^0.1.31",
|
||||
"@cherrystudio/embedjs-openai": "^0.1.31",
|
||||
"@codemirror/view": "^6.0.0",
|
||||
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
|
||||
"@electron-toolkit/eslint-config-ts": "^3.0.0",
|
||||
"@electron-toolkit/preload": "^3.0.0",
|
||||
@@ -104,7 +108,7 @@
|
||||
"@langchain/community": "^0.3.36",
|
||||
"@langchain/ollama": "^0.2.1",
|
||||
"@mistralai/mistralai": "^1.6.0",
|
||||
"@modelcontextprotocol/sdk": "^1.11.4",
|
||||
"@modelcontextprotocol/sdk": "^1.12.3",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@notionhq/client": "^2.2.15",
|
||||
"@playwright/test": "^1.52.0",
|
||||
@@ -138,6 +142,8 @@
|
||||
"@vitest/coverage-v8": "^3.1.4",
|
||||
"@vitest/ui": "^3.1.4",
|
||||
"@vitest/web-worker": "^3.1.4",
|
||||
"@viz-js/lang-dot": "^1.0.5",
|
||||
"@viz-js/viz": "^3.14.0",
|
||||
"@xyflow/react": "^12.4.4",
|
||||
"antd": "patch:antd@npm%3A5.24.7#~/.yarn/patches/antd-npm-5.24.7-356a553ae5.patch",
|
||||
"archiver": "^7.0.1",
|
||||
@@ -170,6 +176,7 @@
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
"fast-diff": "^1.3.0",
|
||||
"fast-xml-parser": "^5.2.0",
|
||||
"framer-motion": "^12.17.3",
|
||||
"franc-min": "^6.2.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"google-auth-library": "^9.15.1",
|
||||
@@ -200,8 +207,8 @@
|
||||
"react-infinite-scroll-component": "^6.1.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-redux": "^9.1.2",
|
||||
"react-router": "6",
|
||||
"react-router-dom": "6",
|
||||
"react-router": "^7.6.2",
|
||||
"react-router-dom": "^7.6.2",
|
||||
"react-spinners": "^0.14.1",
|
||||
"react-window": "^1.8.11",
|
||||
"redux": "^5.0.1",
|
||||
@@ -222,6 +229,7 @@
|
||||
"tiny-pinyin": "^1.3.2",
|
||||
"tokenx": "^1.1.0",
|
||||
"typescript": "^5.6.2",
|
||||
"unified": "^11.0.5",
|
||||
"uuid": "^10.0.0",
|
||||
"vite": "6.2.6",
|
||||
"vitest": "^3.1.4",
|
||||
|
||||
@@ -74,6 +74,8 @@ export enum IpcChannel {
|
||||
Mcp_ServersChanged = 'mcp:servers-changed',
|
||||
Mcp_ServersUpdated = 'mcp:servers-updated',
|
||||
Mcp_CheckConnectivity = 'mcp:check-connectivity',
|
||||
Mcp_SetProgress = 'mcp:set-progress',
|
||||
Mcp_AbortTool = 'mcp:abort-tool',
|
||||
|
||||
// Python
|
||||
Python_Execute = 'python:execute',
|
||||
@@ -145,6 +147,7 @@ export enum IpcChannel {
|
||||
File_Base64File = 'file:base64File',
|
||||
File_GetPdfInfo = 'file:getPdfInfo',
|
||||
Fs_Read = 'fs:read',
|
||||
File_OpenWithRelativePath = 'file:openWithRelativePath',
|
||||
|
||||
// file service
|
||||
FileService_Upload = 'file-service:upload',
|
||||
@@ -165,6 +168,16 @@ export enum IpcChannel {
|
||||
Backup_CheckConnection = 'backup:checkConnection',
|
||||
Backup_CreateDirectory = 'backup:createDirectory',
|
||||
Backup_DeleteWebdavFile = 'backup:deleteWebdavFile',
|
||||
Backup_BackupToLocalDir = 'backup:backupToLocalDir',
|
||||
Backup_RestoreFromLocalBackup = 'backup:restoreFromLocalBackup',
|
||||
Backup_ListLocalBackupFiles = 'backup:listLocalBackupFiles',
|
||||
Backup_DeleteLocalBackupFile = 'backup:deleteLocalBackupFile',
|
||||
Backup_SetLocalBackupDir = 'backup:setLocalBackupDir',
|
||||
Backup_BackupToS3 = 'backup:backupToS3',
|
||||
Backup_RestoreFromS3 = 'backup:restoreFromS3',
|
||||
Backup_ListS3Files = 'backup:listS3Files',
|
||||
Backup_DeleteS3File = 'backup:deleteS3File',
|
||||
Backup_CheckS3Connection = 'backup:checkS3Connection',
|
||||
|
||||
// zip
|
||||
Zip_Compress = 'zip:compress',
|
||||
@@ -229,5 +242,12 @@ export enum IpcChannel {
|
||||
Selection_ActionWindowMinimize = 'selection:action-window-minimize',
|
||||
Selection_ActionWindowPin = 'selection:action-window-pin',
|
||||
Selection_ProcessAction = 'selection:process-action',
|
||||
Selection_UpdateActionData = 'selection:update-action-data'
|
||||
Selection_UpdateActionData = 'selection:update-action-data',
|
||||
|
||||
// Navigation
|
||||
Navigation_Url = 'navigation:url',
|
||||
Navigation_Close = 'navigation:close',
|
||||
|
||||
// Settings Window
|
||||
SettingsWindow_Show = 'settings-window:show'
|
||||
}
|
||||
|
||||
@@ -10,13 +10,13 @@ if (isDev) {
|
||||
export const DATA_PATH = getDataPath()
|
||||
|
||||
export const titleBarOverlayDark = {
|
||||
height: 40,
|
||||
height: 42,
|
||||
color: 'rgba(255,255,255,0)',
|
||||
symbolColor: '#fff'
|
||||
}
|
||||
|
||||
export const titleBarOverlayLight = {
|
||||
height: 40,
|
||||
height: 42,
|
||||
color: 'rgba(255,255,255,0)',
|
||||
symbolColor: '#000'
|
||||
}
|
||||
|
||||
@@ -12,8 +12,10 @@ import { BrowserWindow, dialog, ipcMain, session, shell, systemPreferences, webC
|
||||
import log from 'electron-log'
|
||||
import { Notification } from 'src/renderer/src/types/notification'
|
||||
|
||||
import appService from './services/AppService'
|
||||
import AppUpdater from './services/AppUpdater'
|
||||
import BackupManager from './services/BackupManager'
|
||||
import { CacheService } from './services/CacheService'
|
||||
import { configManager } from './services/ConfigManager'
|
||||
import CopilotService from './services/CopilotService'
|
||||
import { ExportService } from './services/ExportService'
|
||||
@@ -29,6 +31,7 @@ import { pythonService } from './services/PythonService'
|
||||
import { FileServiceManager } from './services/remotefile/FileServiceManager'
|
||||
import { searchService } from './services/SearchService'
|
||||
import { SelectionService } from './services/SelectionService'
|
||||
import { SettingsWindowService } from './services/SettingsWindowService'
|
||||
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
|
||||
import storeSyncService from './services/StoreSyncService'
|
||||
import { themeService } from './services/ThemeService'
|
||||
@@ -114,12 +117,8 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
})
|
||||
|
||||
// launch on boot
|
||||
ipcMain.handle(IpcChannel.App_SetLaunchOnBoot, (_, openAtLogin: boolean) => {
|
||||
// Set login item settings for windows and mac
|
||||
// linux is not supported because it requires more file operations
|
||||
if (isWin || isMac) {
|
||||
app.setLoginItemSettings({ openAtLogin })
|
||||
}
|
||||
ipcMain.handle(IpcChannel.App_SetLaunchOnBoot, (_, isLaunchOnBoot: boolean) => {
|
||||
appService.setAppLaunchOnBoot(isLaunchOnBoot)
|
||||
})
|
||||
|
||||
// launch to tray
|
||||
@@ -368,6 +367,16 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle(IpcChannel.Backup_CheckConnection, backupManager.checkConnection)
|
||||
ipcMain.handle(IpcChannel.Backup_CreateDirectory, backupManager.createDirectory)
|
||||
ipcMain.handle(IpcChannel.Backup_DeleteWebdavFile, backupManager.deleteWebdavFile)
|
||||
ipcMain.handle(IpcChannel.Backup_BackupToLocalDir, backupManager.backupToLocalDir)
|
||||
ipcMain.handle(IpcChannel.Backup_RestoreFromLocalBackup, backupManager.restoreFromLocalBackup)
|
||||
ipcMain.handle(IpcChannel.Backup_ListLocalBackupFiles, backupManager.listLocalBackupFiles)
|
||||
ipcMain.handle(IpcChannel.Backup_DeleteLocalBackupFile, backupManager.deleteLocalBackupFile)
|
||||
ipcMain.handle(IpcChannel.Backup_SetLocalBackupDir, backupManager.setLocalBackupDir)
|
||||
ipcMain.handle(IpcChannel.Backup_BackupToS3, backupManager.backupToS3)
|
||||
ipcMain.handle(IpcChannel.Backup_RestoreFromS3, backupManager.restoreFromS3)
|
||||
ipcMain.handle(IpcChannel.Backup_ListS3Files, backupManager.listS3Files)
|
||||
ipcMain.handle(IpcChannel.Backup_DeleteS3File, backupManager.deleteS3File)
|
||||
ipcMain.handle(IpcChannel.Backup_CheckS3Connection, backupManager.checkS3Connection)
|
||||
|
||||
// file
|
||||
ipcMain.handle(IpcChannel.File_Open, fileManager.open)
|
||||
@@ -392,6 +401,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle(IpcChannel.File_Download, fileManager.downloadFile)
|
||||
ipcMain.handle(IpcChannel.File_Copy, fileManager.copyFile)
|
||||
ipcMain.handle(IpcChannel.File_BinaryImage, fileManager.binaryImage)
|
||||
ipcMain.handle(IpcChannel.File_OpenWithRelativePath, fileManager.openFileWithRelativePath)
|
||||
|
||||
// file service
|
||||
ipcMain.handle(IpcChannel.FileService_Upload, async (_, provider: Provider, file: FileMetadata) => {
|
||||
@@ -494,6 +504,10 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle(IpcChannel.Mcp_GetResource, mcpService.getResource)
|
||||
ipcMain.handle(IpcChannel.Mcp_GetInstallInfo, mcpService.getInstallInfo)
|
||||
ipcMain.handle(IpcChannel.Mcp_CheckConnectivity, mcpService.checkMcpConnectivity)
|
||||
ipcMain.handle(IpcChannel.Mcp_AbortTool, mcpService.abortTool)
|
||||
ipcMain.handle(IpcChannel.Mcp_SetProgress, (_, progress: number) => {
|
||||
mainWindow.webContents.send('mcp-progress', progress)
|
||||
})
|
||||
|
||||
// Register Python execution handler
|
||||
ipcMain.handle(
|
||||
@@ -565,4 +579,12 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle(IpcChannel.App_SetDisableHardwareAcceleration, (_, isDisable: boolean) => {
|
||||
configManager.setDisableHardwareAcceleration(isDisable)
|
||||
})
|
||||
|
||||
// Navigation
|
||||
ipcMain.handle(IpcChannel.Navigation_Url, (_, url: string) => {
|
||||
CacheService.set('navigation-url', url)
|
||||
})
|
||||
|
||||
// Settings Window
|
||||
SettingsWindowService.registerIpcHandler()
|
||||
}
|
||||
|
||||
@@ -5,26 +5,19 @@ import { AzureOpenAiEmbeddings } from '@cherrystudio/embedjs-openai/src/azure-op
|
||||
import { getInstanceName } from '@main/utils'
|
||||
import { KnowledgeBaseParams } from '@types'
|
||||
|
||||
import { SUPPORTED_DIM_MODELS as VOYAGE_SUPPORTED_DIM_MODELS, VoyageEmbeddings } from './VoyageEmbeddings'
|
||||
import { VOYAGE_SUPPORTED_DIM_MODELS } from './utils'
|
||||
import { VoyageEmbeddings } from './VoyageEmbeddings'
|
||||
|
||||
export default class EmbeddingsFactory {
|
||||
static create({ model, provider, apiKey, apiVersion, baseURL, dimensions }: KnowledgeBaseParams): BaseEmbeddings {
|
||||
const batchSize = 10
|
||||
if (provider === 'voyageai') {
|
||||
if (VOYAGE_SUPPORTED_DIM_MODELS.includes(model)) {
|
||||
return new VoyageEmbeddings({
|
||||
modelName: model,
|
||||
apiKey,
|
||||
outputDimension: dimensions,
|
||||
batchSize: 8
|
||||
})
|
||||
} else {
|
||||
return new VoyageEmbeddings({
|
||||
modelName: model,
|
||||
apiKey,
|
||||
batchSize: 8
|
||||
})
|
||||
}
|
||||
return new VoyageEmbeddings({
|
||||
modelName: model,
|
||||
apiKey,
|
||||
outputDimension: VOYAGE_SUPPORTED_DIM_MODELS.includes(model) ? dimensions : undefined,
|
||||
batchSize: 8
|
||||
})
|
||||
}
|
||||
if (provider === 'ollama') {
|
||||
if (baseURL.includes('v1/')) {
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
import { BaseEmbeddings } from '@cherrystudio/embedjs-interfaces'
|
||||
import { VoyageEmbeddings as _VoyageEmbeddings } from '@langchain/community/embeddings/voyage'
|
||||
|
||||
import { VOYAGE_SUPPORTED_DIM_MODELS } from './utils'
|
||||
|
||||
/**
|
||||
* 支持设置嵌入维度的模型
|
||||
*/
|
||||
export const SUPPORTED_DIM_MODELS = ['voyage-3-large', 'voyage-3.5', 'voyage-3.5-lite', 'voyage-code-3']
|
||||
export class VoyageEmbeddings extends BaseEmbeddings {
|
||||
private model: _VoyageEmbeddings
|
||||
constructor(private readonly configuration?: ConstructorParameters<typeof _VoyageEmbeddings>[0]) {
|
||||
super()
|
||||
if (!this.configuration) this.configuration = {}
|
||||
if (!this.configuration.modelName) this.configuration.modelName = 'voyage-3'
|
||||
if (!SUPPORTED_DIM_MODELS.includes(this.configuration.modelName) && this.configuration.outputDimension) {
|
||||
throw new Error(`VoyageEmbeddings only supports ${SUPPORTED_DIM_MODELS.join(', ')}`)
|
||||
if (!this.configuration) {
|
||||
throw new Error('Pass in a configuration.')
|
||||
}
|
||||
if (!this.configuration.modelName) this.configuration.modelName = 'voyage-3'
|
||||
|
||||
this.model = new _VoyageEmbeddings(this.configuration)
|
||||
if (!VOYAGE_SUPPORTED_DIM_MODELS.includes(this.configuration.modelName) && this.configuration.outputDimension) {
|
||||
console.error(`VoyageEmbeddings only supports ${VOYAGE_SUPPORTED_DIM_MODELS.join(', ')} to set outputDimension.`)
|
||||
this.model = new _VoyageEmbeddings({ ...this.configuration, outputDimension: undefined })
|
||||
} else {
|
||||
this.model = new _VoyageEmbeddings(this.configuration)
|
||||
}
|
||||
}
|
||||
override async getDimensions(): Promise<number> {
|
||||
if (!this.configuration?.outputDimension) {
|
||||
throw new Error('You need to pass in the optional dimensions parameter for this model')
|
||||
}
|
||||
return this.configuration?.outputDimension
|
||||
return this.configuration?.outputDimension ?? (this.configuration?.modelName === 'voyage-code-2' ? 1536 : 1024)
|
||||
}
|
||||
|
||||
override async embedDocuments(texts: string[]): Promise<number[][]> {
|
||||
|
||||
45
src/main/knowledage/embeddings/utils.ts
Normal file
45
src/main/knowledage/embeddings/utils.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
export const VOYAGE_SUPPORTED_DIM_MODELS = ['voyage-3-large', 'voyage-3.5', 'voyage-3.5-lite', 'voyage-code-3']
|
||||
|
||||
// NOTE: 下面的暂时没用上,但先留着吧
|
||||
export const OPENAI_SUPPORTED_DIM_MODELS = ['text-embedding-3-small', 'text-embedding-3-large']
|
||||
|
||||
export const DASHSCOPE_SUPPORTED_DIM_MODELS = ['text-embedding-v3', 'text-embedding-v4']
|
||||
|
||||
export const OPENSOURCE_SUPPORTED_DIM_MODELS = ['qwen3-embedding-0.6B', 'qwen3-embedding-4B', 'qwen3-embedding-8B']
|
||||
|
||||
export const GOOGLE_SUPPORTED_DIM_MODELS = ['gemini-embedding-exp-03-07', 'gemini-embedding-exp']
|
||||
|
||||
export const SUPPORTED_DIM_MODELS = [
|
||||
...VOYAGE_SUPPORTED_DIM_MODELS,
|
||||
...OPENAI_SUPPORTED_DIM_MODELS,
|
||||
...DASHSCOPE_SUPPORTED_DIM_MODELS,
|
||||
...OPENSOURCE_SUPPORTED_DIM_MODELS,
|
||||
...GOOGLE_SUPPORTED_DIM_MODELS
|
||||
]
|
||||
|
||||
/**
|
||||
* 从模型 ID 中提取基础名称。
|
||||
* 例如:
|
||||
* - 'deepseek/deepseek-r1' => 'deepseek-r1'
|
||||
* - 'deepseek-ai/deepseek/deepseek-r1' => 'deepseek-r1'
|
||||
* @param {string} id 模型 ID
|
||||
* @param {string} [delimiter='/'] 分隔符,默认为 '/'
|
||||
* @returns {string} 基础名称
|
||||
*/
|
||||
export const getBaseModelName = (id: string, delimiter: string = '/'): string => {
|
||||
const parts = id.split(delimiter)
|
||||
return parts[parts.length - 1]
|
||||
}
|
||||
|
||||
/**
|
||||
* 从模型 ID 中提取基础名称并转换为小写。
|
||||
* 例如:
|
||||
* - 'deepseek/DeepSeek-R1' => 'deepseek-r1'
|
||||
* - 'deepseek-ai/deepseek/DeepSeek-R1' => 'deepseek-r1'
|
||||
* @param {string} id 模型 ID
|
||||
* @param {string} [delimiter='/'] 分隔符,默认为 '/'
|
||||
* @returns {string} 小写的基础名称
|
||||
*/
|
||||
export const getLowerBaseModelName = (id: string, delimiter: string = '/'): string => {
|
||||
return getBaseModelName(id, delimiter).toLowerCase()
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import * as fs from 'node:fs'
|
||||
|
||||
import { JsonLoader, LocalPathLoader, RAGApplication, TextLoader } from '@cherrystudio/embedjs'
|
||||
import type { AddLoaderReturn } from '@cherrystudio/embedjs-interfaces'
|
||||
import { WebLoader } from '@cherrystudio/embedjs-loader-web'
|
||||
import { readTextFileWithAutoEncoding } from '@main/utils/file'
|
||||
import { LoaderReturn } from '@shared/config/types'
|
||||
import { FileMetadata, KnowledgeBaseParams } from '@types'
|
||||
import Logger from 'electron-log'
|
||||
@@ -115,7 +114,7 @@ export async function addFileLoader(
|
||||
// HTML类型处理
|
||||
loaderReturn = await ragApplication.addLoader(
|
||||
new WebLoader({
|
||||
urlOrContent: fs.readFileSync(file.path, 'utf-8'),
|
||||
urlOrContent: await readTextFileWithAutoEncoding(file.path),
|
||||
chunkSize: base.chunkSize,
|
||||
chunkOverlap: base.chunkOverlap
|
||||
}) as any,
|
||||
@@ -125,7 +124,7 @@ export async function addFileLoader(
|
||||
|
||||
case 'json':
|
||||
try {
|
||||
jsonObject = JSON.parse(fs.readFileSync(file.path, 'utf-8'))
|
||||
jsonObject = JSON.parse(await readTextFileWithAutoEncoding(file.path))
|
||||
} catch (error) {
|
||||
jsonParsed = false
|
||||
Logger.warn('[KnowledgeBase] failed parsing json file, falling back to text processing:', file.path, error)
|
||||
@@ -141,7 +140,7 @@ export async function addFileLoader(
|
||||
// 如果是其他文本类型且尚未读取文件,则读取文件
|
||||
loaderReturn = await ragApplication.addLoader(
|
||||
new TextLoader({
|
||||
text: fs.readFileSync(file.path, 'utf-8'),
|
||||
text: await readTextFileWithAutoEncoding(file.path),
|
||||
chunkSize: base.chunkSize,
|
||||
chunkOverlap: base.chunkOverlap
|
||||
}) as any,
|
||||
|
||||
@@ -217,7 +217,7 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider {
|
||||
* @param filePath 文件路径
|
||||
*/
|
||||
private async convertFile(uid: string, filePath: string): Promise<void> {
|
||||
const fileName = path.basename(filePath).split('.')[0]
|
||||
const fileName = path.parse(filePath).name
|
||||
const config = {
|
||||
...this.createAuthConfig(),
|
||||
headers: {
|
||||
81
src/main/services/AppService.ts
Normal file
81
src/main/services/AppService.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { isDev, isLinux, isMac, isWin } from '@main/constant'
|
||||
import { app } from 'electron'
|
||||
import log from 'electron-log'
|
||||
import fs from 'fs'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
|
||||
export class AppService {
|
||||
private static instance: AppService
|
||||
|
||||
private constructor() {
|
||||
// Private constructor to prevent direct instantiation
|
||||
}
|
||||
|
||||
public static getInstance(): AppService {
|
||||
if (!AppService.instance) {
|
||||
AppService.instance = new AppService()
|
||||
}
|
||||
return AppService.instance
|
||||
}
|
||||
|
||||
public async setAppLaunchOnBoot(isLaunchOnBoot: boolean): Promise<void> {
|
||||
// Set login item settings for windows and mac
|
||||
// linux is not supported because it requires more file operations
|
||||
if (isWin || isMac) {
|
||||
app.setLoginItemSettings({ openAtLogin: isLaunchOnBoot })
|
||||
} else if (isLinux) {
|
||||
try {
|
||||
const autostartDir = path.join(os.homedir(), '.config', 'autostart')
|
||||
const desktopFile = path.join(autostartDir, isDev ? 'cherry-studio-dev.desktop' : 'cherry-studio.desktop')
|
||||
|
||||
if (isLaunchOnBoot) {
|
||||
// Ensure autostart directory exists
|
||||
try {
|
||||
await fs.promises.access(autostartDir)
|
||||
} catch {
|
||||
await fs.promises.mkdir(autostartDir, { recursive: true })
|
||||
}
|
||||
|
||||
// Get executable path
|
||||
let executablePath = app.getPath('exe')
|
||||
if (process.env.APPIMAGE) {
|
||||
// For AppImage packaged apps, use APPIMAGE environment variable
|
||||
executablePath = process.env.APPIMAGE
|
||||
}
|
||||
|
||||
// Create desktop file content
|
||||
const desktopContent = `[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Cherry Studio
|
||||
Comment=A powerful AI assistant for producer.
|
||||
Exec=${executablePath}
|
||||
Icon=cherrystudio
|
||||
Terminal=false
|
||||
StartupNotify=false
|
||||
Categories=Development;Utility;
|
||||
X-GNOME-Autostart-enabled=true
|
||||
Hidden=false`
|
||||
|
||||
// Write desktop file
|
||||
await fs.promises.writeFile(desktopFile, desktopContent)
|
||||
log.info('Created autostart desktop file for Linux')
|
||||
} else {
|
||||
// Remove desktop file
|
||||
try {
|
||||
await fs.promises.access(desktopFile)
|
||||
await fs.promises.unlink(desktopFile)
|
||||
log.info('Removed autostart desktop file for Linux')
|
||||
} catch {
|
||||
// File doesn't exist, no need to remove
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Failed to set launch on boot for Linux:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default export as singleton instance
|
||||
export default AppService.getInstance()
|
||||
@@ -1,5 +1,6 @@
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { WebDavConfig } from '@types'
|
||||
import { S3Config } from '@types'
|
||||
import archiver from 'archiver'
|
||||
import { exec } from 'child_process'
|
||||
import { app } from 'electron'
|
||||
@@ -10,6 +11,7 @@ import * as path from 'path'
|
||||
import { CreateDirectoryOptions, FileStat } from 'webdav'
|
||||
|
||||
import { getDataPath } from '../utils'
|
||||
import S3Storage from './S3Storage'
|
||||
import WebDav from './WebDav'
|
||||
import { windowService } from './WindowService'
|
||||
|
||||
@@ -25,6 +27,16 @@ class BackupManager {
|
||||
this.restoreFromWebdav = this.restoreFromWebdav.bind(this)
|
||||
this.listWebdavFiles = this.listWebdavFiles.bind(this)
|
||||
this.deleteWebdavFile = this.deleteWebdavFile.bind(this)
|
||||
this.listLocalBackupFiles = this.listLocalBackupFiles.bind(this)
|
||||
this.deleteLocalBackupFile = this.deleteLocalBackupFile.bind(this)
|
||||
this.backupToLocalDir = this.backupToLocalDir.bind(this)
|
||||
this.restoreFromLocalBackup = this.restoreFromLocalBackup.bind(this)
|
||||
this.setLocalBackupDir = this.setLocalBackupDir.bind(this)
|
||||
this.backupToS3 = this.backupToS3.bind(this)
|
||||
this.restoreFromS3 = this.restoreFromS3.bind(this)
|
||||
this.listS3Files = this.listS3Files.bind(this)
|
||||
this.deleteS3File = this.deleteS3File.bind(this)
|
||||
this.checkS3Connection = this.checkS3Connection.bind(this)
|
||||
}
|
||||
|
||||
private async setWritableRecursive(dirPath: string): Promise<void> {
|
||||
@@ -85,7 +97,11 @@ class BackupManager {
|
||||
|
||||
const onProgress = (processData: { stage: string; progress: number; total: number }) => {
|
||||
mainWindow?.webContents.send(IpcChannel.BackupProgress, processData)
|
||||
Logger.log('[BackupManager] backup progress', processData)
|
||||
// 只在关键阶段记录日志:开始、结束和主要阶段转换点
|
||||
const logStages = ['preparing', 'writing_data', 'preparing_compression', 'completed']
|
||||
if (logStages.includes(processData.stage) || processData.progress === 100) {
|
||||
Logger.log('[BackupManager] backup progress', processData)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -147,18 +163,23 @@ class BackupManager {
|
||||
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
|
||||
try {
|
||||
const items = await fs.readdir(dirPath, { withFileTypes: true })
|
||||
for (const item of items) {
|
||||
const fullPath = path.join(dirPath, item.name)
|
||||
if (item.isDirectory()) {
|
||||
await calculateTotals(fullPath)
|
||||
} else {
|
||||
totalEntries++
|
||||
const stats = await fs.stat(fullPath)
|
||||
totalBytes += stats.size
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// 仅在出错时记录日志
|
||||
Logger.error('[BackupManager] Error calculating totals:', error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,7 +251,11 @@ class BackupManager {
|
||||
|
||||
const onProgress = (processData: { stage: string; progress: number; total: number }) => {
|
||||
mainWindow?.webContents.send(IpcChannel.RestoreProgress, processData)
|
||||
Logger.log('[BackupManager] restore progress', processData)
|
||||
// 只在关键阶段记录日志
|
||||
const logStages = ['preparing', 'extracting', 'extracted', 'reading_data', 'completed']
|
||||
if (logStages.includes(processData.stage) || processData.progress === 100) {
|
||||
Logger.log('[BackupManager] restore progress', processData)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -382,21 +407,54 @@ class BackupManager {
|
||||
destination: string,
|
||||
onProgress: (size: number) => void
|
||||
): Promise<void> {
|
||||
const items = await fs.readdir(source, { withFileTypes: true })
|
||||
// 先统计总文件数
|
||||
let totalFiles = 0
|
||||
let processedFiles = 0
|
||||
let lastProgressReported = 0
|
||||
|
||||
for (const item of items) {
|
||||
const sourcePath = path.join(source, item.name)
|
||||
const destPath = path.join(destination, item.name)
|
||||
// 计算总文件数
|
||||
const countFiles = async (dir: string): Promise<number> => {
|
||||
let count = 0
|
||||
const items = await fs.readdir(dir, { withFileTypes: true })
|
||||
for (const item of items) {
|
||||
if (item.isDirectory()) {
|
||||
count += await countFiles(path.join(dir, item.name))
|
||||
} else {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
if (item.isDirectory()) {
|
||||
await fs.ensureDir(destPath)
|
||||
await this.copyDirWithProgress(sourcePath, destPath, onProgress)
|
||||
} else {
|
||||
const stats = await fs.stat(sourcePath)
|
||||
await fs.copy(sourcePath, destPath)
|
||||
onProgress(stats.size)
|
||||
totalFiles = await countFiles(source)
|
||||
|
||||
// 复制文件并更新进度
|
||||
const copyDir = async (src: string, dest: string): Promise<void> => {
|
||||
const items = await fs.readdir(src, { withFileTypes: true })
|
||||
|
||||
for (const item of items) {
|
||||
const sourcePath = path.join(src, item.name)
|
||||
const destPath = path.join(dest, item.name)
|
||||
|
||||
if (item.isDirectory()) {
|
||||
await fs.ensureDir(destPath)
|
||||
await copyDir(sourcePath, destPath)
|
||||
} else {
|
||||
const stats = await fs.stat(sourcePath)
|
||||
await fs.copy(sourcePath, destPath)
|
||||
processedFiles++
|
||||
|
||||
// 只在进度变化超过5%时报告进度
|
||||
const currentProgress = Math.floor((processedFiles / totalFiles) * 100)
|
||||
if (currentProgress - lastProgressReported >= 5 || processedFiles === totalFiles) {
|
||||
lastProgressReported = currentProgress
|
||||
onProgress(stats.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await copyDir(source, destination)
|
||||
}
|
||||
|
||||
async checkConnection(_: Electron.IpcMainInvokeEvent, webdavConfig: WebDavConfig) {
|
||||
@@ -423,6 +481,191 @@ class BackupManager {
|
||||
throw new Error(error.message || 'Failed to delete backup file')
|
||||
}
|
||||
}
|
||||
|
||||
async backupToLocalDir(
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
data: string,
|
||||
fileName: string,
|
||||
localConfig: {
|
||||
localBackupDir: string
|
||||
skipBackupFile: boolean
|
||||
}
|
||||
) {
|
||||
try {
|
||||
const backupDir = localConfig.localBackupDir
|
||||
// Create backup directory if it doesn't exist
|
||||
await fs.ensureDir(backupDir)
|
||||
|
||||
const backupedFilePath = await this.backup(_, fileName, data, backupDir, localConfig.skipBackupFile)
|
||||
return backupedFilePath
|
||||
} catch (error) {
|
||||
Logger.error('[BackupManager] Local backup failed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async backupToS3(_: Electron.IpcMainInvokeEvent, data: string, s3Config: S3Config) {
|
||||
const os = require('os')
|
||||
const deviceName = os.hostname ? os.hostname() : 'device'
|
||||
const timestamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/[-:T.Z]/g, '')
|
||||
.slice(0, 14)
|
||||
const filename = s3Config.fileName || `cherry-studio.backup.${deviceName}.${timestamp}.zip`
|
||||
|
||||
Logger.log(`[BackupManager] Starting S3 backup to ${filename}`)
|
||||
|
||||
const backupedFilePath = await this.backup(_, filename, data, undefined, s3Config.skipBackupFile)
|
||||
const s3Client = new S3Storage(s3Config)
|
||||
try {
|
||||
const fileBuffer = await fs.promises.readFile(backupedFilePath)
|
||||
const result = await s3Client.putFileContents(filename, fileBuffer)
|
||||
await fs.remove(backupedFilePath)
|
||||
|
||||
Logger.log(`[BackupManager] S3 backup completed successfully: ${filename}`)
|
||||
return result
|
||||
} catch (error) {
|
||||
Logger.error(`[BackupManager] S3 backup failed:`, error)
|
||||
await fs.remove(backupedFilePath)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async restoreFromLocalBackup(_: Electron.IpcMainInvokeEvent, fileName: string, localBackupDir: string) {
|
||||
try {
|
||||
const backupDir = localBackupDir
|
||||
const backupPath = path.join(backupDir, fileName)
|
||||
|
||||
if (!fs.existsSync(backupPath)) {
|
||||
throw new Error(`Backup file not found: ${backupPath}`)
|
||||
}
|
||||
|
||||
return await this.restore(_, backupPath)
|
||||
} catch (error) {
|
||||
Logger.error('[BackupManager] Local restore failed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async listLocalBackupFiles(_: Electron.IpcMainInvokeEvent, localBackupDir: string) {
|
||||
try {
|
||||
const files = await fs.readdir(localBackupDir)
|
||||
const result: Array<{ fileName: string; modifiedTime: string; size: number }> = []
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(localBackupDir, file)
|
||||
const stat = await fs.stat(filePath)
|
||||
|
||||
if (stat.isFile() && file.endsWith('.zip')) {
|
||||
result.push({
|
||||
fileName: file,
|
||||
modifiedTime: stat.mtime.toISOString(),
|
||||
size: stat.size
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by modified time, newest first
|
||||
return result.sort((a, b) => new Date(b.modifiedTime).getTime() - new Date(a.modifiedTime).getTime())
|
||||
} catch (error) {
|
||||
Logger.error('[BackupManager] List local backup files failed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async deleteLocalBackupFile(_: Electron.IpcMainInvokeEvent, fileName: string, localBackupDir: string) {
|
||||
try {
|
||||
const filePath = path.join(localBackupDir, fileName)
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`Backup file not found: ${filePath}`)
|
||||
}
|
||||
|
||||
await fs.remove(filePath)
|
||||
return true
|
||||
} catch (error) {
|
||||
Logger.error('[BackupManager] Delete local backup file failed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async setLocalBackupDir(_: Electron.IpcMainInvokeEvent, dirPath: string) {
|
||||
try {
|
||||
// Check if directory exists
|
||||
await fs.ensureDir(dirPath)
|
||||
return true
|
||||
} catch (error) {
|
||||
Logger.error('[BackupManager] Set local backup directory failed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async restoreFromS3(_: Electron.IpcMainInvokeEvent, s3Config: S3Config) {
|
||||
const filename = s3Config.fileName || 'cherry-studio.backup.zip'
|
||||
|
||||
Logger.log(`[BackupManager] Starting restore from S3: ${filename}`)
|
||||
|
||||
const s3Client = new S3Storage(s3Config)
|
||||
try {
|
||||
const retrievedFile = await s3Client.getFileContents(filename)
|
||||
const backupedFilePath = path.join(this.backupDir, filename)
|
||||
if (!fs.existsSync(this.backupDir)) {
|
||||
fs.mkdirSync(this.backupDir, { recursive: true })
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const writeStream = fs.createWriteStream(backupedFilePath)
|
||||
writeStream.write(retrievedFile as Buffer)
|
||||
writeStream.end()
|
||||
writeStream.on('finish', () => resolve())
|
||||
writeStream.on('error', (error) => reject(error))
|
||||
})
|
||||
|
||||
Logger.log(`[BackupManager] S3 restore file downloaded successfully: ${filename}`)
|
||||
return await this.restore(_, backupedFilePath)
|
||||
} catch (error: any) {
|
||||
Logger.error('[BackupManager] Failed to restore from S3:', error)
|
||||
throw new Error(error.message || 'Failed to restore backup file')
|
||||
}
|
||||
}
|
||||
|
||||
listS3Files = async (_: Electron.IpcMainInvokeEvent, s3Config: S3Config) => {
|
||||
try {
|
||||
const s3Client = new S3Storage(s3Config)
|
||||
|
||||
const objects = await s3Client.listFiles()
|
||||
const files = objects
|
||||
.filter((obj) => obj.key.endsWith('.zip'))
|
||||
.map((obj) => {
|
||||
const segments = obj.key.split('/')
|
||||
const fileName = segments[segments.length - 1]
|
||||
return {
|
||||
fileName,
|
||||
modifiedTime: obj.lastModified || '',
|
||||
size: obj.size
|
||||
}
|
||||
})
|
||||
|
||||
return files.sort((a, b) => new Date(b.modifiedTime).getTime() - new Date(a.modifiedTime).getTime())
|
||||
} catch (error: any) {
|
||||
Logger.error('Failed to list S3 files:', error)
|
||||
throw new Error(error.message || 'Failed to list backup files')
|
||||
}
|
||||
}
|
||||
|
||||
async deleteS3File(_: Electron.IpcMainInvokeEvent, fileName: string, s3Config: S3Config) {
|
||||
try {
|
||||
const s3Client = new S3Storage(s3Config)
|
||||
return await s3Client.deleteFile(fileName)
|
||||
} catch (error: any) {
|
||||
Logger.error('Failed to delete S3 file:', error)
|
||||
throw new Error(error.message || 'Failed to delete backup file')
|
||||
}
|
||||
}
|
||||
|
||||
async checkS3Connection(_: Electron.IpcMainInvokeEvent, s3Config: S3Config) {
|
||||
const s3Client = new S3Storage(s3Config)
|
||||
return await s3Client.checkConnection()
|
||||
}
|
||||
}
|
||||
|
||||
export default BackupManager
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
interface CacheItem<T> {
|
||||
data: T
|
||||
timestamp: number
|
||||
duration: number
|
||||
duration?: number
|
||||
}
|
||||
|
||||
export class CacheService {
|
||||
@@ -11,9 +11,9 @@ export class CacheService {
|
||||
* Set cache
|
||||
* @param key Cache key
|
||||
* @param data Cache data
|
||||
* @param duration Cache duration (in milliseconds)
|
||||
* @param duration Cache duration (in milliseconds), if not set the cache will never expire
|
||||
*/
|
||||
static set<T>(key: string, data: T, duration: number): void {
|
||||
static set<T>(key: string, data: T, duration?: number): void {
|
||||
this.cache.set(key, {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
@@ -30,6 +30,11 @@ export class CacheService {
|
||||
const item = this.cache.get(key)
|
||||
if (!item) return null
|
||||
|
||||
// If duration is undefined, cache never expires
|
||||
if (item.duration === undefined) {
|
||||
return item.data
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
if (now - item.timestamp > item.duration) {
|
||||
this.remove(key)
|
||||
@@ -63,6 +68,11 @@ export class CacheService {
|
||||
const item = this.cache.get(key)
|
||||
if (!item) return false
|
||||
|
||||
// If duration is undefined, cache never expires
|
||||
if (item.duration === undefined) {
|
||||
return true
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
if (now - item.timestamp > item.duration) {
|
||||
this.remove(key)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getFilesDir, getFileType, getTempDir } from '@main/utils/file'
|
||||
import { getFilesDir, getFileType, getTempDir, readTextFileWithAutoEncoding } from '@main/utils/file'
|
||||
import { documentExts, imageExts, MB } from '@shared/config/constant'
|
||||
import { FileMetadata } from '@types'
|
||||
import * as crypto from 'crypto'
|
||||
@@ -188,6 +188,8 @@ class FileStorage {
|
||||
count: 1
|
||||
}
|
||||
|
||||
logger.info('[FileStorage] File uploaded:', fileMetadata)
|
||||
|
||||
return fileMetadata
|
||||
}
|
||||
|
||||
@@ -229,7 +231,11 @@ class FileStorage {
|
||||
await fs.promises.rm(path.join(this.storageDir, id), { recursive: true })
|
||||
}
|
||||
|
||||
public readFile = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<string> => {
|
||||
public readFile = async (
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
id: string,
|
||||
detectEncoding: boolean = false
|
||||
): Promise<string> => {
|
||||
const filePath = path.join(this.storageDir, id)
|
||||
|
||||
const fileExtension = path.extname(filePath)
|
||||
@@ -256,7 +262,16 @@ class FileStorage {
|
||||
}
|
||||
}
|
||||
|
||||
return fs.readFileSync(filePath, 'utf8')
|
||||
try {
|
||||
if (detectEncoding) {
|
||||
return readTextFileWithAutoEncoding(filePath)
|
||||
} else {
|
||||
return fs.readFileSync(filePath, 'utf-8')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error)
|
||||
return 'failed to read file'
|
||||
}
|
||||
}
|
||||
|
||||
public createTempFile = async (_: Electron.IpcMainInvokeEvent, fileName: string): Promise<string> => {
|
||||
@@ -409,6 +424,19 @@ class FileStorage {
|
||||
shell.openPath(path).catch((err) => logger.error('[IPC - Error] Failed to open file:', err))
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过相对路径打开文件,跨设备时使用
|
||||
* @param file
|
||||
*/
|
||||
public openFileWithRelativePath = async (_: Electron.IpcMainInvokeEvent, file: FileMetadata): Promise<void> => {
|
||||
const filePath = path.join(this.storageDir, file.name)
|
||||
if (fs.existsSync(filePath)) {
|
||||
shell.openPath(filePath).catch((err) => logger.error('[IPC - Error] Failed to open file:', err))
|
||||
} else {
|
||||
logger.warn('[IPC - Warning] File does not exist:', filePath)
|
||||
}
|
||||
}
|
||||
|
||||
public save = async (
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
fileName: string,
|
||||
|
||||
@@ -24,9 +24,9 @@ import { WebLoader } from '@cherrystudio/embedjs-loader-web'
|
||||
import Embeddings from '@main/knowledage/embeddings/Embeddings'
|
||||
import { addFileLoader } from '@main/knowledage/loader'
|
||||
import { NoteLoader } from '@main/knowledage/loader/noteLoader'
|
||||
import OcrProvider from '@main/knowledage/ocr/OcrProvider'
|
||||
import PreprocessProvider from '@main/knowledage/preprocess/PreprocessProvider'
|
||||
import Reranker from '@main/knowledage/reranker/Reranker'
|
||||
import OcrProvider from '@main/ocr/OcrProvider'
|
||||
import PreprocessProvider from '@main/preprocess/PreprocessProvider'
|
||||
import { windowService } from '@main/services/WindowService'
|
||||
import { getDataPath } from '@main/utils'
|
||||
import { getAllFiles } from '@main/utils/file'
|
||||
|
||||
@@ -28,6 +28,7 @@ import { app } from 'electron'
|
||||
import Logger from 'electron-log'
|
||||
import { EventEmitter } from 'events'
|
||||
import { memoize } from 'lodash'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
import { CacheService } from './CacheService'
|
||||
import { CallBackServer } from './mcp/oauth/callback'
|
||||
@@ -71,6 +72,7 @@ function withCache<T extends unknown[], R>(
|
||||
class McpService {
|
||||
private clients: Map<string, Client> = new Map()
|
||||
private pendingClients: Map<string, Promise<Client>> = new Map()
|
||||
private activeToolCalls: Map<string, AbortController> = new Map()
|
||||
|
||||
constructor() {
|
||||
this.initClient = this.initClient.bind(this)
|
||||
@@ -84,6 +86,7 @@ class McpService {
|
||||
this.removeServer = this.removeServer.bind(this)
|
||||
this.restartServer = this.restartServer.bind(this)
|
||||
this.stopServer = this.stopServer.bind(this)
|
||||
this.abortTool = this.abortTool.bind(this)
|
||||
this.cleanup = this.cleanup.bind(this)
|
||||
}
|
||||
|
||||
@@ -455,10 +458,14 @@ class McpService {
|
||||
*/
|
||||
public async callTool(
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
{ server, name, args }: { server: MCPServer; name: string; args: any }
|
||||
{ server, name, args, callId }: { server: MCPServer; name: string; args: any; callId?: string }
|
||||
): Promise<MCPCallToolResponse> {
|
||||
const toolCallId = callId || uuidv4()
|
||||
const abortController = new AbortController()
|
||||
this.activeToolCalls.set(toolCallId, abortController)
|
||||
|
||||
try {
|
||||
Logger.info('[MCP] Calling:', server.name, name, args)
|
||||
Logger.info('[MCP] Calling:', server.name, name, args, 'callId:', toolCallId)
|
||||
if (typeof args === 'string') {
|
||||
try {
|
||||
args = JSON.parse(args)
|
||||
@@ -468,12 +475,19 @@ class McpService {
|
||||
}
|
||||
const client = await this.initClient(server)
|
||||
const result = await client.callTool({ name, arguments: args }, undefined, {
|
||||
timeout: server.timeout ? server.timeout * 1000 : 60000 // Default timeout of 1 minute
|
||||
onprogress: (process) => {
|
||||
console.log('[MCP] Progress:', process.progress / (process.total || 1))
|
||||
window.api.mcp.setProgress(process.progress / (process.total || 1))
|
||||
},
|
||||
timeout: server.timeout ? server.timeout * 1000 : 60000, // Default timeout of 1 minute
|
||||
signal: this.activeToolCalls.get(toolCallId)?.signal
|
||||
})
|
||||
return result as MCPCallToolResponse
|
||||
} catch (error) {
|
||||
Logger.error(`[MCP] Error calling tool ${name} on ${server.name}:`, error)
|
||||
throw error
|
||||
} finally {
|
||||
this.activeToolCalls.delete(toolCallId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -664,6 +678,20 @@ class McpService {
|
||||
delete env.http_proxy
|
||||
delete env.https_proxy
|
||||
}
|
||||
|
||||
// 实现 abortTool 方法
|
||||
public async abortTool(_: Electron.IpcMainInvokeEvent, callId: string) {
|
||||
const activeToolCall = this.activeToolCalls.get(callId)
|
||||
if (activeToolCall) {
|
||||
activeToolCall.abort()
|
||||
this.activeToolCalls.delete(callId)
|
||||
Logger.info(`[MCP] Aborted tool call: ${callId}`)
|
||||
return true
|
||||
} else {
|
||||
Logger.warn(`[MCP] No active tool call found for callId: ${callId}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new McpService()
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
// import Logger from 'electron-log'
|
||||
// import { Operator } from 'opendal'
|
||||
|
||||
// export default class RemoteStorage {
|
||||
// public instance: Operator | undefined
|
||||
|
||||
// /**
|
||||
// *
|
||||
// * @param scheme is the scheme for opendal services. Available value includes "azblob", "azdls", "cos", "gcs", "obs", "oss", "s3", "webdav", "webhdfs", "aliyun-drive", "alluxio", "azfile", "dropbox", "gdrive", "onedrive", "postgresql", "mysql", "redis", "swift", "mongodb", "alluxio", "b2", "seafile", "upyun", "koofr", "yandex-disk"
|
||||
// * @param options is the options for given opendal services. Valid options depend on the scheme. Checkout https://docs.rs/opendal/latest/opendal/services/index.html for all valid options.
|
||||
// *
|
||||
// * For example, use minio as remote storage:
|
||||
// *
|
||||
// * ```typescript
|
||||
// * const storage = new RemoteStorage('s3', {
|
||||
// * endpoint: 'http://localhost:9000',
|
||||
// * region: 'us-east-1',
|
||||
// * bucket: 'testbucket',
|
||||
// * access_key_id: 'user',
|
||||
// * secret_access_key: 'password',
|
||||
// * root: '/path/to/basepath',
|
||||
// * })
|
||||
// * ```
|
||||
// */
|
||||
// constructor(scheme: string, options?: Record<string, string> | undefined | null) {
|
||||
// this.instance = new Operator(scheme, options)
|
||||
|
||||
// this.putFileContents = this.putFileContents.bind(this)
|
||||
// this.getFileContents = this.getFileContents.bind(this)
|
||||
// }
|
||||
|
||||
// public putFileContents = async (filename: string, data: string | Buffer) => {
|
||||
// if (!this.instance) {
|
||||
// return new Error('RemoteStorage client not initialized')
|
||||
// }
|
||||
|
||||
// try {
|
||||
// return await this.instance.write(filename, data)
|
||||
// } catch (error) {
|
||||
// Logger.error('[RemoteStorage] Error putting file contents:', error)
|
||||
// throw error
|
||||
// }
|
||||
// }
|
||||
|
||||
// public getFileContents = async (filename: string) => {
|
||||
// if (!this.instance) {
|
||||
// throw new Error('RemoteStorage client not initialized')
|
||||
// }
|
||||
|
||||
// try {
|
||||
// return await this.instance.read(filename)
|
||||
// } catch (error) {
|
||||
// Logger.error('[RemoteStorage] Error getting file contents:', error)
|
||||
// throw error
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
183
src/main/services/S3Storage.ts
Normal file
183
src/main/services/S3Storage.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import {
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
HeadBucketCommand,
|
||||
ListObjectsV2Command,
|
||||
PutObjectCommand,
|
||||
S3Client
|
||||
} from '@aws-sdk/client-s3'
|
||||
import type { S3Config } from '@types'
|
||||
import Logger from 'electron-log'
|
||||
import * as net from 'net'
|
||||
import { Readable } from 'stream'
|
||||
|
||||
/**
|
||||
* 将可读流转换为 Buffer
|
||||
*/
|
||||
function streamToBuffer(stream: Readable): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = []
|
||||
stream.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)))
|
||||
stream.on('error', reject)
|
||||
stream.on('end', () => resolve(Buffer.concat(chunks)))
|
||||
})
|
||||
}
|
||||
|
||||
// 需要使用 Virtual Host-Style 的服务商域名后缀白名单
|
||||
const VIRTUAL_HOST_SUFFIXES = ['aliyuncs.com', 'myqcloud.com']
|
||||
|
||||
/**
|
||||
* 使用 AWS SDK v3 的简单 S3 封装,兼容之前 RemoteStorage 的最常用接口。
|
||||
*/
|
||||
export default class S3Storage {
|
||||
private client: S3Client
|
||||
private bucket: string
|
||||
private root: string
|
||||
|
||||
constructor(config: S3Config) {
|
||||
const { endpoint, region, accessKeyId, secretAccessKey, bucket, root } = config
|
||||
|
||||
const usePathStyle = (() => {
|
||||
if (!endpoint) return false
|
||||
|
||||
try {
|
||||
const { hostname } = new URL(endpoint)
|
||||
|
||||
if (hostname === 'localhost' || net.isIP(hostname) !== 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
const isInWhiteList = VIRTUAL_HOST_SUFFIXES.some((suffix) => hostname.endsWith(suffix))
|
||||
return !isInWhiteList
|
||||
} catch (e) {
|
||||
Logger.warn('[S3Storage] Failed to parse endpoint, fallback to Path-Style:', endpoint, e)
|
||||
return true
|
||||
}
|
||||
})()
|
||||
|
||||
this.client = new S3Client({
|
||||
region,
|
||||
endpoint: endpoint || undefined,
|
||||
credentials: {
|
||||
accessKeyId: accessKeyId,
|
||||
secretAccessKey: secretAccessKey
|
||||
},
|
||||
forcePathStyle: usePathStyle
|
||||
})
|
||||
|
||||
this.bucket = bucket
|
||||
this.root = root?.replace(/^\/+/g, '').replace(/\/+$/g, '') || ''
|
||||
|
||||
this.putFileContents = this.putFileContents.bind(this)
|
||||
this.getFileContents = this.getFileContents.bind(this)
|
||||
this.deleteFile = this.deleteFile.bind(this)
|
||||
this.listFiles = this.listFiles.bind(this)
|
||||
this.checkConnection = this.checkConnection.bind(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部辅助方法,用来拼接带 root 的对象 key
|
||||
*/
|
||||
private buildKey(key: string): string {
|
||||
if (!this.root) return key
|
||||
return key.startsWith(`${this.root}/`) ? key : `${this.root}/${key}`
|
||||
}
|
||||
|
||||
async putFileContents(key: string, data: Buffer | string) {
|
||||
try {
|
||||
const contentType = key.endsWith('.zip') ? 'application/zip' : 'application/octet-stream'
|
||||
|
||||
return await this.client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: this.buildKey(key),
|
||||
Body: data,
|
||||
ContentType: contentType
|
||||
})
|
||||
)
|
||||
} catch (error) {
|
||||
Logger.error('[S3Storage] Error putting object:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async getFileContents(key: string): Promise<Buffer> {
|
||||
try {
|
||||
const res = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: this.buildKey(key) }))
|
||||
if (!res.Body || !(res.Body instanceof Readable)) {
|
||||
throw new Error('Empty body received from S3')
|
||||
}
|
||||
return await streamToBuffer(res.Body as Readable)
|
||||
} catch (error) {
|
||||
Logger.error('[S3Storage] Error getting object:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async deleteFile(key: string) {
|
||||
try {
|
||||
const keyWithRoot = this.buildKey(key)
|
||||
const variations = new Set([keyWithRoot, key.replace(/^\//, '')])
|
||||
for (const k of variations) {
|
||||
try {
|
||||
await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: k }))
|
||||
} catch {
|
||||
// 忽略删除失败
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error('[S3Storage] Error deleting object:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 列举指定前缀下的对象,默认列举全部。
|
||||
*/
|
||||
async listFiles(prefix = ''): Promise<Array<{ key: string; lastModified?: string; size: number }>> {
|
||||
const files: Array<{ key: string; lastModified?: string; size: number }> = []
|
||||
let continuationToken: string | undefined
|
||||
const fullPrefix = this.buildKey(prefix)
|
||||
|
||||
try {
|
||||
do {
|
||||
const res = await this.client.send(
|
||||
new ListObjectsV2Command({
|
||||
Bucket: this.bucket,
|
||||
Prefix: fullPrefix === '' ? undefined : fullPrefix,
|
||||
ContinuationToken: continuationToken
|
||||
})
|
||||
)
|
||||
|
||||
res.Contents?.forEach((obj) => {
|
||||
if (!obj.Key) return
|
||||
files.push({
|
||||
key: obj.Key,
|
||||
lastModified: obj.LastModified?.toISOString(),
|
||||
size: obj.Size ?? 0
|
||||
})
|
||||
})
|
||||
|
||||
continuationToken = res.IsTruncated ? res.NextContinuationToken : undefined
|
||||
} while (continuationToken)
|
||||
|
||||
return files
|
||||
} catch (error) {
|
||||
Logger.error('[S3Storage] Error listing objects:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试调用 HeadBucket 判断凭证/网络是否可用
|
||||
*/
|
||||
async checkConnection() {
|
||||
try {
|
||||
await this.client.send(new HeadBucketCommand({ Bucket: this.bucket }))
|
||||
return true
|
||||
} catch (error) {
|
||||
Logger.error('[S3Storage] Error checking connection:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { SELECTION_FINETUNED_LIST, SELECTION_PREDEFINED_BLACKLIST } from '@main/configs/SelectionConfig'
|
||||
import { isDev, isMac, isWin } from '@main/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { BrowserWindow, ipcMain, screen, systemPreferences } from 'electron'
|
||||
import { app, BrowserWindow, ipcMain, screen, systemPreferences } from 'electron'
|
||||
import Logger from 'electron-log'
|
||||
import { join } from 'path'
|
||||
import type {
|
||||
@@ -141,7 +141,7 @@ export class SelectionService {
|
||||
* Initialize zoom factor from config and subscribe to changes
|
||||
* Ensures UI elements scale properly with system DPI settings
|
||||
*/
|
||||
private initZoomFactor() {
|
||||
private initZoomFactor(): void {
|
||||
const zoomFactor = configManager.getZoomFactor()
|
||||
if (zoomFactor) {
|
||||
this.setZoomFactor(zoomFactor)
|
||||
@@ -154,7 +154,7 @@ export class SelectionService {
|
||||
this.zoomFactor = zoomFactor
|
||||
}
|
||||
|
||||
private initConfig() {
|
||||
private initConfig(): void {
|
||||
this.triggerMode = configManager.getSelectionAssistantTriggerMode() as TriggerMode
|
||||
this.isFollowToolbar = configManager.getSelectionAssistantFollowToolbar()
|
||||
this.isRemeberWinSize = configManager.getSelectionAssistantRemeberWinSize()
|
||||
@@ -207,7 +207,7 @@ export class SelectionService {
|
||||
* @param mode - The mode to set, either 'default', 'whitelist', or 'blacklist'
|
||||
* @param list - An array of strings representing the list of items to include or exclude
|
||||
*/
|
||||
private setHookGlobalFilterMode(mode: string, list: string[]) {
|
||||
private setHookGlobalFilterMode(mode: string, list: string[]): void {
|
||||
if (!this.selectionHook) return
|
||||
|
||||
const modeMap = {
|
||||
@@ -245,7 +245,7 @@ export class SelectionService {
|
||||
}
|
||||
}
|
||||
|
||||
private setHookFineTunedList() {
|
||||
private setHookFineTunedList(): void {
|
||||
if (!this.selectionHook) return
|
||||
|
||||
const excludeClipboardCursorDetectList = isWin
|
||||
@@ -271,6 +271,11 @@ export class SelectionService {
|
||||
* @returns {boolean} Success status of service start
|
||||
*/
|
||||
public start(): boolean {
|
||||
if (!isSupportedOS) {
|
||||
this.logError(new Error('SelectionService start(): not supported on this OS'))
|
||||
return false
|
||||
}
|
||||
|
||||
if (!this.selectionHook) {
|
||||
this.logError(new Error('SelectionService start(): instance is null'))
|
||||
return false
|
||||
@@ -373,7 +378,7 @@ export class SelectionService {
|
||||
* Toggle the enabled state of the selection service
|
||||
* Will sync the new enabled store to all renderer windows
|
||||
*/
|
||||
public toggleEnabled(enabled: boolean | undefined = undefined) {
|
||||
public toggleEnabled(enabled: boolean | undefined = undefined): void {
|
||||
if (!this.selectionHook) return
|
||||
|
||||
const newEnabled = enabled === undefined ? !configManager.getSelectionAssistantEnabled() : enabled
|
||||
@@ -389,7 +394,7 @@ export class SelectionService {
|
||||
* Sets up window properties, event handlers, and loads the toolbar UI
|
||||
* @param readyCallback Optional callback when window is ready to show
|
||||
*/
|
||||
private createToolbarWindow(readyCallback?: () => void) {
|
||||
private createToolbarWindow(readyCallback?: () => void): void {
|
||||
if (this.isToolbarAlive()) return
|
||||
|
||||
const { toolbarWidth, toolbarHeight } = this.getToolbarRealSize()
|
||||
@@ -414,9 +419,11 @@ export class SelectionService {
|
||||
backgroundMaterial: 'none',
|
||||
|
||||
// Platform specific settings
|
||||
// [macOS] DO NOT set type to 'panel', it will not work because it conflicts with other settings
|
||||
// [macOS] DO NOT set focusable to false, it will make other windows bring to front together
|
||||
...(isWin ? { type: 'toolbar', focusable: false } : {}),
|
||||
// [macOS] `panel` conflicts with other settings ,
|
||||
// and log will show `NSWindow does not support nonactivating panel styleMask 0x80`
|
||||
// but it seems still work on fullscreen apps, so we set this anyway
|
||||
...(isWin ? { type: 'toolbar', focusable: false } : { type: 'panel' }),
|
||||
hiddenInMissionControl: true, // [macOS only]
|
||||
acceptFirstMouse: true, // [macOS only]
|
||||
|
||||
@@ -447,13 +454,6 @@ export class SelectionService {
|
||||
// Add show/hide event listeners
|
||||
this.toolbarWindow.on('show', () => {
|
||||
this.toolbarWindow?.webContents.send(IpcChannel.Selection_ToolbarVisibilityChange, true)
|
||||
|
||||
// [macOS] force the toolbar window to be visible on current desktop
|
||||
// but it will make docker icon flash. And we found that it's not necessary now.
|
||||
// will remove after testing
|
||||
// if (isMac) {
|
||||
// this.toolbarWindow!.setVisibleOnAllWorkspaces(false)
|
||||
// }
|
||||
})
|
||||
|
||||
this.toolbarWindow.on('hide', () => {
|
||||
@@ -485,10 +485,10 @@ export class SelectionService {
|
||||
* @param point Reference point for positioning, logical coordinates
|
||||
* @param orientation Preferred position relative to reference point
|
||||
*/
|
||||
private showToolbarAtPosition(point: Point, orientation: RelativeOrientation) {
|
||||
private showToolbarAtPosition(point: Point, orientation: RelativeOrientation, programName: string): void {
|
||||
if (!this.isToolbarAlive()) {
|
||||
this.createToolbarWindow(() => {
|
||||
this.showToolbarAtPosition(point, orientation)
|
||||
this.showToolbarAtPosition(point, orientation, programName)
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -509,25 +509,55 @@ export class SelectionService {
|
||||
//should set every time the window is shown
|
||||
this.toolbarWindow!.setAlwaysOnTop(true, 'screen-saver')
|
||||
|
||||
// [macOS] force the toolbar window to be visible on current desktop
|
||||
// but it will make docker icon flash. And we found that it's not necessary now.
|
||||
// will remove after testing
|
||||
// if (isMac) {
|
||||
// this.toolbarWindow!.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })
|
||||
// }
|
||||
if (!isMac) {
|
||||
this.toolbarWindow!.show()
|
||||
/**
|
||||
* [Windows]
|
||||
* In Windows 10, setOpacity(1) will make the window completely transparent
|
||||
* It's a strange behavior, so we don't use it for compatibility
|
||||
*/
|
||||
// this.toolbarWindow!.setOpacity(1)
|
||||
this.startHideByMouseKeyListener()
|
||||
return
|
||||
}
|
||||
|
||||
/************************************************
|
||||
* [macOS] the following code is only for macOS
|
||||
*
|
||||
* WARNING:
|
||||
* DO NOT MODIFY THESE CODES, UNLESS YOU REALLY KNOW WHAT YOU ARE DOING!!!!
|
||||
*************************************************/
|
||||
|
||||
// [macOS] a hacky way
|
||||
// when set `skipTransformProcessType: true`, if the selection is in self app, it will make the selection canceled after toolbar showing
|
||||
// so we just don't set `skipTransformProcessType: true` when in self app
|
||||
const isSelf = ['com.github.Electron', 'com.kangfenmao.CherryStudio'].includes(programName)
|
||||
|
||||
if (!isSelf) {
|
||||
// [macOS] an ugly hacky way
|
||||
// `focusable: true` will make mainWindow disappeared when `setVisibleOnAllWorkspaces`
|
||||
// so we set `focusable: true` before showing, and then set false after showing
|
||||
this.toolbarWindow!.setFocusable(false)
|
||||
|
||||
// [macOS]
|
||||
// force `setVisibleOnAllWorkspaces: true` to let toolbar show in all workspaces. And we MUST not set it to false again
|
||||
// set `skipTransformProcessType: true` to avoid dock icon spinning when `setVisibleOnAllWorkspaces`
|
||||
this.toolbarWindow!.setVisibleOnAllWorkspaces(true, {
|
||||
visibleOnFullScreen: true,
|
||||
skipTransformProcessType: true
|
||||
})
|
||||
}
|
||||
|
||||
// [macOS] MUST use `showInactive()` to prevent other windows bring to front together
|
||||
// [Windows] is OK for both `show()` and `showInactive()` because of `focusable: false`
|
||||
this.toolbarWindow!.showInactive()
|
||||
|
||||
/**
|
||||
* [Windows]
|
||||
* In Windows 10, setOpacity(1) will make the window completely transparent
|
||||
* It's a strange behavior, so we don't use it for compatibility
|
||||
*/
|
||||
// this.toolbarWindow!.setOpacity(1)
|
||||
// [macOS] restore the focusable status
|
||||
this.toolbarWindow!.setFocusable(true)
|
||||
|
||||
this.startHideByMouseKeyListener()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -588,8 +618,8 @@ export class SelectionService {
|
||||
* Check if toolbar window exists and is not destroyed
|
||||
* @returns {boolean} Toolbar window status
|
||||
*/
|
||||
private isToolbarAlive() {
|
||||
return this.toolbarWindow && !this.toolbarWindow.isDestroyed()
|
||||
private isToolbarAlive(): boolean {
|
||||
return !!(this.toolbarWindow && !this.toolbarWindow.isDestroyed())
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -598,7 +628,7 @@ export class SelectionService {
|
||||
* @param width New toolbar width
|
||||
* @param height New toolbar height
|
||||
*/
|
||||
public determineToolbarSize(width: number, height: number) {
|
||||
public determineToolbarSize(width: number, height: number): void {
|
||||
const toolbarWidth = Math.ceil(width)
|
||||
|
||||
// only update toolbar width if it's changed
|
||||
@@ -611,7 +641,7 @@ export class SelectionService {
|
||||
* Get actual toolbar dimensions accounting for zoom factor
|
||||
* @returns Object containing toolbar width and height
|
||||
*/
|
||||
private getToolbarRealSize() {
|
||||
private getToolbarRealSize(): { toolbarWidth: number; toolbarHeight: number } {
|
||||
return {
|
||||
toolbarWidth: this.TOOLBAR_WIDTH * this.zoomFactor,
|
||||
toolbarHeight: this.TOOLBAR_HEIGHT * this.zoomFactor
|
||||
@@ -882,8 +912,9 @@ export class SelectionService {
|
||||
refPoint = { x: Math.round(refPoint.x), y: Math.round(refPoint.y) }
|
||||
}
|
||||
|
||||
this.showToolbarAtPosition(refPoint, refOrientation)
|
||||
this.toolbarWindow?.webContents.send(IpcChannel.Selection_TextSelected, selectionData)
|
||||
// [macOS] isFullscreen is only available on macOS
|
||||
this.showToolbarAtPosition(refPoint, refOrientation, selectionData.programName)
|
||||
this.toolbarWindow!.webContents.send(IpcChannel.Selection_TextSelected, selectionData)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -891,7 +922,7 @@ export class SelectionService {
|
||||
*/
|
||||
|
||||
// Start monitoring global mouse clicks
|
||||
private startHideByMouseKeyListener() {
|
||||
private startHideByMouseKeyListener(): void {
|
||||
try {
|
||||
// Register event handlers
|
||||
this.selectionHook!.on('mouse-down', this.handleMouseDownHide)
|
||||
@@ -904,7 +935,7 @@ export class SelectionService {
|
||||
}
|
||||
|
||||
// Stop monitoring global mouse clicks
|
||||
private stopHideByMouseKeyListener() {
|
||||
private stopHideByMouseKeyListener(): void {
|
||||
if (!this.isHideByMouseKeyListenerActive) return
|
||||
|
||||
try {
|
||||
@@ -1098,7 +1129,7 @@ export class SelectionService {
|
||||
* Initialize preloaded action windows
|
||||
* Creates a pool of windows at startup for faster response
|
||||
*/
|
||||
private async initPreloadedActionWindows() {
|
||||
private async initPreloadedActionWindows(): Promise<void> {
|
||||
try {
|
||||
// Create initial pool of preloaded windows
|
||||
for (let i = 0; i < this.PRELOAD_ACTION_WINDOW_COUNT; i++) {
|
||||
@@ -1112,7 +1143,7 @@ export class SelectionService {
|
||||
/**
|
||||
* Close all preloaded action windows
|
||||
*/
|
||||
private closePreloadedActionWindows() {
|
||||
private closePreloadedActionWindows(): void {
|
||||
for (const actionWindow of this.preloadedActionWindows) {
|
||||
if (!actionWindow.isDestroyed()) {
|
||||
actionWindow.destroy()
|
||||
@@ -1124,7 +1155,7 @@ export class SelectionService {
|
||||
* Preload a new action window asynchronously
|
||||
* This method is called after popping a window to ensure we always have windows ready
|
||||
*/
|
||||
private async pushNewActionWindow() {
|
||||
private async pushNewActionWindow(): Promise<void> {
|
||||
try {
|
||||
const actionWindow = this.createPreloadedActionWindow()
|
||||
this.preloadedActionWindows.push(actionWindow)
|
||||
@@ -1138,7 +1169,7 @@ export class SelectionService {
|
||||
* Immediately returns a window and asynchronously creates a new one
|
||||
* @returns {BrowserWindow} The action window
|
||||
*/
|
||||
private popActionWindow() {
|
||||
private popActionWindow(): BrowserWindow {
|
||||
// Get a window from the preloaded queue or create a new one if empty
|
||||
const actionWindow = this.preloadedActionWindows.pop() || this.createPreloadedActionWindow()
|
||||
|
||||
@@ -1189,20 +1220,26 @@ export class SelectionService {
|
||||
return actionWindow
|
||||
}
|
||||
|
||||
public processAction(actionItem: ActionItem): void {
|
||||
/**
|
||||
* Process action item
|
||||
* @param actionItem Action item to process
|
||||
* @param isFullScreen [macOS] only macOS has the available isFullscreen mode
|
||||
*/
|
||||
public processAction(actionItem: ActionItem, isFullScreen: boolean = false): void {
|
||||
const actionWindow = this.popActionWindow()
|
||||
|
||||
actionWindow.webContents.send(IpcChannel.Selection_UpdateActionData, actionItem)
|
||||
|
||||
this.showActionWindow(actionWindow)
|
||||
this.showActionWindow(actionWindow, isFullScreen)
|
||||
}
|
||||
|
||||
/**
|
||||
* Show action window with proper positioning relative to toolbar
|
||||
* Ensures window stays within screen boundaries
|
||||
* @param actionWindow Window to position and show
|
||||
* @param isFullScreen [macOS] only macOS has the available isFullscreen mode
|
||||
*/
|
||||
private showActionWindow(actionWindow: BrowserWindow) {
|
||||
private showActionWindow(actionWindow: BrowserWindow, isFullScreen: boolean = false): void {
|
||||
let actionWindowWidth = this.ACTION_WINDOW_WIDTH
|
||||
let actionWindowHeight = this.ACTION_WINDOW_HEIGHT
|
||||
|
||||
@@ -1212,11 +1249,14 @@ export class SelectionService {
|
||||
actionWindowHeight = this.lastActionWindowSize.height
|
||||
}
|
||||
|
||||
//center way
|
||||
if (!this.isFollowToolbar || !this.toolbarWindow) {
|
||||
const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint())
|
||||
const workArea = display.workArea
|
||||
/********************************************
|
||||
* Setting the position of the action window
|
||||
********************************************/
|
||||
const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint())
|
||||
const workArea = display.workArea
|
||||
|
||||
// Center of the screen
|
||||
if (!this.isFollowToolbar || !this.toolbarWindow) {
|
||||
const centerX = workArea.x + (workArea.width - actionWindowWidth) / 2
|
||||
const centerY = workArea.y + (workArea.height - actionWindowHeight) / 2
|
||||
|
||||
@@ -1226,53 +1266,107 @@ export class SelectionService {
|
||||
x: Math.round(centerX),
|
||||
y: Math.round(centerY)
|
||||
})
|
||||
} else {
|
||||
// Follow toolbar position
|
||||
const toolbarBounds = this.toolbarWindow!.getBounds()
|
||||
const GAP = 6 // 6px gap from screen edges
|
||||
|
||||
//make sure action window is inside screen
|
||||
if (actionWindowWidth > workArea.width - 2 * GAP) {
|
||||
actionWindowWidth = workArea.width - 2 * GAP
|
||||
}
|
||||
|
||||
if (actionWindowHeight > workArea.height - 2 * GAP) {
|
||||
actionWindowHeight = workArea.height - 2 * GAP
|
||||
}
|
||||
|
||||
// Calculate initial position to center action window horizontally below toolbar
|
||||
let posX = Math.round(toolbarBounds.x + (toolbarBounds.width - actionWindowWidth) / 2)
|
||||
let posY = Math.round(toolbarBounds.y)
|
||||
|
||||
// Ensure action window stays within screen boundaries with a small gap
|
||||
if (posX + actionWindowWidth > workArea.x + workArea.width) {
|
||||
posX = workArea.x + workArea.width - actionWindowWidth - GAP
|
||||
} else if (posX < workArea.x) {
|
||||
posX = workArea.x + GAP
|
||||
}
|
||||
if (posY + actionWindowHeight > workArea.y + workArea.height) {
|
||||
// If window would go below screen, try to position it above toolbar
|
||||
posY = workArea.y + workArea.height - actionWindowHeight - GAP
|
||||
} else if (posY < workArea.y) {
|
||||
posY = workArea.y + GAP
|
||||
}
|
||||
|
||||
actionWindow.setPosition(posX, posY, false)
|
||||
//KEY to make window not resize
|
||||
actionWindow.setBounds({
|
||||
width: actionWindowWidth,
|
||||
height: actionWindowHeight,
|
||||
x: posX,
|
||||
y: posY
|
||||
})
|
||||
}
|
||||
|
||||
if (!isMac) {
|
||||
actionWindow.show()
|
||||
return
|
||||
}
|
||||
|
||||
//follow toolbar
|
||||
const toolbarBounds = this.toolbarWindow!.getBounds()
|
||||
const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint())
|
||||
const workArea = display.workArea
|
||||
const GAP = 6 // 6px gap from screen edges
|
||||
/************************************************
|
||||
* [macOS] the following code is only for macOS
|
||||
*
|
||||
* WARNING:
|
||||
* DO NOT MODIFY THESE CODES, UNLESS YOU REALLY KNOW WHAT YOU ARE DOING!!!!
|
||||
*************************************************/
|
||||
|
||||
//make sure action window is inside screen
|
||||
if (actionWindowWidth > workArea.width - 2 * GAP) {
|
||||
actionWindowWidth = workArea.width - 2 * GAP
|
||||
// act normally when the app is not in fullscreen mode
|
||||
if (!isFullScreen) {
|
||||
actionWindow.show()
|
||||
return
|
||||
}
|
||||
|
||||
if (actionWindowHeight > workArea.height - 2 * GAP) {
|
||||
actionWindowHeight = workArea.height - 2 * GAP
|
||||
}
|
||||
// [macOS] an UGLY HACKY way for fullscreen override settings
|
||||
|
||||
// Calculate initial position to center action window horizontally below toolbar
|
||||
let posX = Math.round(toolbarBounds.x + (toolbarBounds.width - actionWindowWidth) / 2)
|
||||
let posY = Math.round(toolbarBounds.y)
|
||||
// FIXME sometimes the dock will be shown when the action window is shown
|
||||
// FIXME if actionWindow show on the fullscreen app, switch to other space will cause the mainWindow to be shown
|
||||
// FIXME When setVisibleOnAllWorkspaces is true, docker icon disappeared when the first action window is shown on the fullscreen app
|
||||
// use app.dock.show() to show the dock again will cause the action window to be closed when auto hide on blur is enabled
|
||||
|
||||
// Ensure action window stays within screen boundaries with a small gap
|
||||
if (posX + actionWindowWidth > workArea.x + workArea.width) {
|
||||
posX = workArea.x + workArea.width - actionWindowWidth - GAP
|
||||
} else if (posX < workArea.x) {
|
||||
posX = workArea.x + GAP
|
||||
}
|
||||
if (posY + actionWindowHeight > workArea.y + workArea.height) {
|
||||
// If window would go below screen, try to position it above toolbar
|
||||
posY = workArea.y + workArea.height - actionWindowHeight - GAP
|
||||
} else if (posY < workArea.y) {
|
||||
posY = workArea.y + GAP
|
||||
}
|
||||
// setFocusable(false) to prevent the action window hide when blur (if auto hide on blur is enabled)
|
||||
actionWindow.setFocusable(false)
|
||||
actionWindow.setAlwaysOnTop(true, 'floating')
|
||||
|
||||
actionWindow.setPosition(posX, posY, false)
|
||||
//KEY to make window not resize
|
||||
actionWindow.setBounds({
|
||||
width: actionWindowWidth,
|
||||
height: actionWindowHeight,
|
||||
x: posX,
|
||||
y: posY
|
||||
// `setVisibleOnAllWorkspaces(true)` will cause the dock icon disappeared
|
||||
// just store the dock icon status, and show it again
|
||||
const isDockShown = app.dock?.isVisible()
|
||||
|
||||
// DO NOT set `skipTransformProcessType: true`,
|
||||
// it will cause the action window to be shown on other space
|
||||
actionWindow.setVisibleOnAllWorkspaces(true, {
|
||||
visibleOnFullScreen: true
|
||||
})
|
||||
|
||||
actionWindow.show()
|
||||
actionWindow.showInactive()
|
||||
|
||||
// show the dock again if last time it was shown
|
||||
// do not put it after `actionWindow.focus()`, will cause the action window to be closed when auto hide on blur is enabled
|
||||
if (!app.dock?.isVisible() && isDockShown) {
|
||||
app.dock?.show()
|
||||
}
|
||||
|
||||
// unset everything
|
||||
setTimeout(() => {
|
||||
actionWindow.setVisibleOnAllWorkspaces(false, {
|
||||
visibleOnFullScreen: true,
|
||||
skipTransformProcessType: true
|
||||
})
|
||||
actionWindow.setAlwaysOnTop(false)
|
||||
|
||||
actionWindow.setFocusable(true)
|
||||
|
||||
// regain the focus when all the works done
|
||||
actionWindow.focus()
|
||||
}, 50)
|
||||
}
|
||||
|
||||
public closeActionWindow(actionWindow: BrowserWindow): void {
|
||||
@@ -1292,38 +1386,40 @@ export class SelectionService {
|
||||
* Switches between selection-based and alt-key based triggering
|
||||
* Manages appropriate event listeners for each mode
|
||||
*/
|
||||
private processTriggerMode() {
|
||||
private processTriggerMode(): void {
|
||||
if (!this.selectionHook) return
|
||||
|
||||
switch (this.triggerMode) {
|
||||
case TriggerMode.Selected:
|
||||
if (this.isCtrlkeyListenerActive) {
|
||||
this.selectionHook!.off('key-down', this.handleKeyDownCtrlkeyMode)
|
||||
this.selectionHook!.off('key-up', this.handleKeyUpCtrlkeyMode)
|
||||
this.selectionHook.off('key-down', this.handleKeyDownCtrlkeyMode)
|
||||
this.selectionHook.off('key-up', this.handleKeyUpCtrlkeyMode)
|
||||
|
||||
this.isCtrlkeyListenerActive = false
|
||||
}
|
||||
|
||||
this.selectionHook!.setSelectionPassiveMode(false)
|
||||
this.selectionHook.setSelectionPassiveMode(false)
|
||||
break
|
||||
case TriggerMode.Ctrlkey:
|
||||
if (!this.isCtrlkeyListenerActive) {
|
||||
this.selectionHook!.on('key-down', this.handleKeyDownCtrlkeyMode)
|
||||
this.selectionHook!.on('key-up', this.handleKeyUpCtrlkeyMode)
|
||||
this.selectionHook.on('key-down', this.handleKeyDownCtrlkeyMode)
|
||||
this.selectionHook.on('key-up', this.handleKeyUpCtrlkeyMode)
|
||||
|
||||
this.isCtrlkeyListenerActive = true
|
||||
}
|
||||
|
||||
this.selectionHook!.setSelectionPassiveMode(true)
|
||||
this.selectionHook.setSelectionPassiveMode(true)
|
||||
break
|
||||
case TriggerMode.Shortcut:
|
||||
//remove the ctrlkey listener, don't need any key listener for shortcut mode
|
||||
if (this.isCtrlkeyListenerActive) {
|
||||
this.selectionHook!.off('key-down', this.handleKeyDownCtrlkeyMode)
|
||||
this.selectionHook!.off('key-up', this.handleKeyUpCtrlkeyMode)
|
||||
this.selectionHook.off('key-down', this.handleKeyDownCtrlkeyMode)
|
||||
this.selectionHook.off('key-up', this.handleKeyUpCtrlkeyMode)
|
||||
|
||||
this.isCtrlkeyListenerActive = false
|
||||
}
|
||||
|
||||
this.selectionHook!.setSelectionPassiveMode(true)
|
||||
this.selectionHook.setSelectionPassiveMode(true)
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -1376,8 +1472,9 @@ export class SelectionService {
|
||||
configManager.setSelectionAssistantFilterList(filterList)
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.Selection_ProcessAction, (_, actionItem: ActionItem) => {
|
||||
selectionService?.processAction(actionItem)
|
||||
// [macOS] only macOS has the available isFullscreen mode
|
||||
ipcMain.handle(IpcChannel.Selection_ProcessAction, (_, actionItem: ActionItem, isFullScreen: boolean = false) => {
|
||||
selectionService?.processAction(actionItem, isFullScreen)
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.Selection_ActionWindowClose, (event) => {
|
||||
@@ -1404,13 +1501,13 @@ export class SelectionService {
|
||||
this.isIpcHandlerRegistered = true
|
||||
}
|
||||
|
||||
private logInfo(message: string, forceShow: boolean = false) {
|
||||
private logInfo(message: string, forceShow: boolean = false): void {
|
||||
if (isDev || forceShow) {
|
||||
Logger.info('[SelectionService] Info: ', message)
|
||||
}
|
||||
}
|
||||
|
||||
private logError(...args: [...string[], Error]) {
|
||||
private logError(...args: [...string[], Error]): void {
|
||||
Logger.error('[SelectionService] Error: ', ...args)
|
||||
}
|
||||
}
|
||||
@@ -1423,7 +1520,7 @@ export class SelectionService {
|
||||
export function initSelectionService(): boolean {
|
||||
if (!isSupportedOS) return false
|
||||
|
||||
configManager.subscribe(ConfigKeys.SelectionAssistantEnabled, (enabled: boolean) => {
|
||||
configManager.subscribe(ConfigKeys.SelectionAssistantEnabled, (enabled: boolean): void => {
|
||||
//avoid closure
|
||||
const ss = SelectionService.getInstance()
|
||||
if (!ss) {
|
||||
|
||||
149
src/main/services/SettingsWindowService.ts
Normal file
149
src/main/services/SettingsWindowService.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { is } from '@electron-toolkit/utils'
|
||||
import { isLinux, isMac } from '@main/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { BrowserWindow, nativeTheme } from 'electron'
|
||||
import { join } from 'path'
|
||||
|
||||
import icon from '../../../build/icon.png?asset'
|
||||
import { titleBarOverlayDark, titleBarOverlayLight } from '../config'
|
||||
|
||||
export class SettingsWindowService {
|
||||
private static instance: SettingsWindowService | null = null
|
||||
private settingsWindow: BrowserWindow | null = null
|
||||
|
||||
public static getInstance(): SettingsWindowService {
|
||||
if (!SettingsWindowService.instance) {
|
||||
SettingsWindowService.instance = new SettingsWindowService()
|
||||
}
|
||||
return SettingsWindowService.instance
|
||||
}
|
||||
|
||||
public createSettingsWindow(defaultTab?: string): BrowserWindow {
|
||||
if (this.settingsWindow && !this.settingsWindow.isDestroyed()) {
|
||||
this.settingsWindow.show()
|
||||
this.settingsWindow.focus()
|
||||
return this.settingsWindow
|
||||
}
|
||||
|
||||
this.settingsWindow = new BrowserWindow({
|
||||
width: 1000,
|
||||
height: 700,
|
||||
minWidth: 900,
|
||||
minHeight: 600,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
transparent: false,
|
||||
vibrancy: 'sidebar',
|
||||
visualEffectState: 'active',
|
||||
titleBarStyle: 'hidden',
|
||||
titleBarOverlay: nativeTheme.shouldUseDarkColors ? titleBarOverlayDark : titleBarOverlayLight,
|
||||
backgroundColor: isMac ? undefined : nativeTheme.shouldUseDarkColors ? '#181818' : '#FFFFFF',
|
||||
darkTheme: nativeTheme.shouldUseDarkColors,
|
||||
trafficLightPosition: { x: 12, y: 12 },
|
||||
...(isLinux ? { icon } : {}),
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: false,
|
||||
webSecurity: false,
|
||||
webviewTag: true,
|
||||
allowRunningInsecureContent: true,
|
||||
backgroundThrottling: false
|
||||
}
|
||||
})
|
||||
|
||||
this.setupSettingsWindow()
|
||||
this.loadSettingsWindowContent(defaultTab)
|
||||
|
||||
return this.settingsWindow
|
||||
}
|
||||
|
||||
private setupSettingsWindow() {
|
||||
if (!this.settingsWindow) return
|
||||
|
||||
this.settingsWindow.on('ready-to-show', () => {
|
||||
this.settingsWindow?.show()
|
||||
})
|
||||
|
||||
this.settingsWindow.on('closed', () => {
|
||||
this.settingsWindow = null
|
||||
})
|
||||
|
||||
this.settingsWindow.on('close', () => {
|
||||
// Clean up when window is closed
|
||||
})
|
||||
|
||||
// Handle theme changes
|
||||
nativeTheme.on('updated', () => {
|
||||
if (this.settingsWindow && !this.settingsWindow.isDestroyed()) {
|
||||
this.settingsWindow.setTitleBarOverlay(
|
||||
nativeTheme.shouldUseDarkColors ? titleBarOverlayDark : titleBarOverlayLight
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private loadSettingsWindowContent(defaultTab?: string) {
|
||||
if (!this.settingsWindow) return
|
||||
|
||||
const queryParam = defaultTab ? `?tab=${defaultTab}` : ''
|
||||
|
||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||
this.settingsWindow.loadURL(process.env['ELECTRON_RENDERER_URL'] + '/settingsWindow.html' + queryParam)
|
||||
} else {
|
||||
this.settingsWindow.loadFile(join(__dirname, '../renderer/settingsWindow.html'))
|
||||
if (defaultTab) {
|
||||
this.settingsWindow.webContents.once('did-finish-load', () => {
|
||||
this.settingsWindow?.webContents.send(IpcChannel.SettingsWindow_Show, { defaultTab })
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public showSettingsWindow(defaultTab?: string) {
|
||||
if (this.settingsWindow && !this.settingsWindow.isDestroyed()) {
|
||||
if (this.settingsWindow.isMinimized()) {
|
||||
this.settingsWindow.restore()
|
||||
}
|
||||
|
||||
if (!isLinux) {
|
||||
this.settingsWindow.setVisibleOnAllWorkspaces(true)
|
||||
}
|
||||
|
||||
this.settingsWindow.show()
|
||||
this.settingsWindow.focus()
|
||||
|
||||
if (!isLinux) {
|
||||
this.settingsWindow.setVisibleOnAllWorkspaces(false)
|
||||
}
|
||||
} else {
|
||||
this.createSettingsWindow(defaultTab)
|
||||
}
|
||||
}
|
||||
|
||||
public hideSettingsWindow() {
|
||||
if (this.settingsWindow && !this.settingsWindow.isDestroyed()) {
|
||||
this.settingsWindow.hide()
|
||||
}
|
||||
}
|
||||
|
||||
public closeSettingsWindow() {
|
||||
if (this.settingsWindow && !this.settingsWindow.isDestroyed()) {
|
||||
this.settingsWindow.close()
|
||||
}
|
||||
}
|
||||
|
||||
public getSettingsWindow(): BrowserWindow | null {
|
||||
return this.settingsWindow
|
||||
}
|
||||
|
||||
public static registerIpcHandler() {
|
||||
const { ipcMain } = require('electron')
|
||||
const service = SettingsWindowService.getInstance()
|
||||
|
||||
ipcMain.handle(IpcChannel.SettingsWindow_Show, (_, options?: { defaultTab?: string }) => {
|
||||
service.showSettingsWindow(options?.defaultTab)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const settingsWindowService = SettingsWindowService.getInstance()
|
||||
@@ -5,10 +5,12 @@ import Logger from 'electron-log'
|
||||
|
||||
import { configManager } from './ConfigManager'
|
||||
import selectionService from './SelectionService'
|
||||
import { settingsWindowService } from './SettingsWindowService'
|
||||
import { windowService } from './WindowService'
|
||||
|
||||
let showAppAccelerator: string | null = null
|
||||
let showMiniWindowAccelerator: string | null = null
|
||||
let showSettingsAccelerator: string | null = null
|
||||
let selectionAssistantToggleAccelerator: string | null = null
|
||||
let selectionAssistantSelectTextAccelerator: string | null = null
|
||||
|
||||
@@ -26,6 +28,10 @@ function getShortcutHandler(shortcut: Shortcut) {
|
||||
return (window: BrowserWindow) => handleZoomFactor([window], -0.1)
|
||||
case 'zoom_reset':
|
||||
return (window: BrowserWindow) => handleZoomFactor([window], 0, true)
|
||||
case 'show_settings':
|
||||
return () => {
|
||||
settingsWindowService.showSettingsWindow()
|
||||
}
|
||||
case 'show_app':
|
||||
return () => {
|
||||
windowService.toggleMainWindow()
|
||||
@@ -146,9 +152,13 @@ export function registerShortcuts(window: BrowserWindow) {
|
||||
// only register universal shortcuts when needed
|
||||
if (
|
||||
onlyUniversalShortcuts &&
|
||||
!['show_app', 'mini_window', 'selection_assistant_toggle', 'selection_assistant_select_text'].includes(
|
||||
shortcut.key
|
||||
)
|
||||
![
|
||||
'show_app',
|
||||
'mini_window',
|
||||
'show_settings',
|
||||
'selection_assistant_toggle',
|
||||
'selection_assistant_select_text'
|
||||
].includes(shortcut.key)
|
||||
) {
|
||||
return
|
||||
}
|
||||
@@ -171,6 +181,10 @@ export function registerShortcuts(window: BrowserWindow) {
|
||||
showMiniWindowAccelerator = formatShortcutKey(shortcut.shortcut)
|
||||
break
|
||||
|
||||
case 'show_settings':
|
||||
showSettingsAccelerator = formatShortcutKey(shortcut.shortcut)
|
||||
break
|
||||
|
||||
case 'selection_assistant_toggle':
|
||||
selectionAssistantToggleAccelerator = formatShortcutKey(shortcut.shortcut)
|
||||
break
|
||||
@@ -222,6 +236,12 @@ export function registerShortcuts(window: BrowserWindow) {
|
||||
handler && globalShortcut.register(accelerator, () => handler(window))
|
||||
}
|
||||
|
||||
if (showSettingsAccelerator) {
|
||||
const handler = getShortcutHandler({ key: 'show_settings' } as Shortcut)
|
||||
const accelerator = convertShortcutFormat(showSettingsAccelerator)
|
||||
handler && globalShortcut.register(accelerator, () => handler(window))
|
||||
}
|
||||
|
||||
if (selectionAssistantToggleAccelerator) {
|
||||
const handler = getShortcutHandler({ key: 'selection_assistant_toggle' } as Shortcut)
|
||||
const accelerator = convertShortcutFormat(selectionAssistantToggleAccelerator)
|
||||
@@ -258,6 +278,7 @@ export function unregisterAllShortcuts() {
|
||||
try {
|
||||
showAppAccelerator = null
|
||||
showMiniWindowAccelerator = null
|
||||
showSettingsAccelerator = null
|
||||
selectionAssistantToggleAccelerator = null
|
||||
selectionAssistantSelectTextAccelerator = null
|
||||
windowOnHandlers.forEach((handlers, window) => {
|
||||
|
||||
@@ -1,48 +1,48 @@
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { ThemeMode } from '@types'
|
||||
import { BrowserWindow, nativeTheme } from 'electron'
|
||||
|
||||
import { titleBarOverlayDark, titleBarOverlayLight } from '../config'
|
||||
import { configManager } from './ConfigManager'
|
||||
|
||||
class ThemeService {
|
||||
private theme: ThemeMode = ThemeMode.system
|
||||
constructor() {
|
||||
this.theme = configManager.getTheme()
|
||||
|
||||
if (this.theme === ThemeMode.dark || this.theme === ThemeMode.light || this.theme === ThemeMode.system) {
|
||||
nativeTheme.themeSource = this.theme
|
||||
} else {
|
||||
// 兼容旧版本
|
||||
configManager.setTheme(ThemeMode.system)
|
||||
nativeTheme.themeSource = ThemeMode.system
|
||||
}
|
||||
nativeTheme.on('updated', this.themeUpdatadHandler.bind(this))
|
||||
}
|
||||
|
||||
themeUpdatadHandler() {
|
||||
BrowserWindow.getAllWindows().forEach((win) => {
|
||||
if (win && !win.isDestroyed() && win.setTitleBarOverlay) {
|
||||
try {
|
||||
win.setTitleBarOverlay(nativeTheme.shouldUseDarkColors ? titleBarOverlayDark : titleBarOverlayLight)
|
||||
} catch (error) {
|
||||
// don't throw error if setTitleBarOverlay failed
|
||||
// Because it may be called with some windows have some title bar
|
||||
}
|
||||
}
|
||||
win.webContents.send(IpcChannel.ThemeUpdated, nativeTheme.shouldUseDarkColors ? ThemeMode.dark : ThemeMode.light)
|
||||
})
|
||||
}
|
||||
|
||||
setTheme(theme: ThemeMode) {
|
||||
if (theme === this.theme) {
|
||||
return
|
||||
}
|
||||
|
||||
this.theme = theme
|
||||
nativeTheme.themeSource = theme
|
||||
configManager.setTheme(theme)
|
||||
}
|
||||
}
|
||||
|
||||
export const themeService = new ThemeService()
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { ThemeMode } from '@types'
|
||||
import { BrowserWindow, nativeTheme } from 'electron'
|
||||
|
||||
import { titleBarOverlayDark, titleBarOverlayLight } from '../config'
|
||||
import { configManager } from './ConfigManager'
|
||||
|
||||
class ThemeService {
|
||||
private theme: ThemeMode = ThemeMode.system
|
||||
constructor() {
|
||||
this.theme = configManager.getTheme()
|
||||
|
||||
if (this.theme === ThemeMode.dark || this.theme === ThemeMode.light || this.theme === ThemeMode.system) {
|
||||
nativeTheme.themeSource = this.theme
|
||||
} else {
|
||||
// 兼容旧版本
|
||||
configManager.setTheme(ThemeMode.system)
|
||||
nativeTheme.themeSource = ThemeMode.system
|
||||
}
|
||||
nativeTheme.on('updated', this.themeUpdatadHandler.bind(this))
|
||||
}
|
||||
|
||||
themeUpdatadHandler() {
|
||||
BrowserWindow.getAllWindows().forEach((win) => {
|
||||
if (win && !win.isDestroyed() && win.setTitleBarOverlay) {
|
||||
try {
|
||||
win.setTitleBarOverlay(nativeTheme.shouldUseDarkColors ? titleBarOverlayDark : titleBarOverlayLight)
|
||||
} catch (error) {
|
||||
// don't throw error if setTitleBarOverlay failed
|
||||
// Because it may be called with some windows have some title bar
|
||||
}
|
||||
}
|
||||
win.webContents.send(IpcChannel.ThemeUpdated, nativeTheme.shouldUseDarkColors ? ThemeMode.dark : ThemeMode.light)
|
||||
})
|
||||
}
|
||||
|
||||
setTheme(theme: ThemeMode) {
|
||||
if (theme === this.theme) {
|
||||
return
|
||||
}
|
||||
|
||||
this.theme = theme
|
||||
nativeTheme.themeSource = theme
|
||||
configManager.setTheme(theme)
|
||||
}
|
||||
}
|
||||
|
||||
export const themeService = new ThemeService()
|
||||
|
||||
@@ -12,6 +12,7 @@ import { join } from 'path'
|
||||
|
||||
import icon from '../../../build/icon.png?asset'
|
||||
import { titleBarOverlayDark, titleBarOverlayLight } from '../config'
|
||||
import { CacheService } from './CacheService'
|
||||
import { configManager } from './ConfigManager'
|
||||
import { contextMenu } from './ContextMenu'
|
||||
import { initSessionUserAgent } from './WebviewService'
|
||||
@@ -63,7 +64,7 @@ export class WindowService {
|
||||
titleBarOverlay: nativeTheme.shouldUseDarkColors ? titleBarOverlayDark : titleBarOverlayLight,
|
||||
backgroundColor: isMac ? undefined : nativeTheme.shouldUseDarkColors ? '#181818' : '#FFFFFF',
|
||||
darkTheme: nativeTheme.shouldUseDarkColors,
|
||||
trafficLightPosition: { x: 8, y: 12 },
|
||||
trafficLightPosition: { x: 12, y: 12 },
|
||||
...(isLinux ? { icon } : {}),
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
@@ -313,6 +314,11 @@ export class WindowService {
|
||||
return app.quit()
|
||||
}
|
||||
|
||||
if (CacheService.get('navigation-url') !== '/') {
|
||||
event.preventDefault()
|
||||
return mainWindow.webContents.send(IpcChannel.Navigation_Close)
|
||||
}
|
||||
|
||||
// 托盘及关闭行为设置
|
||||
const isShowTray = configManager.getTray()
|
||||
const isTrayOnClose = configManager.getTrayOnClose()
|
||||
@@ -435,8 +441,9 @@ export class WindowService {
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
transparent: isMac,
|
||||
vibrancy: 'under-window',
|
||||
visualEffectState: 'followWindow',
|
||||
vibrancy: isMac ? 'under-window' : undefined,
|
||||
visualEffectState: isMac ? 'followWindow' : undefined,
|
||||
backgroundMaterial: isWin ? 'acrylic' : undefined,
|
||||
center: true,
|
||||
frame: false,
|
||||
alwaysOnTop: true,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isMac } from '@main/constant'
|
||||
import Logger from 'electron-log'
|
||||
|
||||
import { windowService } from '../WindowService'
|
||||
@@ -33,8 +34,13 @@ export async function handleProvidersProtocolUrl(url: URL) {
|
||||
(await mainWindow.webContents.executeJavaScript(`typeof window.navigate === 'function'`))
|
||||
) {
|
||||
mainWindow.webContents.executeJavaScript(`window.navigate('/settings/provider?addProviderData=${data}')`)
|
||||
|
||||
if (isMac) {
|
||||
windowService.showMainWindow()
|
||||
}
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
Logger.info('handleProvidersProtocolUrl timeout', { data, version })
|
||||
handleProvidersProtocolUrl(url)
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
@@ -44,7 +44,9 @@ export function handleMcpProtocolUrl(url: URL) {
|
||||
// }
|
||||
// }
|
||||
// cherrystudio://mcp/install?servers={base64Encode(JSON.stringify(jsonConfig))}
|
||||
|
||||
const data = params.get('servers')
|
||||
|
||||
if (data) {
|
||||
const stringify = Buffer.from(data, 'base64').toString('utf8')
|
||||
Logger.info('install MCP servers from urlschema: ', stringify)
|
||||
@@ -63,10 +65,8 @@ export function handleMcpProtocolUrl(url: URL) {
|
||||
}
|
||||
}
|
||||
|
||||
const mainWindow = windowService.getMainWindow()
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.executeJavaScript("window.navigate('/settings/mcp')")
|
||||
}
|
||||
windowService.getMainWindow()?.show()
|
||||
|
||||
break
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import * as fs from 'node:fs'
|
||||
import * as fsPromises from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { FileTypes } from '@types'
|
||||
import iconv from 'iconv-lite'
|
||||
import { detectAll as detectEncodingAll } from 'jschardet'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { readTextFileWithAutoEncoding } from '../file'
|
||||
import { getAllFiles, getAppConfigDir, getConfigDir, getFilesDir, getFileType, getTempDir } from '../file'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('node:fs')
|
||||
vi.mock('node:fs/promises')
|
||||
vi.mock('node:os')
|
||||
vi.mock('node:path')
|
||||
vi.mock('uuid', () => ({
|
||||
@@ -241,4 +246,54 @@ describe('file', () => {
|
||||
expect(appConfigDir).toBe('/mock/home/.cherrystudio/config/')
|
||||
})
|
||||
})
|
||||
|
||||
describe('readTextFileWithAutoEncoding', () => {
|
||||
const mockFilePath = '/path/to/mock/file.txt'
|
||||
|
||||
it('should read file with auto encoding', async () => {
|
||||
const content = '这是一段GB2312编码的测试内容'
|
||||
const buffer = iconv.encode(content, 'GB2312')
|
||||
|
||||
// 创建模拟的 FileHandle 对象
|
||||
const mockFileHandle = {
|
||||
read: vi.fn().mockResolvedValue({
|
||||
bytesRead: buffer.byteLength,
|
||||
buffer: buffer
|
||||
}),
|
||||
close: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
|
||||
// 模拟 open 方法
|
||||
vi.spyOn(fsPromises, 'open').mockResolvedValue(mockFileHandle as any)
|
||||
vi.spyOn(fsPromises, 'readFile').mockResolvedValue(buffer)
|
||||
|
||||
const result = await readTextFileWithAutoEncoding(mockFilePath)
|
||||
expect(result).toBe(content)
|
||||
})
|
||||
|
||||
it('should try to fix bad detected encoding', async () => {
|
||||
const content = '这是一段GB2312编码的测试内容'
|
||||
const buffer = iconv.encode(content, 'GB2312')
|
||||
|
||||
// 创建模拟的 FileHandle 对象
|
||||
const mockFileHandle = {
|
||||
read: vi.fn().mockResolvedValue({
|
||||
bytesRead: buffer.byteLength,
|
||||
buffer: buffer
|
||||
}),
|
||||
close: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
|
||||
// 模拟 fs.open 方法
|
||||
vi.spyOn(fsPromises, 'open').mockResolvedValue(mockFileHandle as any)
|
||||
vi.spyOn(fsPromises, 'readFile').mockResolvedValue(buffer)
|
||||
vi.mocked(vi.fn(detectEncodingAll)).mockReturnValue([
|
||||
{ encoding: 'UTF-8', confidence: 0.9 },
|
||||
{ encoding: 'GB2312', confidence: 0.8 }
|
||||
])
|
||||
|
||||
const result = await readTextFileWithAutoEncoding(mockFilePath)
|
||||
expect(result).toBe(content)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import * as fs from 'node:fs'
|
||||
import { open, readFile } from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { isLinux, isPortable } from '@main/constant'
|
||||
import { audioExts, documentExts, imageExts, textExts, videoExts } from '@shared/config/constant'
|
||||
import { audioExts, documentExts, imageExts, MB, textExts, videoExts } from '@shared/config/constant'
|
||||
import { FileMetadata, FileTypes } from '@types'
|
||||
import { app } from 'electron'
|
||||
import Logger from 'electron-log'
|
||||
import iconv from 'iconv-lite'
|
||||
import * as jschardet from 'jschardet'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
export function initAppDataDir() {
|
||||
@@ -202,3 +206,49 @@ export function getCacheDir() {
|
||||
export function getAppConfigDir(name: string) {
|
||||
return path.join(getConfigDir(), name)
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取文件内容并自动检测编码格式进行解码
|
||||
* @param filePath - 文件路径
|
||||
* @returns 解码后的文件内容
|
||||
*/
|
||||
export async function readTextFileWithAutoEncoding(filePath: string): Promise<string> {
|
||||
// 读取前1MB以检测编码
|
||||
const buffer = Buffer.alloc(1 * MB)
|
||||
const fh = await open(filePath, 'r')
|
||||
const { buffer: bufferRead } = await fh.read(buffer, 0, 1 * MB, 0)
|
||||
await fh.close()
|
||||
|
||||
// 获取文件编码格式,最多取前两个可能的编码
|
||||
const encodings = jschardet
|
||||
.detectAll(bufferRead)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
encoding: item.encoding === 'ascii' ? 'UTF-8' : item.encoding
|
||||
}))
|
||||
.filter((item, index, array) => array.findIndex((prevItem) => prevItem.encoding === item.encoding) === index)
|
||||
.slice(0, 2)
|
||||
|
||||
if (encodings.length === 0) {
|
||||
Logger.error('Failed to detect encoding. Use utf-8 to decode.')
|
||||
const data = await readFile(filePath)
|
||||
return iconv.decode(data, 'UTF-8')
|
||||
}
|
||||
|
||||
const data = await readFile(filePath)
|
||||
|
||||
for (const item of encodings) {
|
||||
const encoding = item.encoding
|
||||
const content = iconv.decode(data, encoding)
|
||||
if (content.includes('\uFFFD')) {
|
||||
Logger.error(
|
||||
`File ${filePath} was auto-detected as ${encoding} encoding, but contains invalid characters. Trying other encodings`
|
||||
)
|
||||
} else {
|
||||
return content
|
||||
}
|
||||
}
|
||||
|
||||
Logger.error(`File ${filePath} failed to decode with all possible encodings, trying UTF-8 encoding`)
|
||||
return iconv.decode(data, 'UTF-8')
|
||||
}
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
import { BrowserWindow } from 'electron'
|
||||
|
||||
import { configManager } from '../services/ConfigManager'
|
||||
|
||||
export function handleZoomFactor(wins: BrowserWindow[], delta: number, reset: boolean = false) {
|
||||
if (reset) {
|
||||
wins.forEach((win) => {
|
||||
win.webContents.setZoomFactor(1)
|
||||
})
|
||||
configManager.setZoomFactor(1)
|
||||
return
|
||||
}
|
||||
|
||||
if (delta === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentZoom = configManager.getZoomFactor()
|
||||
const newZoom = Number((currentZoom + delta).toFixed(1))
|
||||
if (newZoom >= 0.5 && newZoom <= 2.0) {
|
||||
wins.forEach((win) => {
|
||||
win.webContents.setZoomFactor(newZoom)
|
||||
})
|
||||
configManager.setZoomFactor(newZoom)
|
||||
}
|
||||
}
|
||||
import { BrowserWindow } from 'electron'
|
||||
|
||||
import { configManager } from '../services/ConfigManager'
|
||||
|
||||
export function handleZoomFactor(wins: BrowserWindow[], delta: number, reset: boolean = false) {
|
||||
if (reset) {
|
||||
wins.forEach((win) => {
|
||||
win.webContents.setZoomFactor(1)
|
||||
})
|
||||
configManager.setZoomFactor(1)
|
||||
return
|
||||
}
|
||||
|
||||
if (delta === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentZoom = configManager.getZoomFactor()
|
||||
const newZoom = Number((currentZoom + delta).toFixed(1))
|
||||
if (newZoom >= 0.5 && newZoom <= 2.0) {
|
||||
wins.forEach((win) => {
|
||||
win.webContents.setZoomFactor(newZoom)
|
||||
})
|
||||
configManager.setZoomFactor(newZoom)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
KnowledgeItem,
|
||||
MCPServer,
|
||||
Provider,
|
||||
S3Config,
|
||||
Shortcut,
|
||||
ThemeMode,
|
||||
WebDavConfig
|
||||
@@ -72,9 +73,9 @@ const api = {
|
||||
decompress: (text: Buffer) => ipcRenderer.invoke(IpcChannel.Zip_Decompress, text)
|
||||
},
|
||||
backup: {
|
||||
backup: (fileName: string, data: string, destinationPath?: string, skipBackupFile?: boolean) =>
|
||||
ipcRenderer.invoke(IpcChannel.Backup_Backup, fileName, data, destinationPath, skipBackupFile),
|
||||
restore: (backupPath: string) => ipcRenderer.invoke(IpcChannel.Backup_Restore, backupPath),
|
||||
backup: (filename: string, content: string, path: string, skipBackupFile: boolean) =>
|
||||
ipcRenderer.invoke(IpcChannel.Backup_Backup, filename, content, path, skipBackupFile),
|
||||
restore: (path: string) => ipcRenderer.invoke(IpcChannel.Backup_Restore, path),
|
||||
backupToWebdav: (data: string, webdavConfig: WebDavConfig) =>
|
||||
ipcRenderer.invoke(IpcChannel.Backup_BackupToWebdav, data, webdavConfig),
|
||||
restoreFromWebdav: (webdavConfig: WebDavConfig) =>
|
||||
@@ -86,14 +87,36 @@ const api = {
|
||||
createDirectory: (webdavConfig: WebDavConfig, path: string, options?: CreateDirectoryOptions) =>
|
||||
ipcRenderer.invoke(IpcChannel.Backup_CreateDirectory, webdavConfig, path, options),
|
||||
deleteWebdavFile: (fileName: string, webdavConfig: WebDavConfig) =>
|
||||
ipcRenderer.invoke(IpcChannel.Backup_DeleteWebdavFile, fileName, webdavConfig)
|
||||
ipcRenderer.invoke(IpcChannel.Backup_DeleteWebdavFile, fileName, webdavConfig),
|
||||
backupToLocalDir: (
|
||||
data: string,
|
||||
fileName: string,
|
||||
localConfig: { localBackupDir?: string; skipBackupFile?: boolean }
|
||||
) => ipcRenderer.invoke(IpcChannel.Backup_BackupToLocalDir, data, fileName, localConfig),
|
||||
restoreFromLocalBackup: (fileName: string, localBackupDir?: string) =>
|
||||
ipcRenderer.invoke(IpcChannel.Backup_RestoreFromLocalBackup, fileName, localBackupDir),
|
||||
listLocalBackupFiles: (localBackupDir?: string) =>
|
||||
ipcRenderer.invoke(IpcChannel.Backup_ListLocalBackupFiles, localBackupDir),
|
||||
deleteLocalBackupFile: (fileName: string, localBackupDir?: string) =>
|
||||
ipcRenderer.invoke(IpcChannel.Backup_DeleteLocalBackupFile, fileName, localBackupDir),
|
||||
setLocalBackupDir: (dirPath: string) => ipcRenderer.invoke(IpcChannel.Backup_SetLocalBackupDir, dirPath),
|
||||
checkWebdavConnection: (webdavConfig: WebDavConfig) =>
|
||||
ipcRenderer.invoke(IpcChannel.Backup_CheckConnection, webdavConfig),
|
||||
|
||||
backupToS3: (data: string, s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_BackupToS3, data, s3Config),
|
||||
restoreFromS3: (s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_RestoreFromS3, s3Config),
|
||||
listS3Files: (s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_ListS3Files, s3Config),
|
||||
deleteS3File: (fileName: string, s3Config: S3Config) =>
|
||||
ipcRenderer.invoke(IpcChannel.Backup_DeleteS3File, fileName, s3Config),
|
||||
checkS3Connection: (s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_CheckS3Connection, s3Config)
|
||||
},
|
||||
file: {
|
||||
select: (options?: OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.File_Select, options),
|
||||
upload: (file: FileMetadata) => ipcRenderer.invoke(IpcChannel.File_Upload, file),
|
||||
delete: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Delete, fileId),
|
||||
deleteDir: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_DeleteDir, dirPath),
|
||||
read: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Read, fileId),
|
||||
read: (fileId: string, detectEncoding?: boolean) =>
|
||||
ipcRenderer.invoke(IpcChannel.File_Read, fileId, detectEncoding),
|
||||
clear: () => ipcRenderer.invoke(IpcChannel.File_Clear),
|
||||
get: (filePath: string) => ipcRenderer.invoke(IpcChannel.File_Get, filePath),
|
||||
/**
|
||||
@@ -124,7 +147,8 @@ const api = {
|
||||
copy: (fileId: string, destPath: string) => ipcRenderer.invoke(IpcChannel.File_Copy, fileId, destPath),
|
||||
base64File: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64File, fileId),
|
||||
pdfInfo: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_GetPdfInfo, fileId),
|
||||
getPathForFile: (file: File) => webUtils.getPathForFile(file)
|
||||
getPathForFile: (file: File) => webUtils.getPathForFile(file),
|
||||
openFileWithRelativePath: (file: FileMetadata) => ipcRenderer.invoke(IpcChannel.File_OpenWithRelativePath, file)
|
||||
},
|
||||
fs: {
|
||||
read: (pathOrUrl: string, encoding?: BufferEncoding) => ipcRenderer.invoke(IpcChannel.Fs_Read, pathOrUrl, encoding)
|
||||
@@ -206,8 +230,8 @@ 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 }) =>
|
||||
ipcRenderer.invoke(IpcChannel.Mcp_CallTool, { server, name, args }),
|
||||
callTool: ({ server, name, args, callId }: { server: MCPServer; name: string; args: any; callId?: string }) =>
|
||||
ipcRenderer.invoke(IpcChannel.Mcp_CallTool, { server, name, args, callId }),
|
||||
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 }),
|
||||
@@ -215,7 +239,9 @@ const api = {
|
||||
getResource: ({ server, uri }: { server: MCPServer; uri: string }) =>
|
||||
ipcRenderer.invoke(IpcChannel.Mcp_GetResource, { server, uri }),
|
||||
getInstallInfo: () => ipcRenderer.invoke(IpcChannel.Mcp_GetInstallInfo),
|
||||
checkMcpConnectivity: (server: any) => ipcRenderer.invoke(IpcChannel.Mcp_CheckConnectivity, server)
|
||||
checkMcpConnectivity: (server: any) => ipcRenderer.invoke(IpcChannel.Mcp_CheckConnectivity, server),
|
||||
abortTool: (callId: string) => ipcRenderer.invoke(IpcChannel.Mcp_AbortTool, callId),
|
||||
setProgress: (progress: number) => ipcRenderer.invoke(IpcChannel.Mcp_SetProgress, progress)
|
||||
},
|
||||
python: {
|
||||
execute: (script: string, context?: Record<string, any>, timeout?: number) =>
|
||||
@@ -285,14 +311,32 @@ const api = {
|
||||
ipcRenderer.invoke(IpcChannel.Selection_SetRemeberWinSize, isRemeberWinSize),
|
||||
setFilterMode: (filterMode: string) => ipcRenderer.invoke(IpcChannel.Selection_SetFilterMode, filterMode),
|
||||
setFilterList: (filterList: string[]) => ipcRenderer.invoke(IpcChannel.Selection_SetFilterList, filterList),
|
||||
processAction: (actionItem: ActionItem) => ipcRenderer.invoke(IpcChannel.Selection_ProcessAction, actionItem),
|
||||
processAction: (actionItem: ActionItem, isFullScreen: boolean = false) =>
|
||||
ipcRenderer.invoke(IpcChannel.Selection_ProcessAction, actionItem, isFullScreen),
|
||||
closeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowClose),
|
||||
minimizeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowMinimize),
|
||||
pinActionWindow: (isPinned: boolean) => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowPin, isPinned)
|
||||
},
|
||||
quoteToMainWindow: (text: string) => ipcRenderer.invoke(IpcChannel.App_QuoteToMain, text),
|
||||
navigation: {
|
||||
url: (url: string) => ipcRenderer.invoke(IpcChannel.Navigation_Url, url)
|
||||
},
|
||||
setDisableHardwareAcceleration: (isDisable: boolean) =>
|
||||
ipcRenderer.invoke(IpcChannel.App_SetDisableHardwareAcceleration, isDisable)
|
||||
ipcRenderer.invoke(IpcChannel.App_SetDisableHardwareAcceleration, isDisable),
|
||||
|
||||
// Settings Window
|
||||
showSettingsWindow: (options?: { defaultTab?: string }) =>
|
||||
ipcRenderer.invoke(IpcChannel.SettingsWindow_Show, options),
|
||||
|
||||
on: (channel: string, func: any) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, ...args: any[]) => {
|
||||
func(...args)
|
||||
}
|
||||
ipcRenderer.on(channel, listener)
|
||||
return () => {
|
||||
ipcRenderer.off(channel, listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use `contextBridge` APIs to expose Electron APIs to
|
||||
|
||||
@@ -1,46 +1,45 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="initial-scale=1, width=device-width" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' 'unsafe-inline' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
|
||||
<title>Cherry Studio</title>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="initial-scale=1, width=device-width" />
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' 'unsafe-inline' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
|
||||
<title>Cherry Studio</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
#spinner {
|
||||
position: fixed;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#spinner {
|
||||
position: fixed;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
}
|
||||
#spinner img {
|
||||
width: 100px;
|
||||
border-radius: 50px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
#spinner img {
|
||||
width: 100px;
|
||||
border-radius: 50px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<div id="spinner">
|
||||
<img src="/src/assets/images/logo.png" />
|
||||
</div>
|
||||
<script>
|
||||
console.time('init')
|
||||
</script>
|
||||
<script type="module" src="/src/init.ts"></script>
|
||||
<script type="module" src="/src/entryPoint.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<div id="spinner">
|
||||
<img src="/src/assets/images/logo.png" />
|
||||
</div>
|
||||
<script>
|
||||
console.time('init')
|
||||
</script>
|
||||
<script type="module" src="/src/init.ts"></script>
|
||||
<script type="module" src="/src/entryPoint.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="initial-scale=1, width=device-width" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
|
||||
<title>Cherry Studio</title>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="initial-scale=1, width=device-width" />
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
|
||||
<title>Cherry Studio</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/windows/mini/entryPoint.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/windows/mini/entryPoint.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,41 +1,39 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="initial-scale=1, width=device-width" />
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
|
||||
<title>Cherry Studio Selection Assistant</title>
|
||||
</head>
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/windows/selection/action/entryPoint.tsx"></script>
|
||||
<style>
|
||||
html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
#root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,46 +1,43 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="initial-scale=1, width=device-width" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
|
||||
<title>Cherry Studio Selection Toolbar</title>
|
||||
</head>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="initial-scale=1, width=device-width" />
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
|
||||
<title>Cherry Studio Selection Toolbar</title>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/windows/selection/toolbar/entryPoint.tsx"></script>
|
||||
<style>
|
||||
html {
|
||||
margin: 0 !important;
|
||||
background-color: transparent !important;
|
||||
background-image: none !important;
|
||||
}
|
||||
|
||||
</head>
|
||||
body {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
overflow: hidden !important;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/windows/selection/toolbar/entryPoint.tsx"></script>
|
||||
<style>
|
||||
html {
|
||||
margin: 0 !important;
|
||||
background-color: transparent !important;
|
||||
background-image: none !important;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
overflow: hidden !important;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#root {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
width: max-content !important;
|
||||
height: fit-content !important;
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
#root {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
width: max-content !important;
|
||||
height: fit-content !important;
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
23
src/renderer/settingsWindow.html
Normal file
23
src/renderer/settingsWindow.html
Normal file
@@ -0,0 +1,23 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="initial-scale=1, width=device-width" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
|
||||
<title>Cherry Studio</title>
|
||||
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/windows/settings/entryPoint.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -2,10 +2,10 @@ import '@renderer/databases'
|
||||
|
||||
import store, { persistor } from '@renderer/store'
|
||||
import { Provider } from 'react-redux'
|
||||
import { HashRouter, Route, Routes } from 'react-router-dom'
|
||||
import { HashRouter } from 'react-router-dom'
|
||||
import { PersistGate } from 'redux-persist/integration/react'
|
||||
|
||||
import Sidebar from './components/app/Sidebar'
|
||||
import AppLayout from './components/Layout/AppLayout'
|
||||
import TopViewContainer from './components/TopView'
|
||||
import AntdProvider from './context/AntdProvider'
|
||||
import { CodeStyleProvider } from './context/CodeStyleProvider'
|
||||
@@ -13,14 +13,8 @@ import { NotificationProvider } from './context/NotificationProvider'
|
||||
import StyleSheetManager from './context/StyleSheetManager'
|
||||
import { ThemeProvider } from './context/ThemeProvider'
|
||||
import NavigationHandler from './handler/NavigationHandler'
|
||||
import AgentsPage from './pages/agents/AgentsPage'
|
||||
import AppsPage from './pages/apps/AppsPage'
|
||||
import FilesPage from './pages/files/FilesPage'
|
||||
import HomePage from './pages/home/HomePage'
|
||||
import KnowledgePage from './pages/knowledge/KnowledgePage'
|
||||
import PaintingsRoutePage from './pages/paintings/PaintingsRoutePage'
|
||||
import SettingsPage from './pages/settings/SettingsPage'
|
||||
import TranslatePage from './pages/translate/TranslatePage'
|
||||
import { ChatProvider } from './hooks/useChat'
|
||||
import Routes from './Routes'
|
||||
|
||||
function App(): React.ReactElement {
|
||||
return (
|
||||
@@ -31,22 +25,16 @@ function App(): React.ReactElement {
|
||||
<NotificationProvider>
|
||||
<CodeStyleProvider>
|
||||
<PersistGate loading={null} persistor={persistor}>
|
||||
<TopViewContainer>
|
||||
<HashRouter>
|
||||
<HashRouter>
|
||||
<TopViewContainer>
|
||||
<NavigationHandler />
|
||||
<Sidebar />
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/agents" element={<AgentsPage />} />
|
||||
<Route path="/paintings/*" element={<PaintingsRoutePage />} />
|
||||
<Route path="/translate" element={<TranslatePage />} />
|
||||
<Route path="/files" element={<FilesPage />} />
|
||||
<Route path="/knowledge" element={<KnowledgePage />} />
|
||||
<Route path="/apps" element={<AppsPage />} />
|
||||
<Route path="/settings/*" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
</TopViewContainer>
|
||||
<ChatProvider>
|
||||
<AppLayout>
|
||||
<Routes />
|
||||
</AppLayout>
|
||||
</ChatProvider>
|
||||
</TopViewContainer>
|
||||
</HashRouter>
|
||||
</PersistGate>
|
||||
</CodeStyleProvider>
|
||||
</NotificationProvider>
|
||||
|
||||
29
src/renderer/src/Routes.tsx
Normal file
29
src/renderer/src/Routes.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Route, Routes } from 'react-router-dom'
|
||||
|
||||
import AgentsPage from './pages/agents/AgentsPage'
|
||||
import AppsPage from './pages/apps/AppsPage'
|
||||
import FilesPage from './pages/files/FilesPage'
|
||||
import HomePage from './pages/home/HomePage'
|
||||
import KnowledgePage from './pages/knowledge/KnowledgePage'
|
||||
import McpServersPage from './pages/mcp-servers'
|
||||
import PaintingsRoutePage from './pages/paintings/PaintingsRoutePage'
|
||||
import TranslatePage from './pages/translate/TranslatePage'
|
||||
|
||||
const RouteContainer = () => {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/agents" element={<AgentsPage />} />
|
||||
<Route path="/paintings/*" element={<PaintingsRoutePage />} />
|
||||
<Route path="/translate" element={<TranslatePage />} />
|
||||
<Route path="/files" element={<FilesPage />} />
|
||||
<Route path="/knowledge" element={<KnowledgePage />} />
|
||||
<Route path="/apps" element={<AppsPage />} />
|
||||
<Route path="/mcp-servers/*" element={<McpServersPage />} />
|
||||
{/* <Route path="/settings/*" element={<SettingsPage />} />
|
||||
<Route path="/launchpad" element={<LaunchpadPage />} /> */}
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
export default RouteContainer
|
||||
@@ -47,10 +47,9 @@ export class ApiClientFactory {
|
||||
// 然后检查标准的provider type
|
||||
switch (provider.type) {
|
||||
case 'openai':
|
||||
case 'azure-openai':
|
||||
console.log(`[ApiClientFactory] Creating OpenAIApiClient for provider: ${provider.id}`)
|
||||
instance = new OpenAIAPIClient(provider) as BaseApiClient
|
||||
break
|
||||
case 'azure-openai':
|
||||
case 'openai-response':
|
||||
instance = new OpenAIResponseAPIClient(provider) as BaseApiClient
|
||||
break
|
||||
|
||||
@@ -254,7 +254,7 @@ export abstract class BaseApiClient<
|
||||
|
||||
for (const fileBlock of textFileBlocks) {
|
||||
const file = fileBlock.file
|
||||
const fileContent = (await window.api.file.read(file.id + file.ext)).trim()
|
||||
const fileContent = (await window.api.file.read(file.id + file.ext, true)).trim()
|
||||
const fileNameRow = 'file: ' + file.origin_name + '\n\n'
|
||||
text = text + fileNameRow + fileContent + divider
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ export class NewAPIClient extends BaseApiClient {
|
||||
return client
|
||||
}
|
||||
|
||||
if (model.endpoint_type === 'openai') {
|
||||
if (model.endpoint_type === 'openai' || model.endpoint_type === 'image-generation') {
|
||||
const client = this.clients.get('openai')
|
||||
if (!client || !this.isValidClient(client)) {
|
||||
throw new Error('Failed to get openai client')
|
||||
|
||||
@@ -49,10 +49,10 @@ import {
|
||||
LLMWebSearchCompleteChunk,
|
||||
LLMWebSearchInProgressChunk,
|
||||
MCPToolCreatedChunk,
|
||||
TextCompleteChunk,
|
||||
TextDeltaChunk,
|
||||
ThinkingCompleteChunk,
|
||||
ThinkingDeltaChunk
|
||||
TextStartChunk,
|
||||
ThinkingDeltaChunk,
|
||||
ThinkingStartChunk
|
||||
} from '@renderer/types/chunk'
|
||||
import { type Message } from '@renderer/types/newMessage'
|
||||
import {
|
||||
@@ -231,7 +231,7 @@ export class AnthropicAPIClient extends BaseApiClient<
|
||||
}
|
||||
})
|
||||
} else {
|
||||
const fileContent = await (await window.api.file.read(file.id + file.ext)).trim()
|
||||
const fileContent = await (await window.api.file.read(file.id + file.ext, true)).trim()
|
||||
parts.push({
|
||||
type: 'text',
|
||||
text: file.origin_name + '\n' + fileContent
|
||||
@@ -519,7 +519,6 @@ export class AnthropicAPIClient extends BaseApiClient<
|
||||
return () => {
|
||||
let accumulatedJson = ''
|
||||
const toolCalls: Record<number, ToolUseBlock> = {}
|
||||
const ChunkIdTypeMap: Record<number, ChunkType> = {}
|
||||
return {
|
||||
async transform(rawChunk: AnthropicSdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
|
||||
switch (rawChunk.type) {
|
||||
@@ -615,16 +614,16 @@ export class AnthropicAPIClient extends BaseApiClient<
|
||||
break
|
||||
}
|
||||
case 'text': {
|
||||
if (!ChunkIdTypeMap[rawChunk.index]) {
|
||||
ChunkIdTypeMap[rawChunk.index] = ChunkType.TEXT_DELTA // 用textdelta代表文本块
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_START
|
||||
} as TextStartChunk)
|
||||
break
|
||||
}
|
||||
case 'thinking':
|
||||
case 'redacted_thinking': {
|
||||
if (!ChunkIdTypeMap[rawChunk.index]) {
|
||||
ChunkIdTypeMap[rawChunk.index] = ChunkType.THINKING_DELTA // 用thinkingdelta代表思考块
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_START
|
||||
} as ThinkingStartChunk)
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -661,15 +660,6 @@ export class AnthropicAPIClient extends BaseApiClient<
|
||||
break
|
||||
}
|
||||
case 'content_block_stop': {
|
||||
if (ChunkIdTypeMap[rawChunk.index] === ChunkType.TEXT_DELTA) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_COMPLETE
|
||||
} as TextCompleteChunk)
|
||||
} else if (ChunkIdTypeMap[rawChunk.index] === ChunkType.THINKING_DELTA) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_COMPLETE
|
||||
} as ThinkingCompleteChunk)
|
||||
}
|
||||
const toolCall = toolCalls[rawChunk.index]
|
||||
if (toolCall) {
|
||||
try {
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
ToolCallResponse,
|
||||
WebSearchSource
|
||||
} from '@renderer/types'
|
||||
import { ChunkType, LLMWebSearchCompleteChunk } from '@renderer/types/chunk'
|
||||
import { ChunkType, LLMWebSearchCompleteChunk, TextStartChunk, ThinkingStartChunk } from '@renderer/types/chunk'
|
||||
import { Message } from '@renderer/types/newMessage'
|
||||
import {
|
||||
GeminiOptions,
|
||||
@@ -288,7 +288,7 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
continue
|
||||
}
|
||||
if ([FileTypes.TEXT, FileTypes.DOCUMENT].includes(file.type)) {
|
||||
const fileContent = await (await window.api.file.read(file.id + file.ext)).trim()
|
||||
const fileContent = await (await window.api.file.read(file.id + file.ext, true)).trim()
|
||||
parts.push({
|
||||
text: file.origin_name + '\n' + fileContent
|
||||
})
|
||||
@@ -547,20 +547,34 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
}
|
||||
|
||||
getResponseChunkTransformer(): ResponseChunkTransformer<GeminiSdkRawChunk> {
|
||||
const toolCalls: FunctionCall[] = []
|
||||
let isFirstTextChunk = true
|
||||
let isFirstThinkingChunk = true
|
||||
return () => ({
|
||||
async transform(chunk: GeminiSdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
|
||||
const toolCalls: FunctionCall[] = []
|
||||
if (chunk.candidates && chunk.candidates.length > 0) {
|
||||
for (const candidate of chunk.candidates) {
|
||||
if (candidate.content) {
|
||||
candidate.content.parts?.forEach((part) => {
|
||||
const text = part.text || ''
|
||||
if (part.thought) {
|
||||
if (isFirstThinkingChunk) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_START
|
||||
} as ThinkingStartChunk)
|
||||
isFirstThinkingChunk = false
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_DELTA,
|
||||
text: text
|
||||
})
|
||||
} else if (part.text) {
|
||||
if (isFirstTextChunk) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_START
|
||||
} as TextStartChunk)
|
||||
isFirstTextChunk = false
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_DELTA,
|
||||
text: text
|
||||
@@ -593,6 +607,13 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
}
|
||||
} as LLMWebSearchCompleteChunk)
|
||||
}
|
||||
if (toolCalls.length > 0) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.MCP_TOOL_CREATED,
|
||||
tool_calls: [...toolCalls]
|
||||
})
|
||||
toolCalls.length = 0
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.LLM_RESPONSE_COMPLETE,
|
||||
response: {
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
ToolCallResponse,
|
||||
WebSearchSource
|
||||
} from '@renderer/types'
|
||||
import { ChunkType } from '@renderer/types/chunk'
|
||||
import { ChunkType, TextStartChunk, ThinkingStartChunk } from '@renderer/types/chunk'
|
||||
import { Message } from '@renderer/types/newMessage'
|
||||
import {
|
||||
OpenAISdkMessageParam,
|
||||
@@ -307,7 +307,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
}
|
||||
|
||||
if ([FileTypes.TEXT, FileTypes.DOCUMENT].includes(file.type)) {
|
||||
const fileContent = await (await window.api.file.read(file.id + file.ext)).trim()
|
||||
const fileContent = await (await window.api.file.read(file.id + file.ext, true)).trim()
|
||||
parts.push({
|
||||
type: 'text',
|
||||
text: file.origin_name + '\n' + fileContent
|
||||
@@ -659,6 +659,8 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
isFinished = true
|
||||
}
|
||||
|
||||
let isFirstThinkingChunk = true
|
||||
let isFirstTextChunk = true
|
||||
return (context: ResponseChunkTransformerContext) => ({
|
||||
async transform(chunk: OpenAISdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
|
||||
// 持续更新usage信息
|
||||
@@ -699,6 +701,12 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
// @ts-ignore - reasoning_content is not in standard OpenAI types but some providers use it
|
||||
const reasoningText = contentSource.reasoning_content || contentSource.reasoning
|
||||
if (reasoningText) {
|
||||
if (isFirstThinkingChunk) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_START
|
||||
} as ThinkingStartChunk)
|
||||
isFirstThinkingChunk = false
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_DELTA,
|
||||
text: reasoningText
|
||||
@@ -707,6 +715,12 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
|
||||
// 处理文本内容
|
||||
if (contentSource.content) {
|
||||
if (isFirstTextChunk) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_START
|
||||
} as TextStartChunk)
|
||||
isFirstTextChunk = false
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_DELTA,
|
||||
text: contentSource.content
|
||||
|
||||
@@ -89,7 +89,7 @@ export abstract class OpenAIBaseClient<
|
||||
const data = await sdk.embeddings.create({
|
||||
model: model.id,
|
||||
input: model?.provider === 'baidu-cloud' ? ['hi'] : 'hi',
|
||||
encoding_format: 'float'
|
||||
encoding_format: this.provider.id === 'voyageai' ? undefined : 'float'
|
||||
})
|
||||
return data.data[0].embedding.length
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { GenericChunk } from '@renderer/aiCore/middleware/schemas'
|
||||
import { CompletionsContext } from '@renderer/aiCore/middleware/types'
|
||||
import {
|
||||
isOpenAIChatCompletionOnlyModel,
|
||||
isOpenAILLMModel,
|
||||
isSupportedReasoningEffortOpenAIModel,
|
||||
isVisionModel
|
||||
} from '@renderer/config/models'
|
||||
@@ -38,7 +39,7 @@ import { findFileBlocks, findImageBlocks } from '@renderer/utils/messageUtils/fi
|
||||
import { buildSystemPrompt } from '@renderer/utils/prompt'
|
||||
import { MB } from '@shared/config/constant'
|
||||
import { isEmpty } from 'lodash'
|
||||
import OpenAI from 'openai'
|
||||
import OpenAI, { AzureOpenAI } from 'openai'
|
||||
import { ResponseInput } from 'openai/resources/responses/responses'
|
||||
|
||||
import { RequestTransformer, ResponseChunkTransformer } from '../types'
|
||||
@@ -64,10 +65,13 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
* 根据模型特征选择合适的客户端
|
||||
*/
|
||||
public getClient(model: Model) {
|
||||
if (isOpenAIChatCompletionOnlyModel(model)) {
|
||||
return this.client
|
||||
} else {
|
||||
if (isOpenAILLMModel(model) && !isOpenAIChatCompletionOnlyModel(model)) {
|
||||
if (this.provider.id === 'azure-openai' || this.provider.type === 'azure-openai') {
|
||||
this.provider = { ...this.provider, apiVersion: 'preview' }
|
||||
}
|
||||
return this
|
||||
} else {
|
||||
return this.client
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,15 +80,25 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
return this.sdkInstance
|
||||
}
|
||||
|
||||
return new OpenAI({
|
||||
dangerouslyAllowBrowser: true,
|
||||
apiKey: this.apiKey,
|
||||
baseURL: this.getBaseURL(),
|
||||
defaultHeaders: {
|
||||
...this.defaultHeaders(),
|
||||
...this.provider.extra_headers
|
||||
}
|
||||
})
|
||||
if (this.provider.id === 'azure-openai' || this.provider.type === 'azure-openai') {
|
||||
this.provider = { ...this.provider, apiHost: `${this.provider.apiHost}/openai/v1` }
|
||||
return new AzureOpenAI({
|
||||
dangerouslyAllowBrowser: true,
|
||||
apiKey: this.apiKey,
|
||||
apiVersion: this.provider.apiVersion,
|
||||
baseURL: this.provider.apiHost
|
||||
})
|
||||
} else {
|
||||
return new OpenAI({
|
||||
dangerouslyAllowBrowser: true,
|
||||
apiKey: this.apiKey,
|
||||
baseURL: this.getBaseURL(),
|
||||
defaultHeaders: {
|
||||
...this.defaultHeaders(),
|
||||
...this.provider.extra_headers
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
override async createCompletions(
|
||||
@@ -172,7 +186,7 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
}
|
||||
|
||||
if ([FileTypes.TEXT, FileTypes.DOCUMENT].includes(file.type)) {
|
||||
const fileContent = (await window.api.file.read(file.id + file.ext)).trim()
|
||||
const fileContent = (await window.api.file.read(file.id + file.ext, true)).trim()
|
||||
parts.push({
|
||||
type: 'input_text',
|
||||
text: file.origin_name + '\n' + fileContent
|
||||
@@ -353,16 +367,15 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
(m) => (m as OpenAI.Responses.EasyInputMessage).role === 'assistant'
|
||||
) as OpenAI.Responses.EasyInputMessage
|
||||
const finalUserMessage = userMessage.pop() as OpenAI.Responses.EasyInputMessage
|
||||
if (
|
||||
finalAssistantMessage &&
|
||||
Array.isArray(finalAssistantMessage.content) &&
|
||||
finalUserMessage &&
|
||||
Array.isArray(finalUserMessage.content)
|
||||
) {
|
||||
finalAssistantMessage.content = [...finalAssistantMessage.content, ...finalUserMessage.content]
|
||||
if (finalUserMessage && Array.isArray(finalUserMessage.content)) {
|
||||
if (finalAssistantMessage && Array.isArray(finalAssistantMessage.content)) {
|
||||
finalAssistantMessage.content = [...finalAssistantMessage.content, ...finalUserMessage.content]
|
||||
// 这里是故意将上条助手消息的内容(包含图片和文件)作为用户消息发送
|
||||
userMessage = [{ ...finalAssistantMessage, role: 'user' } as OpenAI.Responses.EasyInputMessage]
|
||||
} else {
|
||||
userMessage.push(finalUserMessage)
|
||||
}
|
||||
}
|
||||
// 这里是故意将上条助手消息的内容(包含图片和文件)作为用户消息发送
|
||||
userMessage = [{ ...finalAssistantMessage, role: 'user' } as OpenAI.Responses.EasyInputMessage]
|
||||
}
|
||||
|
||||
// 4. 最终请求消息
|
||||
@@ -423,6 +436,8 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
const outputItems: OpenAI.Responses.ResponseOutputItem[] = []
|
||||
let hasBeenCollectedToolCalls = false
|
||||
let hasReasoningSummary = false
|
||||
let isFirstThinkingChunk = true
|
||||
let isFirstTextChunk = true
|
||||
return () => ({
|
||||
async transform(chunk: OpenAIResponseSdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
|
||||
// 处理chunk
|
||||
@@ -434,6 +449,12 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
switch (output.type) {
|
||||
case 'message':
|
||||
if (output.content[0].type === 'output_text') {
|
||||
if (isFirstTextChunk) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_START
|
||||
})
|
||||
isFirstTextChunk = false
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_DELTA,
|
||||
text: output.content[0].text
|
||||
@@ -450,6 +471,12 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
}
|
||||
break
|
||||
case 'reasoning':
|
||||
if (isFirstThinkingChunk) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_START
|
||||
})
|
||||
isFirstThinkingChunk = false
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_DELTA,
|
||||
text: output.summary.map((s) => s.text).join('\n')
|
||||
@@ -509,6 +536,12 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
hasReasoningSummary = true
|
||||
break
|
||||
case 'response.reasoning_summary_text.delta':
|
||||
if (isFirstThinkingChunk) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_START
|
||||
})
|
||||
isFirstThinkingChunk = false
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_DELTA,
|
||||
text: chunk.delta
|
||||
@@ -534,6 +567,12 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
})
|
||||
break
|
||||
case 'response.output_text.delta': {
|
||||
if (isFirstTextChunk) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_START
|
||||
})
|
||||
isFirstTextChunk = false
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_DELTA,
|
||||
text: chunk.delta
|
||||
|
||||
@@ -75,11 +75,12 @@ export default class AiProvider {
|
||||
} else {
|
||||
// Existing logic for other models
|
||||
if (!params.enableReasoning) {
|
||||
builder.remove(ThinkingTagExtractionMiddlewareName)
|
||||
// 这里注释掉不会影响正常的关闭思考,可忽略不计的性能下降
|
||||
// builder.remove(ThinkingTagExtractionMiddlewareName)
|
||||
builder.remove(ThinkChunkMiddlewareName)
|
||||
}
|
||||
// 注意:用client判断会导致typescript类型收窄
|
||||
if (!(this.apiClient instanceof OpenAIAPIClient)) {
|
||||
if (!(this.apiClient instanceof OpenAIAPIClient) && !(this.apiClient instanceof OpenAIResponseAPIClient)) {
|
||||
builder.remove(ThinkingTagExtractionMiddlewareName)
|
||||
}
|
||||
if (!(this.apiClient instanceof AnthropicAPIClient) && !(this.apiClient instanceof OpenAIResponseAPIClient)) {
|
||||
|
||||
@@ -67,7 +67,12 @@ export const AbortHandlerMiddleware: CompletionsMiddleware =
|
||||
const streamWithAbortHandler = (result.stream as ReadableStream<Chunk>).pipeThrough(
|
||||
new TransformStream<Chunk, Chunk | ErrorChunk>({
|
||||
transform(chunk, controller) {
|
||||
// 检查 abort 状态
|
||||
// 如果已经收到错误块,不再检查 abort 状态
|
||||
if (chunk.type === ChunkType.ERROR) {
|
||||
controller.enqueue(chunk)
|
||||
return
|
||||
}
|
||||
|
||||
if (abortSignal?.aborted) {
|
||||
// 转换为 ErrorChunk
|
||||
const errorChunk: ErrorChunk = {
|
||||
|
||||
@@ -136,7 +136,6 @@ function extractAndAccumulateUsageMetrics(ctx: CompletionsContext, chunk: Generi
|
||||
Logger.debug(`[${MIDDLEWARE_NAME}] First token timestamp: ${ctx._internal.customState.firstTokenTimestamp}`)
|
||||
}
|
||||
if (chunk.type === ChunkType.LLM_RESPONSE_COMPLETE) {
|
||||
Logger.debug(`[${MIDDLEWARE_NAME}] LLM_RESPONSE_COMPLETE chunk received:`, ctx._internal)
|
||||
// 从LLM_RESPONSE_COMPLETE chunk中提取usage数据
|
||||
if (chunk.response?.usage) {
|
||||
accumulateUsage(ctx._internal.observer.usage, chunk.response.usage)
|
||||
|
||||
@@ -89,6 +89,11 @@ function createToolHandlingTransform(
|
||||
let hasToolUseResponses = false
|
||||
let streamEnded = false
|
||||
|
||||
// 存储已执行的工具结果
|
||||
const executedToolResults: SdkMessageParam[] = []
|
||||
const executedToolCalls: SdkToolCall[] = []
|
||||
const executionPromises: Promise<void>[] = []
|
||||
|
||||
return new TransformStream({
|
||||
async transform(chunk: GenericChunk, controller) {
|
||||
try {
|
||||
@@ -98,22 +103,64 @@ function createToolHandlingTransform(
|
||||
|
||||
// 1. 处理Function Call方式的工具调用
|
||||
if (createdChunk.tool_calls && createdChunk.tool_calls.length > 0) {
|
||||
toolCalls.push(...createdChunk.tool_calls)
|
||||
hasToolCalls = true
|
||||
|
||||
for (const toolCall of createdChunk.tool_calls) {
|
||||
toolCalls.push(toolCall)
|
||||
|
||||
const executionPromise = (async () => {
|
||||
try {
|
||||
const result = await executeToolCalls(
|
||||
ctx,
|
||||
[toolCall],
|
||||
mcpTools,
|
||||
allToolResponses,
|
||||
currentParams.onChunk,
|
||||
currentParams.assistant.model!
|
||||
)
|
||||
|
||||
// 缓存执行结果
|
||||
executedToolResults.push(...result.toolResults)
|
||||
executedToolCalls.push(...result.confirmedToolCalls)
|
||||
} catch (error) {
|
||||
console.error(`🔧 [${MIDDLEWARE_NAME}] Error executing tool call asynchronously:`, error)
|
||||
}
|
||||
})()
|
||||
|
||||
executionPromises.push(executionPromise)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 处理Tool Use方式的工具调用
|
||||
if (createdChunk.tool_use_responses && createdChunk.tool_use_responses.length > 0) {
|
||||
toolUseResponses.push(...createdChunk.tool_use_responses)
|
||||
hasToolUseResponses = true
|
||||
for (const toolUseResponse of createdChunk.tool_use_responses) {
|
||||
toolUseResponses.push(toolUseResponse)
|
||||
const executionPromise = (async () => {
|
||||
try {
|
||||
const result = await executeToolUseResponses(
|
||||
ctx,
|
||||
[toolUseResponse], // 单个执行
|
||||
mcpTools,
|
||||
allToolResponses,
|
||||
currentParams.onChunk,
|
||||
currentParams.assistant.model!
|
||||
)
|
||||
|
||||
// 缓存执行结果
|
||||
executedToolResults.push(...result.toolResults)
|
||||
} catch (error) {
|
||||
console.error(`🔧 [${MIDDLEWARE_NAME}] Error executing tool use response asynchronously:`, error)
|
||||
// 错误时不影响其他工具的执行
|
||||
}
|
||||
})()
|
||||
|
||||
executionPromises.push(executionPromise)
|
||||
}
|
||||
}
|
||||
|
||||
// 不转发MCP工具进展chunks,避免重复处理
|
||||
return
|
||||
} else {
|
||||
controller.enqueue(chunk)
|
||||
}
|
||||
|
||||
// 转发其他所有chunk
|
||||
controller.enqueue(chunk)
|
||||
} catch (error) {
|
||||
console.error(`🔧 [${MIDDLEWARE_NAME}] Error processing chunk:`, error)
|
||||
controller.error(error)
|
||||
@@ -121,43 +168,33 @@ function createToolHandlingTransform(
|
||||
},
|
||||
|
||||
async flush(controller) {
|
||||
const shouldExecuteToolCalls = hasToolCalls && toolCalls.length > 0
|
||||
const shouldExecuteToolUseResponses = hasToolUseResponses && toolUseResponses.length > 0
|
||||
|
||||
if (!streamEnded && (shouldExecuteToolCalls || shouldExecuteToolUseResponses)) {
|
||||
// 在流结束时等待所有异步工具执行完成,然后进行递归调用
|
||||
if (!streamEnded && (hasToolCalls || hasToolUseResponses)) {
|
||||
streamEnded = true
|
||||
|
||||
try {
|
||||
let toolResult: SdkMessageParam[] = []
|
||||
|
||||
if (shouldExecuteToolCalls) {
|
||||
toolResult = await executeToolCalls(
|
||||
ctx,
|
||||
toolCalls,
|
||||
mcpTools,
|
||||
allToolResponses,
|
||||
currentParams.onChunk,
|
||||
currentParams.assistant.model!
|
||||
)
|
||||
} else if (shouldExecuteToolUseResponses) {
|
||||
toolResult = await executeToolUseResponses(
|
||||
ctx,
|
||||
toolUseResponses,
|
||||
mcpTools,
|
||||
allToolResponses,
|
||||
currentParams.onChunk,
|
||||
currentParams.assistant.model!
|
||||
)
|
||||
}
|
||||
|
||||
if (toolResult.length > 0) {
|
||||
await Promise.all(executionPromises)
|
||||
if (executedToolResults.length > 0) {
|
||||
const output = ctx._internal.toolProcessingState?.output
|
||||
const newParams = buildParamsWithToolResults(
|
||||
ctx,
|
||||
currentParams,
|
||||
output,
|
||||
executedToolResults,
|
||||
executedToolCalls
|
||||
)
|
||||
|
||||
// 在递归调用前通知UI开始新的LLM响应处理
|
||||
if (currentParams.onChunk) {
|
||||
currentParams.onChunk({
|
||||
type: ChunkType.LLM_RESPONSE_CREATED
|
||||
})
|
||||
}
|
||||
|
||||
const newParams = buildParamsWithToolResults(ctx, currentParams, output, toolResult, toolCalls)
|
||||
await executeWithToolHandling(newParams, depth + 1)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`🔧 [${MIDDLEWARE_NAME}] Error in tool processing:`, error)
|
||||
Logger.error(`🔧 [${MIDDLEWARE_NAME}] Error in tool processing:`, error)
|
||||
controller.error(error)
|
||||
} finally {
|
||||
hasToolCalls = false
|
||||
@@ -178,8 +215,7 @@ async function executeToolCalls(
|
||||
allToolResponses: MCPToolResponse[],
|
||||
onChunk: CompletionsParams['onChunk'],
|
||||
model: Model
|
||||
): Promise<SdkMessageParam[]> {
|
||||
// 转换为MCPToolResponse格式
|
||||
): Promise<{ toolResults: SdkMessageParam[]; confirmedToolCalls: SdkToolCall[] }> {
|
||||
const mcpToolResponses: ToolCallResponse[] = toolCalls
|
||||
.map((toolCall) => {
|
||||
const mcpTool = ctx.apiClientInstance.convertSdkToolCallToMcp(toolCall, mcpTools)
|
||||
@@ -192,11 +228,11 @@ async function executeToolCalls(
|
||||
|
||||
if (mcpToolResponses.length === 0) {
|
||||
console.warn(`🔧 [${MIDDLEWARE_NAME}] No valid MCP tool responses to execute`)
|
||||
return []
|
||||
return { toolResults: [], confirmedToolCalls: [] }
|
||||
}
|
||||
|
||||
// 使用现有的parseAndCallTools函数执行工具
|
||||
const toolResults = await parseAndCallTools(
|
||||
const { toolResults, confirmedToolResponses } = await parseAndCallTools(
|
||||
mcpToolResponses,
|
||||
allToolResponses,
|
||||
onChunk,
|
||||
@@ -204,10 +240,26 @@ async function executeToolCalls(
|
||||
return ctx.apiClientInstance.convertMcpToolResponseToSdkMessageParam(mcpToolResponse, resp, model)
|
||||
},
|
||||
model,
|
||||
mcpTools
|
||||
mcpTools,
|
||||
ctx._internal?.flowControl?.abortSignal
|
||||
)
|
||||
|
||||
return toolResults
|
||||
// 找出已确认工具对应的原始toolCalls
|
||||
const confirmedToolCalls = toolCalls.filter((toolCall) => {
|
||||
return confirmedToolResponses.find((confirmed) => {
|
||||
// 根据不同的ID字段匹配原始toolCall
|
||||
return (
|
||||
('name' in toolCall &&
|
||||
(toolCall.name?.includes(confirmed.tool.name) || toolCall.name?.includes(confirmed.tool.id))) ||
|
||||
confirmed.tool.name === toolCall.id ||
|
||||
confirmed.tool.id === toolCall.id ||
|
||||
('toolCallId' in confirmed && confirmed.toolCallId === toolCall.id) ||
|
||||
('function' in toolCall && toolCall.function.name.toLowerCase().includes(confirmed.tool.name.toLowerCase()))
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
return { toolResults, confirmedToolCalls }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -221,9 +273,9 @@ async function executeToolUseResponses(
|
||||
allToolResponses: MCPToolResponse[],
|
||||
onChunk: CompletionsParams['onChunk'],
|
||||
model: Model
|
||||
): Promise<SdkMessageParam[]> {
|
||||
): Promise<{ toolResults: SdkMessageParam[] }> {
|
||||
// 直接使用parseAndCallTools函数处理已经解析好的ToolUseResponse
|
||||
const toolResults = await parseAndCallTools(
|
||||
const { toolResults } = await parseAndCallTools(
|
||||
toolUseResponses,
|
||||
allToolResponses,
|
||||
onChunk,
|
||||
@@ -231,10 +283,11 @@ async function executeToolUseResponses(
|
||||
return ctx.apiClientInstance.convertMcpToolResponseToSdkMessageParam(mcpToolResponse, resp, model)
|
||||
},
|
||||
model,
|
||||
mcpTools
|
||||
mcpTools,
|
||||
ctx._internal?.flowControl?.abortSignal
|
||||
)
|
||||
|
||||
return toolResults
|
||||
return { toolResults }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -245,7 +298,7 @@ function buildParamsWithToolResults(
|
||||
currentParams: CompletionsParams,
|
||||
output: SdkRawOutput | string | undefined,
|
||||
toolResults: SdkMessageParam[],
|
||||
toolCalls: SdkToolCall[]
|
||||
confirmedToolCalls: SdkToolCall[]
|
||||
): CompletionsParams {
|
||||
// 获取当前已经转换好的reqMessages,如果没有则使用原始messages
|
||||
const currentReqMessages = getCurrentReqMessages(ctx)
|
||||
@@ -253,7 +306,7 @@ function buildParamsWithToolResults(
|
||||
const apiClient = ctx.apiClientInstance
|
||||
|
||||
// 从回复中构建助手消息
|
||||
const newReqMessages = apiClient.buildSdkMessages(currentReqMessages, output, toolResults, toolCalls)
|
||||
const newReqMessages = apiClient.buildSdkMessages(currentReqMessages, output, toolResults, confirmedToolCalls)
|
||||
|
||||
if (output && ctx._internal.toolProcessingState) {
|
||||
ctx._internal.toolProcessingState.output = undefined
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Logger from '@renderer/config/logger'
|
||||
import { ChunkType, TextCompleteChunk, TextDeltaChunk } from '@renderer/types/chunk'
|
||||
import { ChunkType, TextDeltaChunk } from '@renderer/types/chunk'
|
||||
|
||||
import { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas'
|
||||
import { CompletionsContext, CompletionsMiddleware } from '../types'
|
||||
@@ -38,7 +38,6 @@ export const TextChunkMiddleware: CompletionsMiddleware =
|
||||
|
||||
// 用于跨chunk的状态管理
|
||||
let accumulatedTextContent = ''
|
||||
let hasTextCompleteEventEnqueue = false
|
||||
const enhancedTextStream = resultFromUpstream.pipeThrough(
|
||||
new TransformStream<GenericChunk, GenericChunk>({
|
||||
transform(chunk: GenericChunk, controller) {
|
||||
@@ -53,18 +52,7 @@ export const TextChunkMiddleware: CompletionsMiddleware =
|
||||
|
||||
// 创建新的chunk,包含处理后的文本
|
||||
controller.enqueue(chunk)
|
||||
} else if (chunk.type === ChunkType.TEXT_COMPLETE) {
|
||||
const textChunk = chunk as TextCompleteChunk
|
||||
controller.enqueue({
|
||||
...textChunk,
|
||||
text: accumulatedTextContent
|
||||
})
|
||||
if (params.onResponse) {
|
||||
params.onResponse(accumulatedTextContent, true)
|
||||
}
|
||||
hasTextCompleteEventEnqueue = true
|
||||
accumulatedTextContent = ''
|
||||
} else if (accumulatedTextContent && !hasTextCompleteEventEnqueue) {
|
||||
} else if (accumulatedTextContent && chunk.type !== ChunkType.TEXT_START) {
|
||||
if (chunk.type === ChunkType.LLM_RESPONSE_COMPLETE) {
|
||||
const finalText = accumulatedTextContent
|
||||
ctx._internal.customState!.accumulatedText = finalText
|
||||
@@ -89,7 +77,6 @@ export const TextChunkMiddleware: CompletionsMiddleware =
|
||||
})
|
||||
controller.enqueue(chunk)
|
||||
}
|
||||
hasTextCompleteEventEnqueue = true
|
||||
accumulatedTextContent = ''
|
||||
} else {
|
||||
// 其他类型的chunk直接传递
|
||||
|
||||
@@ -65,17 +65,7 @@ export const ThinkChunkMiddleware: CompletionsMiddleware =
|
||||
thinking_millsec: thinkingStartTime > 0 ? Date.now() - thinkingStartTime : 0
|
||||
}
|
||||
controller.enqueue(enhancedChunk)
|
||||
} else if (chunk.type === ChunkType.THINKING_COMPLETE) {
|
||||
const thinkingCompleteChunk = chunk as ThinkingCompleteChunk
|
||||
controller.enqueue({
|
||||
...thinkingCompleteChunk,
|
||||
text: accumulatedThinkingContent,
|
||||
thinking_millsec: thinkingStartTime > 0 ? Date.now() - thinkingStartTime : 0
|
||||
})
|
||||
hasThinkingContent = false
|
||||
accumulatedThinkingContent = ''
|
||||
thinkingStartTime = 0
|
||||
} else if (hasThinkingContent && thinkingStartTime > 0) {
|
||||
} else if (hasThinkingContent && thinkingStartTime > 0 && chunk.type !== ChunkType.THINKING_START) {
|
||||
// 收到任何非THINKING_DELTA的chunk时,如果有累积的思考内容,生成THINKING_COMPLETE
|
||||
const thinkingCompleteChunk: ThinkingCompleteChunk = {
|
||||
type: ChunkType.THINKING_COMPLETE,
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { Model } from '@renderer/types'
|
||||
import { ChunkType, TextDeltaChunk, ThinkingCompleteChunk, ThinkingDeltaChunk } from '@renderer/types/chunk'
|
||||
import {
|
||||
ChunkType,
|
||||
TextDeltaChunk,
|
||||
ThinkingCompleteChunk,
|
||||
ThinkingDeltaChunk,
|
||||
ThinkingStartChunk
|
||||
} from '@renderer/types/chunk'
|
||||
import { TagConfig, TagExtractor } from '@renderer/utils/tagExtraction'
|
||||
import Logger from 'electron-log/renderer'
|
||||
|
||||
@@ -59,6 +65,8 @@ export const ThinkingTagExtractionMiddleware: CompletionsMiddleware =
|
||||
let hasThinkingContent = false
|
||||
let thinkingStartTime = 0
|
||||
|
||||
let isFirstTextChunk = true
|
||||
|
||||
const processedStream = resultFromUpstream.pipeThrough(
|
||||
new TransformStream<GenericChunk, GenericChunk>({
|
||||
transform(chunk: GenericChunk, controller) {
|
||||
@@ -87,6 +95,9 @@ export const ThinkingTagExtractionMiddleware: CompletionsMiddleware =
|
||||
if (!hasThinkingContent) {
|
||||
hasThinkingContent = true
|
||||
thinkingStartTime = Date.now()
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_START
|
||||
} as ThinkingStartChunk)
|
||||
}
|
||||
|
||||
if (extractionResult.content?.trim()) {
|
||||
@@ -98,6 +109,12 @@ export const ThinkingTagExtractionMiddleware: CompletionsMiddleware =
|
||||
controller.enqueue(thinkingDeltaChunk)
|
||||
}
|
||||
} else {
|
||||
if (isFirstTextChunk) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_START
|
||||
})
|
||||
isFirstTextChunk = false
|
||||
}
|
||||
// 发送清理后的文本内容
|
||||
const cleanTextChunk: TextDeltaChunk = {
|
||||
...textChunk,
|
||||
@@ -107,7 +124,7 @@ export const ThinkingTagExtractionMiddleware: CompletionsMiddleware =
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
} else if (chunk.type !== ChunkType.TEXT_START) {
|
||||
// 其他类型的chunk直接传递(包括 THINKING_DELTA, THINKING_COMPLETE 等)
|
||||
controller.enqueue(chunk)
|
||||
}
|
||||
|
||||
@@ -22,7 +22,8 @@ const TOOL_USE_TAG_CONFIG: TagConfig = {
|
||||
* 1. 从文本流中检测并提取 <tool_use></tool_use> 标签
|
||||
* 2. 解析工具调用信息并转换为 ToolUseResponse 格式
|
||||
* 3. 生成 MCP_TOOL_CREATED chunk 供 McpToolChunkMiddleware 处理
|
||||
* 4. 清理文本流,移除工具使用标签但保留正常文本
|
||||
* 4. 丢弃 tool_use 之后的所有内容(助手幻觉)
|
||||
* 5. 清理文本流,移除工具使用标签但保留正常文本
|
||||
*
|
||||
* 注意:此中间件只负责提取和转换,实际工具调用由 McpToolChunkMiddleware 处理
|
||||
*/
|
||||
@@ -32,13 +33,10 @@ export const ToolUseExtractionMiddleware: CompletionsMiddleware =
|
||||
async (ctx: CompletionsContext, params: CompletionsParams): Promise<CompletionsResult> => {
|
||||
const mcpTools = params.mcpTools || []
|
||||
|
||||
// 如果没有工具,直接调用下一个中间件
|
||||
if (!mcpTools || mcpTools.length === 0) return next(ctx, params)
|
||||
|
||||
// 调用下游中间件
|
||||
const result = await next(ctx, params)
|
||||
|
||||
// 响应后处理:处理工具使用标签提取
|
||||
if (result.stream) {
|
||||
const resultFromUpstream = result.stream as ReadableStream<GenericChunk>
|
||||
|
||||
@@ -60,7 +58,9 @@ function createToolUseExtractionTransform(
|
||||
_ctx: CompletionsContext,
|
||||
mcpTools: MCPTool[]
|
||||
): TransformStream<GenericChunk, GenericChunk> {
|
||||
const tagExtractor = new TagExtractor(TOOL_USE_TAG_CONFIG)
|
||||
const toolUseExtractor = new TagExtractor(TOOL_USE_TAG_CONFIG)
|
||||
let hasAnyToolUse = false
|
||||
let toolCounter = 0
|
||||
|
||||
return new TransformStream({
|
||||
async transform(chunk: GenericChunk, controller) {
|
||||
@@ -68,30 +68,38 @@ function createToolUseExtractionTransform(
|
||||
// 处理文本内容,检测工具使用标签
|
||||
if (chunk.type === ChunkType.TEXT_DELTA) {
|
||||
const textChunk = chunk as TextDeltaChunk
|
||||
const extractionResults = tagExtractor.processText(textChunk.text)
|
||||
|
||||
for (const result of extractionResults) {
|
||||
// 处理 tool_use 标签
|
||||
const toolUseResults = toolUseExtractor.processText(textChunk.text)
|
||||
|
||||
for (const result of toolUseResults) {
|
||||
if (result.complete && result.tagContentExtracted) {
|
||||
// 提取到完整的工具使用内容,解析并转换为 SDK ToolCall 格式
|
||||
const toolUseResponses = parseToolUse(result.tagContentExtracted, mcpTools)
|
||||
const toolUseResponses = parseToolUse(result.tagContentExtracted, mcpTools, toolCounter)
|
||||
toolCounter += toolUseResponses.length
|
||||
|
||||
if (toolUseResponses.length > 0) {
|
||||
// 生成 MCP_TOOL_CREATED chunk,复用现有的处理流程
|
||||
controller.enqueue({ type: ChunkType.TEXT_COMPLETE, text: '' })
|
||||
// 生成 MCP_TOOL_CREATED chunk
|
||||
const mcpToolCreatedChunk: MCPToolCreatedChunk = {
|
||||
type: ChunkType.MCP_TOOL_CREATED,
|
||||
tool_use_responses: toolUseResponses
|
||||
}
|
||||
controller.enqueue(mcpToolCreatedChunk)
|
||||
|
||||
// 标记已有工具调用
|
||||
hasAnyToolUse = true
|
||||
}
|
||||
} else if (!result.isTagContent && result.content) {
|
||||
// 发送标签外的正常文本内容
|
||||
const cleanTextChunk: TextDeltaChunk = {
|
||||
...textChunk,
|
||||
text: result.content
|
||||
if (!hasAnyToolUse) {
|
||||
const cleanTextChunk: TextDeltaChunk = {
|
||||
...textChunk,
|
||||
text: result.content
|
||||
}
|
||||
controller.enqueue(cleanTextChunk)
|
||||
}
|
||||
controller.enqueue(cleanTextChunk)
|
||||
}
|
||||
// 注意:标签内的内容不会作为TEXT_DELTA转发,避免重复显示
|
||||
// tool_use 标签内的内容不转发,避免重复显示
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -105,16 +113,17 @@ function createToolUseExtractionTransform(
|
||||
},
|
||||
|
||||
async flush(controller) {
|
||||
// 检查是否有未完成的标签内容
|
||||
const finalResult = tagExtractor.finalize()
|
||||
if (finalResult && finalResult.tagContentExtracted) {
|
||||
const toolUseResponses = parseToolUse(finalResult.tagContentExtracted, mcpTools)
|
||||
// 检查是否有未完成的 tool_use 标签内容
|
||||
const finalToolUseResult = toolUseExtractor.finalize()
|
||||
if (finalToolUseResult && finalToolUseResult.tagContentExtracted) {
|
||||
const toolUseResponses = parseToolUse(finalToolUseResult.tagContentExtracted, mcpTools, toolCounter)
|
||||
if (toolUseResponses.length > 0) {
|
||||
const mcpToolCreatedChunk: MCPToolCreatedChunk = {
|
||||
type: ChunkType.MCP_TOOL_CREATED,
|
||||
tool_use_responses: toolUseResponses
|
||||
}
|
||||
controller.enqueue(mcpToolCreatedChunk)
|
||||
hasAnyToolUse = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
@font-face {
|
||||
font-family: 'Twemoji Country Flags';
|
||||
unicode-range:
|
||||
U+1F1E6-1F1FF, U+1F3F4, U+E0062-E0063, U+E0065, U+E0067, U+E006C, U+E006E, U+E0073-E0074, U+E0077, U+E007F;
|
||||
/*https://github.com/beyondkmp/country-flag-emoji-polyfill/blob/master/font/TwemojiCountryFlags.woff2 */
|
||||
src: url('TwemojiCountryFlags.woff2') format('woff2');
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* 国旗字体样式类 */
|
||||
.country-flag-font {
|
||||
font-family: 'Twemoji Country Flags', 'Apple Color Emoji', 'Segoe UI Emoji', sans-serif;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Twemoji Country Flags';
|
||||
unicode-range:
|
||||
U+1F1E6-1F1FF, U+1F3F4, U+E0062-E0063, U+E0065, U+E0067, U+E006C, U+E006E, U+E0073-E0074, U+E0077, U+E007F;
|
||||
/*https://github.com/beyondkmp/country-flag-emoji-polyfill/blob/master/font/TwemojiCountryFlags.woff2 */
|
||||
src: url('TwemojiCountryFlags.woff2') format('woff2');
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* 国旗字体样式类 */
|
||||
.country-flag-font {
|
||||
font-family: 'Twemoji Country Flags', 'Apple Color Emoji', 'Segoe UI Emoji', sans-serif;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
}
|
||||
|
||||
.minapp-drawer {
|
||||
max-width: calc(100vw - var(--sidebar-width));
|
||||
.ant-drawer-content-wrapper {
|
||||
box-shadow: none;
|
||||
}
|
||||
@@ -33,7 +32,7 @@
|
||||
position: absolute;
|
||||
-webkit-app-region: drag;
|
||||
min-height: calc(var(--navbar-height) + 0.5px);
|
||||
width: calc(100vw - var(--sidebar-width));
|
||||
width: 100%;
|
||||
margin-top: -0.5px;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
--color-text-secondary: rgba(235, 235, 245, 0.7);
|
||||
--color-icon: #ffffff99;
|
||||
--color-icon-white: #ffffff;
|
||||
--color-border: #ffffff19;
|
||||
--color-border: #383838;
|
||||
--color-border-soft: #ffffff10;
|
||||
--color-border-mute: #ffffff05;
|
||||
--color-error: #f44336;
|
||||
@@ -44,7 +44,7 @@
|
||||
--color-reference-text: #ffffff;
|
||||
--color-reference-background: #0b0e12;
|
||||
|
||||
--color-list-item: #222;
|
||||
--color-list-item: #252525;
|
||||
--color-list-item-hover: #1e1e1e;
|
||||
|
||||
--modal-background: #111111;
|
||||
@@ -54,25 +54,14 @@
|
||||
--color-background-highlight-accent: rgba(255, 150, 50, 0.9);
|
||||
|
||||
--navbar-background-mac: rgba(20, 20, 20, 0.55);
|
||||
--navbar-background-win: rgba(20, 20, 20, 0.75);
|
||||
--navbar-background: #1f1f1f;
|
||||
|
||||
--navbar-height: 40px;
|
||||
--sidebar-width: 50px;
|
||||
--status-bar-height: 40px;
|
||||
--input-bar-height: 100px;
|
||||
|
||||
--assistants-width: 275px;
|
||||
--topic-list-width: 275px;
|
||||
--settings-width: 250px;
|
||||
--scrollbar-width: 5px;
|
||||
|
||||
--chat-background: transparent;
|
||||
--chat-background-user: rgba(255, 255, 255, 0.08);
|
||||
--chat-background-assistant: transparent;
|
||||
--chat-text-user: var(--color-black);
|
||||
|
||||
--list-item-border-radius: 20px;
|
||||
|
||||
--color-status-success: #52c41a;
|
||||
--color-status-error: #ff4d4f;
|
||||
--color-status-warning: #faad14;
|
||||
@@ -124,8 +113,8 @@
|
||||
--color-reference-text: #000000;
|
||||
--color-reference-background: #f1f7ff;
|
||||
|
||||
--color-list-item: #eee;
|
||||
--color-list-item-hover: #f5f5f5;
|
||||
--color-list-item: rgba(0, 0, 0, 0.05);
|
||||
--color-list-item-hover: rgba(0, 0, 0, 0.03);
|
||||
|
||||
--modal-background: var(--color-white);
|
||||
|
||||
@@ -134,6 +123,7 @@
|
||||
--color-background-highlight-accent: rgba(255, 150, 50, 0.5);
|
||||
|
||||
--navbar-background-mac: rgba(255, 255, 255, 0.55);
|
||||
--navbar-background-win: rgba(255, 255, 255, 0.75);
|
||||
--navbar-background: rgba(244, 244, 244);
|
||||
|
||||
--chat-background: transparent;
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
#content-container {
|
||||
background-color: var(--color-background);
|
||||
border-top: 0.5px solid var(--color-border);
|
||||
border-top-left-radius: 10px;
|
||||
border-left: 0.5px solid var(--color-border);
|
||||
}
|
||||
|
||||
.group-container {
|
||||
.context-menu-container {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu-container {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
:root {
|
||||
--font-family:
|
||||
Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Roboto, Oxygen, Cantarell, 'Open Sans',
|
||||
'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
|
||||
--font-family-serif:
|
||||
serif, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Ubuntu, Roboto, Oxygen, Cantarell, 'Open Sans',
|
||||
'Helvetica Neue', Arial, 'Noto Sans', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
|
||||
--code-font-family: 'Cascadia Code', 'Fira Code', 'Consolas', Menlo, Courier, monospace;
|
||||
}
|
||||
|
||||
// Windows系统专用字体配置
|
||||
body[os='windows'] {
|
||||
--font-family:
|
||||
'Twemoji Country Flags', Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Roboto, Oxygen,
|
||||
Cantarell, 'Open Sans', 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
||||
'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
}
|
||||
:root {
|
||||
--font-family:
|
||||
Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Roboto, Oxygen, Cantarell, 'Open Sans',
|
||||
'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
|
||||
--font-family-serif:
|
||||
serif, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Ubuntu, Roboto, Oxygen, Cantarell, 'Open Sans',
|
||||
'Helvetica Neue', Arial, 'Noto Sans', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
|
||||
--code-font-family: 'Cascadia Code', 'Fira Code', 'Consolas', Menlo, Courier, monospace;
|
||||
}
|
||||
|
||||
// Windows系统专用字体配置
|
||||
body[os='windows'] {
|
||||
--font-family:
|
||||
'Twemoji Country Flags', Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Roboto, Oxygen,
|
||||
Cantarell, 'Open Sans', 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
||||
'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
@use './variables.scss';
|
||||
@use './color.scss';
|
||||
@use './font.scss';
|
||||
@use './markdown.scss';
|
||||
@@ -139,7 +140,7 @@ ul {
|
||||
}
|
||||
}
|
||||
.message-content-container {
|
||||
border-radius: 10px 0 10px 10px;
|
||||
border-radius: 10px;
|
||||
padding: 10px 16px 10px 16px;
|
||||
background-color: var(--chat-background-user);
|
||||
align-self: self-end;
|
||||
|
||||
@@ -19,12 +19,14 @@
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin: 1em 0 1em 0;
|
||||
margin: 1.5em 0 1em 0;
|
||||
line-height: 1.3;
|
||||
font-weight: bold;
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-top: 0;
|
||||
font-size: 2em;
|
||||
border-bottom: 0.5px solid var(--color-border);
|
||||
padding-bottom: 0.3em;
|
||||
@@ -53,8 +55,9 @@
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 1em 0;
|
||||
margin: 1.3em 0;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.6;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 5px;
|
||||
@@ -82,7 +85,7 @@
|
||||
li {
|
||||
margin-bottom: 0.5em;
|
||||
pre {
|
||||
margin: 1.5em 0;
|
||||
margin: 1.5em 0 !important;
|
||||
}
|
||||
&::marker {
|
||||
color: var(--color-text-3);
|
||||
@@ -108,6 +111,7 @@
|
||||
li code {
|
||||
background: var(--color-background-mute);
|
||||
padding: 3px 5px;
|
||||
margin: 0 2px;
|
||||
border-radius: 5px;
|
||||
word-break: keep-all;
|
||||
white-space: pre;
|
||||
@@ -122,9 +126,7 @@
|
||||
overflow-x: auto;
|
||||
font-family: 'Fira Code', 'Courier New', Courier, monospace;
|
||||
background-color: var(--color-background-mute);
|
||||
&:has(.mermaid),
|
||||
&:has(.plantuml-preview),
|
||||
&:has(.svg-preview) {
|
||||
&:has(.special-preview) {
|
||||
background-color: transparent;
|
||||
}
|
||||
&:not(pre pre) {
|
||||
@@ -148,16 +150,19 @@
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 1em 0;
|
||||
padding-left: 1em;
|
||||
color: var(--color-text-light);
|
||||
border-left: 4px solid var(--color-border);
|
||||
font-family: var(--font-family);
|
||||
margin: 1.5em 0;
|
||||
padding: 1em 1.5em;
|
||||
background-color: var(--color-background-soft);
|
||||
border-left: 4px solid var(--color-primary);
|
||||
border-radius: 0 8px 8px 0;
|
||||
font-style: italic;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
table {
|
||||
--table-border-radius: 8px;
|
||||
margin: 1em 0;
|
||||
margin: 2em 0;
|
||||
font-size: 0.9em;
|
||||
width: 100%;
|
||||
border-radius: var(--table-border-radius);
|
||||
overflow: hidden;
|
||||
@@ -182,13 +187,19 @@
|
||||
|
||||
th {
|
||||
background-color: var(--color-background-mute);
|
||||
font-weight: bold;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-family);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background-color: var(--color-background-soft);
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
a,
|
||||
|
||||
18
src/renderer/src/assets/styles/variables.scss
Normal file
18
src/renderer/src/assets/styles/variables.scss
Normal file
@@ -0,0 +1,18 @@
|
||||
:root {
|
||||
--navbar-height: 42px;
|
||||
--sidebar-width: 50px;
|
||||
--status-bar-height: 40px;
|
||||
--input-bar-height: 100px;
|
||||
|
||||
--assistants-width: 275px;
|
||||
--topic-list-width: 275px;
|
||||
--settings-width: 250px;
|
||||
--scrollbar-width: 5px;
|
||||
|
||||
--list-item-border-radius: 15px;
|
||||
--border-width: 0.5px;
|
||||
|
||||
--main-height: 100vh;
|
||||
|
||||
--border-width: 0.5px;
|
||||
}
|
||||
@@ -1,24 +1,28 @@
|
||||
import { Alert } from 'antd'
|
||||
import { t } from 'i18next'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const LOCALSTORAGE_KEY = 'openai_alert_closed'
|
||||
|
||||
const OpenAIAlert = () => {
|
||||
const { t } = useTranslation()
|
||||
interface Props {
|
||||
message?: string
|
||||
key?: string
|
||||
}
|
||||
|
||||
const OpenAIAlert = ({ message = t('settings.provider.openai.alert'), key = LOCALSTORAGE_KEY }: Props) => {
|
||||
const [visible, setVisible] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const closed = localStorage.getItem(LOCALSTORAGE_KEY)
|
||||
const closed = localStorage.getItem(key)
|
||||
setVisible(!closed)
|
||||
}, [])
|
||||
}, [key])
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<Alert
|
||||
style={{ width: '100%', marginTop: 5, marginBottom: 5 }}
|
||||
message={t('settings.provider.openai.alert')}
|
||||
message={message}
|
||||
closable
|
||||
afterClose={() => {
|
||||
localStorage.setItem(LOCALSTORAGE_KEY, '1')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CodeTool, TOOL_SPECS, useCodeTool } from '@renderer/components/CodeToolbar'
|
||||
import { TOOL_SPECS, useCodeTool } from '@renderer/components/CodeToolbar'
|
||||
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
||||
import { useCodeHighlight } from '@renderer/hooks/useCodeHighlight'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
@@ -9,12 +9,13 @@ import { debounce } from 'lodash'
|
||||
import { ChevronsDownUp, ChevronsUpDown, Text as UnWrapIcon, WrapText as WrapIcon } from 'lucide-react'
|
||||
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ThemedToken } from 'shiki/core'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface CodePreviewProps {
|
||||
children: string
|
||||
import { BasicPreviewProps } from './types'
|
||||
|
||||
interface CodePreviewProps extends BasicPreviewProps {
|
||||
language: string
|
||||
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
|
||||
}
|
||||
|
||||
const MAX_COLLAPSE_HEIGHT = 350
|
||||
@@ -150,7 +151,8 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
|
||||
{
|
||||
'--gutter-width': `${gutterDigits}ch`,
|
||||
fontSize: `${fontSize - 1}px`,
|
||||
maxHeight: shouldCollapse ? MAX_COLLAPSE_HEIGHT : undefined
|
||||
maxHeight: shouldCollapse ? MAX_COLLAPSE_HEIGHT : undefined,
|
||||
overflowY: shouldCollapse ? 'auto' : 'hidden'
|
||||
} as React.CSSProperties
|
||||
}>
|
||||
<div
|
||||
@@ -162,19 +164,11 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
/*
|
||||
* FIXME: @tanstack/react-virtual 使用绝对定位,但是会导致
|
||||
* 有气泡样式 `self-end` 并且气泡中只有代码块时整个代码块收缩
|
||||
* 到最小宽度(目前应该是工具栏的宽度)。改为相对定位可以保证宽
|
||||
* 度足够,目前没有发现其他副作用。
|
||||
* 如果发现破坏虚拟列表功能,或者将来有更好的解决方案,再调整。
|
||||
*/
|
||||
position: 'relative',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${virtualItems[0]?.start ?? 0}px)`,
|
||||
willChange: 'transform'
|
||||
transform: `translateY(${virtualItems[0]?.start ?? 0}px)`
|
||||
}}>
|
||||
{virtualizer.getVirtualItems().map((virtualItem) => (
|
||||
<div key={virtualItem.key} data-index={virtualItem.index} ref={virtualizer.measureElement}>
|
||||
@@ -195,9 +189,49 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
|
||||
|
||||
CodePreview.displayName = 'CodePreview'
|
||||
|
||||
/**
|
||||
* 补全代码行 tokens,把原始内容拼接到高亮内容之后,确保渲染出整行来。
|
||||
*/
|
||||
function completeLineTokens(themedTokens: ThemedToken[], rawLine: string): ThemedToken[] {
|
||||
// 如果出现空行,补一个空格保证行高
|
||||
if (rawLine.length === 0) {
|
||||
return [
|
||||
{
|
||||
content: ' ',
|
||||
offset: 0,
|
||||
color: 'inherit',
|
||||
bgColor: 'inherit',
|
||||
htmlStyle: {
|
||||
opacity: '0.35'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const themedContent = themedTokens.map((token) => token.content).join('')
|
||||
const extraContent = rawLine.slice(themedContent.length)
|
||||
|
||||
// 已有内容已经全部高亮,直接返回
|
||||
if (!extraContent) return themedTokens
|
||||
|
||||
// 补全剩余内容
|
||||
return [
|
||||
...themedTokens,
|
||||
{
|
||||
content: extraContent,
|
||||
offset: themedContent.length,
|
||||
color: 'inherit',
|
||||
bgColor: 'inherit',
|
||||
htmlStyle: {
|
||||
opacity: '0.35'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
interface VirtualizedRowData {
|
||||
rawLine: string
|
||||
tokenLine?: any[]
|
||||
tokenLine?: ThemedToken[]
|
||||
showLineNumbers: boolean
|
||||
}
|
||||
|
||||
@@ -210,17 +244,11 @@ const VirtualizedRow = memo(
|
||||
<div className="line">
|
||||
{showLineNumbers && <span className="line-number">{index + 1}</span>}
|
||||
<span className="line-content">
|
||||
{tokenLine ? (
|
||||
// 渲染高亮后的内容
|
||||
tokenLine.map((token, tokenIndex) => (
|
||||
<span key={tokenIndex} style={getReactStyleFromToken(token)}>
|
||||
{token.content}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
// 渲染原始内容
|
||||
<span className="line-content-raw">{rawLine || ' '}</span>
|
||||
)}
|
||||
{completeLineTokens(tokenLine ?? [], rawLine).map((token, tokenIndex) => (
|
||||
<span key={tokenIndex} style={getReactStyleFromToken(token)}>
|
||||
{token.content}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
@@ -234,7 +262,7 @@ const ScrollContainer = styled.div<{
|
||||
$lineHeight?: number
|
||||
}>`
|
||||
display: block;
|
||||
overflow: auto;
|
||||
overflow-x: auto;
|
||||
position: relative;
|
||||
border-radius: inherit;
|
||||
padding: 0.5em 1em;
|
||||
@@ -264,10 +292,6 @@ const ScrollContainer = styled.div<{
|
||||
overflow-wrap: ${(props) => (props.$wrap ? 'break-word' : 'normal')};
|
||||
}
|
||||
}
|
||||
|
||||
.line-content-raw {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
|
||||
102
src/renderer/src/components/CodeBlockView/GraphvizPreview.tsx
Normal file
102
src/renderer/src/components/CodeBlockView/GraphvizPreview.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
|
||||
import SvgSpinners180Ring from '@renderer/components/Icons/SvgSpinners180Ring'
|
||||
import { AsyncInitializer } from '@renderer/utils/asyncInitializer'
|
||||
import { Flex, Spin } from 'antd'
|
||||
import { debounce } from 'lodash'
|
||||
import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import PreviewError from './PreviewError'
|
||||
import { BasicPreviewProps } from './types'
|
||||
|
||||
// 管理 viz 实例
|
||||
const vizInitializer = new AsyncInitializer(async () => {
|
||||
const module = await import('@viz-js/viz')
|
||||
return await module.instance()
|
||||
})
|
||||
|
||||
/** 预览 Graphviz 图表
|
||||
* 通过防抖渲染提供比较统一的体验,减少闪烁。
|
||||
*/
|
||||
const GraphvizPreview: React.FC<BasicPreviewProps> = ({ children, setTools }) => {
|
||||
const graphvizRef = useRef<HTMLDivElement>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
// 使用通用图像工具
|
||||
const { handleZoom, handleCopyImage, handleDownload } = usePreviewToolHandlers(graphvizRef, {
|
||||
imgSelector: 'svg',
|
||||
prefix: 'graphviz',
|
||||
enableWheelZoom: true
|
||||
})
|
||||
|
||||
// 使用工具栏
|
||||
usePreviewTools({
|
||||
setTools,
|
||||
handleZoom,
|
||||
handleCopyImage,
|
||||
handleDownload
|
||||
})
|
||||
|
||||
// 实际的渲染函数
|
||||
const renderGraphviz = useCallback(async (content: string) => {
|
||||
if (!content || !graphvizRef.current) return
|
||||
|
||||
try {
|
||||
setIsLoading(true)
|
||||
|
||||
const viz = await vizInitializer.get()
|
||||
const svgElement = viz.renderSVGElement(content)
|
||||
|
||||
// 清空容器并添加新的 SVG
|
||||
graphvizRef.current.innerHTML = ''
|
||||
graphvizRef.current.appendChild(svgElement)
|
||||
|
||||
// 渲染成功,清除错误记录
|
||||
setError(null)
|
||||
} catch (error) {
|
||||
setError((error as Error).message || 'DOT syntax error or rendering failed')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// debounce 渲染
|
||||
const debouncedRender = useMemo(
|
||||
() =>
|
||||
debounce((content: string) => {
|
||||
startTransition(() => renderGraphviz(content))
|
||||
}, 300),
|
||||
[renderGraphviz]
|
||||
)
|
||||
|
||||
// 触发渲染
|
||||
useEffect(() => {
|
||||
if (children) {
|
||||
setIsLoading(true)
|
||||
debouncedRender(children)
|
||||
} else {
|
||||
debouncedRender.cancel()
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
return () => {
|
||||
debouncedRender.cancel()
|
||||
}
|
||||
}, [children, debouncedRender])
|
||||
|
||||
return (
|
||||
<Spin spinning={isLoading} indicator={<SvgSpinners180Ring color="var(--color-text-2)" />}>
|
||||
<Flex vertical style={{ minHeight: isLoading ? '2rem' : 'auto' }}>
|
||||
{error && <PreviewError>{error}</PreviewError>}
|
||||
<StyledGraphviz ref={graphvizRef} className="graphviz special-preview" />
|
||||
</Flex>
|
||||
</Spin>
|
||||
)
|
||||
}
|
||||
|
||||
const StyledGraphviz = styled.div`
|
||||
overflow: auto;
|
||||
`
|
||||
|
||||
export default memo(GraphvizPreview)
|
||||
@@ -1,70 +0,0 @@
|
||||
import { ExpandOutlined, LinkOutlined } from '@ant-design/icons'
|
||||
import { AppLogo } from '@renderer/config/env'
|
||||
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||
import { extractTitle } from '@renderer/utils/formats'
|
||||
import { Button } from 'antd'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props {
|
||||
html: string
|
||||
}
|
||||
|
||||
const Artifacts: FC<Props> = ({ html }) => {
|
||||
const { t } = useTranslation()
|
||||
const { openMinapp } = useMinappPopup()
|
||||
|
||||
/**
|
||||
* 在应用内打开
|
||||
*/
|
||||
const handleOpenInApp = async () => {
|
||||
const path = await window.api.file.createTempFile('artifacts-preview.html')
|
||||
await window.api.file.write(path, html)
|
||||
const filePath = `file://${path}`
|
||||
const title = extractTitle(html) || 'Artifacts ' + t('chat.artifacts.button.preview')
|
||||
openMinapp({
|
||||
id: 'artifacts-preview',
|
||||
name: title,
|
||||
logo: AppLogo,
|
||||
url: filePath
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 外部链接打开
|
||||
*/
|
||||
const handleOpenExternal = async () => {
|
||||
const path = await window.api.file.createTempFile('artifacts-preview.html')
|
||||
await window.api.file.write(path, html)
|
||||
const filePath = `file://${path}`
|
||||
|
||||
if (window.api.shell && window.api.shell.openExternal) {
|
||||
window.api.shell.openExternal(filePath)
|
||||
} else {
|
||||
console.error(t('artifacts.preview.openExternal.error.content'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Button icon={<ExpandOutlined />} onClick={handleOpenInApp}>
|
||||
{t('chat.artifacts.button.preview')}
|
||||
</Button>
|
||||
|
||||
<Button icon={<LinkOutlined />} onClick={handleOpenExternal}>
|
||||
{t('chat.artifacts.button.openExternal')}
|
||||
</Button>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
margin: 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
padding-bottom: 10px;
|
||||
`
|
||||
|
||||
export default Artifacts
|
||||
404
src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx
Normal file
404
src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx
Normal file
@@ -0,0 +1,404 @@
|
||||
import { CodeOutlined, LinkOutlined } from '@ant-design/icons'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { ThemeMode } from '@renderer/types'
|
||||
import { extractTitle } from '@renderer/utils/formats'
|
||||
import { Button } from 'antd'
|
||||
import { Code, Download, Globe, Sparkles } from 'lucide-react'
|
||||
import { FC, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ClipLoader } from 'react-spinners'
|
||||
import styled, { keyframes } from 'styled-components'
|
||||
|
||||
import HtmlArtifactsPopup from './HtmlArtifactsPopup'
|
||||
|
||||
interface Props {
|
||||
html: string
|
||||
}
|
||||
|
||||
const HtmlArtifactsCard: FC<Props> = ({ html }) => {
|
||||
const { t } = useTranslation()
|
||||
const title = extractTitle(html) || 'HTML Artifacts'
|
||||
const [isPopupOpen, setIsPopupOpen] = useState(false)
|
||||
const { theme } = useTheme()
|
||||
|
||||
const htmlContent = html || ''
|
||||
const hasContent = htmlContent.trim().length > 0
|
||||
|
||||
// 判断是否正在流式生成的逻辑
|
||||
const isStreaming = useMemo(() => {
|
||||
if (!hasContent) return false
|
||||
|
||||
const trimmedHtml = htmlContent.trim()
|
||||
|
||||
// 提前检查:如果包含关键的结束标签,直接判断为完整文档
|
||||
if (/<\/html\s*>/i.test(trimmedHtml)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果同时包含 DOCTYPE 和 </body>,通常也是完整文档
|
||||
if (/<!DOCTYPE\s+html/i.test(trimmedHtml) && /<\/body\s*>/i.test(trimmedHtml)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查 HTML 是否看起来是完整的
|
||||
const indicators = {
|
||||
// 1. 检查常见的 HTML 结构完整性
|
||||
hasHtmlTag: /<html[^>]*>/i.test(trimmedHtml),
|
||||
hasClosingHtmlTag: /<\/html\s*>$/i.test(trimmedHtml),
|
||||
|
||||
// 2. 检查 body 标签完整性
|
||||
hasBodyTag: /<body[^>]*>/i.test(trimmedHtml),
|
||||
hasClosingBodyTag: /<\/body\s*>/i.test(trimmedHtml),
|
||||
|
||||
// 3. 检查是否以未闭合的标签结尾
|
||||
endsWithIncompleteTag: /<[^>]*$/.test(trimmedHtml),
|
||||
|
||||
// 4. 检查是否有未配对的标签
|
||||
hasUnmatchedTags: checkUnmatchedTags(trimmedHtml),
|
||||
|
||||
// 5. 检查是否以常见的"流式结束"模式结尾
|
||||
endsWithTypicalCompletion: /(<\/html>\s*|<\/body>\s*|<\/div>\s*|<\/script>\s*|<\/style>\s*)$/i.test(trimmedHtml)
|
||||
}
|
||||
|
||||
// 如果有明显的未完成标志,则认为正在生成
|
||||
if (indicators.endsWithIncompleteTag || indicators.hasUnmatchedTags) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 如果有 HTML 结构但不完整
|
||||
if (indicators.hasHtmlTag && !indicators.hasClosingHtmlTag) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 如果有 body 结构但不完整
|
||||
if (indicators.hasBodyTag && !indicators.hasClosingBodyTag) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 对于简单的 HTML 片段,检查是否看起来是完整的
|
||||
if (!indicators.hasHtmlTag && !indicators.hasBodyTag) {
|
||||
// 如果是简单片段且没有明显的结束标志,可能还在生成
|
||||
return !indicators.endsWithTypicalCompletion && trimmedHtml.length < 500
|
||||
}
|
||||
|
||||
return false
|
||||
}, [htmlContent, hasContent])
|
||||
|
||||
// 检查未配对标签的辅助函数
|
||||
function checkUnmatchedTags(html: string): boolean {
|
||||
const stack: string[] = []
|
||||
const tagRegex = /<\/?([a-zA-Z][a-zA-Z0-9]*)[^>]*>/g
|
||||
|
||||
// HTML5 void 元素(自闭合元素)的完整列表
|
||||
const voidElements = [
|
||||
'area',
|
||||
'base',
|
||||
'br',
|
||||
'col',
|
||||
'embed',
|
||||
'hr',
|
||||
'img',
|
||||
'input',
|
||||
'link',
|
||||
'meta',
|
||||
'param',
|
||||
'source',
|
||||
'track',
|
||||
'wbr'
|
||||
]
|
||||
|
||||
let match
|
||||
|
||||
while ((match = tagRegex.exec(html)) !== null) {
|
||||
const [fullTag, tagName] = match
|
||||
const isClosing = fullTag.startsWith('</')
|
||||
const isSelfClosing = fullTag.endsWith('/>') || voidElements.includes(tagName.toLowerCase())
|
||||
|
||||
if (isSelfClosing) continue
|
||||
|
||||
if (isClosing) {
|
||||
if (stack.length === 0 || stack.pop() !== tagName.toLowerCase()) {
|
||||
return true // 找到不匹配的闭合标签
|
||||
}
|
||||
} else {
|
||||
stack.push(tagName.toLowerCase())
|
||||
}
|
||||
}
|
||||
|
||||
return stack.length > 0 // 还有未闭合的标签
|
||||
}
|
||||
|
||||
// 获取格式化的代码预览
|
||||
function getFormattedCodePreview(html: string): string {
|
||||
const trimmed = html.trim()
|
||||
const lines = trimmed.split('\n')
|
||||
const lastFewLines = lines.slice(-3) // 显示最后3行
|
||||
return lastFewLines.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* 在编辑器中打开
|
||||
*/
|
||||
const handleOpenInEditor = () => {
|
||||
setIsPopupOpen(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭弹窗
|
||||
*/
|
||||
const handleClosePopup = () => {
|
||||
setIsPopupOpen(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* 外部链接打开
|
||||
*/
|
||||
const handleOpenExternal = async () => {
|
||||
const path = await window.api.file.createTempFile('artifacts-preview.html')
|
||||
await window.api.file.write(path, htmlContent)
|
||||
const filePath = `file://${path}`
|
||||
|
||||
if (window.api.shell && window.api.shell.openExternal) {
|
||||
window.api.shell.openExternal(filePath)
|
||||
} else {
|
||||
console.error(t('artifacts.preview.openExternal.error.content'))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载到本地
|
||||
*/
|
||||
const handleDownload = async () => {
|
||||
const fileName = `${title.replace(/[^a-zA-Z0-9\s]/g, '').replace(/\s+/g, '-') || 'html-artifact'}.html`
|
||||
await window.api.file.save(fileName, htmlContent)
|
||||
window.message.success({ content: t('message.download.success'), key: 'download' })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container $isStreaming={isStreaming}>
|
||||
<Header>
|
||||
<IconWrapper $isStreaming={isStreaming}>
|
||||
{isStreaming ? <Sparkles size={20} color="white" /> : <Globe size={20} color="white" />}
|
||||
</IconWrapper>
|
||||
<TitleSection>
|
||||
<Title>{title}</Title>
|
||||
<TypeBadge>
|
||||
<Code size={12} />
|
||||
<span>HTML</span>
|
||||
</TypeBadge>
|
||||
</TitleSection>
|
||||
</Header>
|
||||
<Content>
|
||||
{isStreaming && !hasContent ? (
|
||||
<GeneratingContainer>
|
||||
<ClipLoader size={20} color="var(--color-primary)" />
|
||||
<GeneratingText>{t('html_artifacts.generating_content', 'Generating content...')}</GeneratingText>
|
||||
</GeneratingContainer>
|
||||
) : isStreaming && hasContent ? (
|
||||
<>
|
||||
<TerminalPreview $theme={theme}>
|
||||
<TerminalContent $theme={theme}>
|
||||
<TerminalLine>
|
||||
<TerminalPrompt $theme={theme}>$</TerminalPrompt>
|
||||
<TerminalCodeLine $theme={theme}>
|
||||
{getFormattedCodePreview(htmlContent)}
|
||||
<TerminalCursor $theme={theme} />
|
||||
</TerminalCodeLine>
|
||||
</TerminalLine>
|
||||
</TerminalContent>
|
||||
</TerminalPreview>
|
||||
<ButtonContainer>
|
||||
<Button icon={<CodeOutlined />} onClick={handleOpenInEditor} type="primary">
|
||||
{t('chat.artifacts.button.preview')}
|
||||
</Button>
|
||||
</ButtonContainer>
|
||||
</>
|
||||
) : (
|
||||
<ButtonContainer>
|
||||
<Button icon={<CodeOutlined />} onClick={handleOpenInEditor} type="primary" disabled={!hasContent}>
|
||||
{t('chat.artifacts.button.preview')}
|
||||
</Button>
|
||||
<Button icon={<LinkOutlined />} onClick={handleOpenExternal} disabled={!hasContent}>
|
||||
{t('chat.artifacts.button.openExternal')}
|
||||
</Button>
|
||||
<Button icon={<Download size={16} />} onClick={handleDownload} disabled={!hasContent}>
|
||||
{t('code_block.download')}
|
||||
</Button>
|
||||
</ButtonContainer>
|
||||
)}
|
||||
</Content>
|
||||
</Container>
|
||||
|
||||
{/* 弹窗组件 */}
|
||||
<HtmlArtifactsPopup open={isPopupOpen} title={title} html={htmlContent} onClose={handleClosePopup} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const shimmer = keyframes`
|
||||
0% {
|
||||
background-position: -200px 0;
|
||||
}
|
||||
100% {
|
||||
background-position: calc(200px + 100%) 0;
|
||||
}
|
||||
`
|
||||
|
||||
const Container = styled.div<{ $isStreaming: boolean }>`
|
||||
background: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin: 16px 0;
|
||||
`
|
||||
|
||||
const GeneratingContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 20px;
|
||||
min-height: 78px;
|
||||
`
|
||||
|
||||
const GeneratingText = styled.div`
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
`
|
||||
|
||||
const Header = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 20px 24px 16px;
|
||||
background: var(--color-background-soft);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
position: relative;
|
||||
border-radius: 8px 8px 0 0;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, #3b82f6, #8b5cf6, #06b6d4);
|
||||
background-size: 200% 100%;
|
||||
animation: ${shimmer} 3s ease-in-out infinite;
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
`
|
||||
|
||||
const IconWrapper = styled.div<{ $isStreaming: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
||||
border-radius: 12px;
|
||||
color: white;
|
||||
box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.3);
|
||||
transition: background 0.3s ease;
|
||||
|
||||
${(props) =>
|
||||
props.$isStreaming &&
|
||||
`
|
||||
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); /* Darker orange for loading */
|
||||
box-shadow: 0 4px 6px -1px rgba(245, 158, 11, 0.3);
|
||||
`}
|
||||
`
|
||||
|
||||
const TitleSection = styled.div`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
`
|
||||
|
||||
const Title = styled.h3`
|
||||
margin: 0 !important;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
line-height: 1.4;
|
||||
`
|
||||
|
||||
const TypeBadge = styled.div`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 6px;
|
||||
background: var(--color-background-mute);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
width: fit-content;
|
||||
`
|
||||
|
||||
const Content = styled.div`
|
||||
padding: 0;
|
||||
background: var(--color-background);
|
||||
`
|
||||
|
||||
const ButtonContainer = styled.div`
|
||||
margin: 16px !important;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
`
|
||||
|
||||
const TerminalPreview = styled.div<{ $theme: ThemeMode }>`
|
||||
margin: 16px;
|
||||
background: ${(props) => (props.$theme === 'dark' ? '#1e1e1e' : '#f0f0f0')};
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
|
||||
`
|
||||
|
||||
const TerminalContent = styled.div<{ $theme: ThemeMode }>`
|
||||
padding: 12px;
|
||||
background: ${(props) => (props.$theme === 'dark' ? '#1e1e1e' : '#f0f0f0')};
|
||||
color: ${(props) => (props.$theme === 'dark' ? '#cccccc' : '#333333')};
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
min-height: 80px;
|
||||
`
|
||||
|
||||
const TerminalLine = styled.div`
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
`
|
||||
|
||||
const TerminalCodeLine = styled.span<{ $theme: ThemeMode }>`
|
||||
flex: 1;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: ${(props) => (props.$theme === 'dark' ? '#cccccc' : '#333333')};
|
||||
background-color: transparent !important;
|
||||
`
|
||||
|
||||
const TerminalPrompt = styled.span<{ $theme: ThemeMode }>`
|
||||
color: ${(props) => (props.$theme === 'dark' ? '#00ff00' : '#007700')};
|
||||
font-weight: bold;
|
||||
flex-shrink: 0;
|
||||
`
|
||||
|
||||
const TerminalCursor = styled.span<{ $theme: ThemeMode }>`
|
||||
display: inline-block;
|
||||
width: 2px;
|
||||
height: 16px;
|
||||
background: ${(props) => (props.$theme === 'dark' ? '#00ff00' : '#007700')};
|
||||
animation: ${keyframes`
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
`} 1s infinite;
|
||||
margin-left: 2px;
|
||||
`
|
||||
|
||||
export default HtmlArtifactsCard
|
||||
459
src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx
Normal file
459
src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx
Normal file
@@ -0,0 +1,459 @@
|
||||
import CodeEditor from '@renderer/components/CodeEditor'
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { classNames } from '@renderer/utils'
|
||||
import { Button, Modal } from 'antd'
|
||||
import { Code, Maximize2, Minimize2, Monitor, MonitorSpeaker, X } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface HtmlArtifactsPopupProps {
|
||||
open: boolean
|
||||
title: string
|
||||
html: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
type ViewMode = 'split' | 'code' | 'preview'
|
||||
|
||||
// 视图模式配置
|
||||
const VIEW_MODE_CONFIG = {
|
||||
split: {
|
||||
key: 'split' as const,
|
||||
icon: MonitorSpeaker,
|
||||
i18nKey: 'html_artifacts.split'
|
||||
},
|
||||
code: {
|
||||
key: 'code' as const,
|
||||
icon: Code,
|
||||
i18nKey: 'html_artifacts.code'
|
||||
},
|
||||
preview: {
|
||||
key: 'preview' as const,
|
||||
icon: Monitor,
|
||||
i18nKey: 'html_artifacts.preview'
|
||||
}
|
||||
} as const
|
||||
|
||||
// 抽取头部组件
|
||||
interface ModalHeaderProps {
|
||||
title: string
|
||||
isFullscreen: boolean
|
||||
viewMode: ViewMode
|
||||
onViewModeChange: (mode: ViewMode) => void
|
||||
onToggleFullscreen: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const ModalHeaderComponent: React.FC<ModalHeaderProps> = ({
|
||||
title,
|
||||
isFullscreen,
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
onToggleFullscreen,
|
||||
onCancel
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const viewButtons = useMemo(() => {
|
||||
return Object.values(VIEW_MODE_CONFIG).map(({ key, icon: Icon, i18nKey }) => (
|
||||
<ViewButton
|
||||
key={key}
|
||||
size="small"
|
||||
type={viewMode === key ? 'primary' : 'default'}
|
||||
icon={<Icon size={14} />}
|
||||
onClick={() => onViewModeChange(key)}>
|
||||
{t(i18nKey)}
|
||||
</ViewButton>
|
||||
))
|
||||
}, [viewMode, onViewModeChange, t])
|
||||
|
||||
return (
|
||||
<ModalHeader onDoubleClick={onToggleFullscreen} className={classNames({ drag: isFullscreen })}>
|
||||
<HeaderLeft $isFullscreen={isFullscreen}>
|
||||
<TitleText>{title}</TitleText>
|
||||
</HeaderLeft>
|
||||
<HeaderCenter>
|
||||
<ViewControls>{viewButtons}</ViewControls>
|
||||
</HeaderCenter>
|
||||
<HeaderRight>
|
||||
<Button
|
||||
onClick={onToggleFullscreen}
|
||||
type="text"
|
||||
icon={isFullscreen ? <Minimize2 size={16} /> : <Maximize2 size={16} />}
|
||||
className="nodrag"
|
||||
/>
|
||||
<Button onClick={onCancel} type="text" icon={<X size={16} />} className="nodrag" />
|
||||
</HeaderRight>
|
||||
</ModalHeader>
|
||||
)
|
||||
}
|
||||
|
||||
// 抽取代码编辑器组件
|
||||
interface CodeSectionProps {
|
||||
html: string
|
||||
visible: boolean
|
||||
onCodeChange: (code: string) => void
|
||||
}
|
||||
|
||||
const CodeSectionComponent: React.FC<CodeSectionProps> = ({ html, visible, onCodeChange }) => {
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<CodeSection $visible={visible}>
|
||||
<CodeEditorWrapper>
|
||||
<CodeEditor
|
||||
value={html}
|
||||
language="html"
|
||||
editable={true}
|
||||
onSave={onCodeChange}
|
||||
style={{ height: '100%' }}
|
||||
options={{
|
||||
stream: false,
|
||||
collapsible: false
|
||||
}}
|
||||
/>
|
||||
</CodeEditorWrapper>
|
||||
</CodeSection>
|
||||
)
|
||||
}
|
||||
|
||||
// 抽取预览组件
|
||||
interface PreviewSectionProps {
|
||||
html: string
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
const PreviewSectionComponent: React.FC<PreviewSectionProps> = ({ html, visible }) => {
|
||||
const htmlContent = html || ''
|
||||
const [debouncedHtml, setDebouncedHtml] = useState(htmlContent)
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const latestHtmlRef = useRef(htmlContent)
|
||||
const currentRenderedHtmlRef = useRef(htmlContent)
|
||||
const { t } = useTranslation()
|
||||
|
||||
// 更新最新的HTML内容引用
|
||||
useEffect(() => {
|
||||
latestHtmlRef.current = htmlContent
|
||||
}, [htmlContent])
|
||||
|
||||
// 固定频率渲染 HTML 内容,每2秒钟检查并更新一次
|
||||
useEffect(() => {
|
||||
// 立即设置初始内容
|
||||
setDebouncedHtml(htmlContent)
|
||||
currentRenderedHtmlRef.current = htmlContent
|
||||
|
||||
// 设置定时器,每2秒检查一次内容是否有变化
|
||||
intervalRef.current = setInterval(() => {
|
||||
if (latestHtmlRef.current !== currentRenderedHtmlRef.current) {
|
||||
setDebouncedHtml(latestHtmlRef.current)
|
||||
currentRenderedHtmlRef.current = latestHtmlRef.current
|
||||
}
|
||||
}, 2000) // 2秒固定频率
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
}
|
||||
}
|
||||
}, []) // 只在组件挂载时执行一次
|
||||
|
||||
if (!visible) return null
|
||||
const isHtmlEmpty = !debouncedHtml.trim()
|
||||
|
||||
return (
|
||||
<PreviewSection $visible={visible}>
|
||||
{isHtmlEmpty ? (
|
||||
<EmptyPreview>
|
||||
<p>{t('html_artifacts.empty_preview', 'No content to preview')}</p>
|
||||
</EmptyPreview>
|
||||
) : (
|
||||
<PreviewFrame
|
||||
key={debouncedHtml} // 强制重新创建iframe当内容变化时
|
||||
srcDoc={debouncedHtml}
|
||||
title="HTML Preview"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms"
|
||||
/>
|
||||
)}
|
||||
</PreviewSection>
|
||||
)
|
||||
}
|
||||
|
||||
// 主弹窗组件
|
||||
const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, html, onClose }) => {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('split')
|
||||
const [currentHtml, setCurrentHtml] = useState(html)
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
|
||||
// 当外部html更新时,同步更新内部状态
|
||||
useEffect(() => {
|
||||
setCurrentHtml(html)
|
||||
}, [html])
|
||||
|
||||
// 计算视图可见性
|
||||
const viewVisibility = useMemo(
|
||||
() => ({
|
||||
code: viewMode === 'split' || viewMode === 'code',
|
||||
preview: viewMode === 'split' || viewMode === 'preview'
|
||||
}),
|
||||
[viewMode]
|
||||
)
|
||||
|
||||
// 计算Modal属性
|
||||
const modalProps = useMemo(
|
||||
() => ({
|
||||
width: isFullscreen ? '100vw' : '90vw',
|
||||
height: isFullscreen ? '100vh' : 'auto',
|
||||
style: { maxWidth: isFullscreen ? '100vw' : '1400px' }
|
||||
}),
|
||||
[isFullscreen]
|
||||
)
|
||||
|
||||
const handleOk = useCallback(() => {
|
||||
onClose()
|
||||
}, [onClose])
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
onClose()
|
||||
}, [onClose])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
onClose()
|
||||
}, [onClose])
|
||||
|
||||
const handleCodeChange = useCallback((newCode: string) => {
|
||||
setCurrentHtml(newCode)
|
||||
}, [])
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
setIsFullscreen((prev) => !prev)
|
||||
}, [])
|
||||
|
||||
const handleViewModeChange = useCallback((mode: ViewMode) => {
|
||||
setViewMode(mode)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<StyledModal
|
||||
$isFullscreen={isFullscreen}
|
||||
title={
|
||||
<ModalHeaderComponent
|
||||
title={title}
|
||||
isFullscreen={isFullscreen}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={handleViewModeChange}
|
||||
onToggleFullscreen={toggleFullscreen}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
}
|
||||
open={open}
|
||||
onOk={handleOk}
|
||||
onCancel={handleCancel}
|
||||
afterClose={handleClose}
|
||||
centered
|
||||
destroyOnClose
|
||||
{...modalProps}
|
||||
footer={null}
|
||||
closable={false}>
|
||||
<Container>
|
||||
<CodeSectionComponent html={currentHtml} visible={viewVisibility.code} onCodeChange={handleCodeChange} />
|
||||
<PreviewSectionComponent html={currentHtml} visible={viewVisibility.preview} />
|
||||
</Container>
|
||||
</StyledModal>
|
||||
)
|
||||
}
|
||||
|
||||
// 样式组件保持不变
|
||||
const commonModalBodyStyles = `
|
||||
padding: 0 !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
`
|
||||
|
||||
const StyledModal = styled(Modal)<{ $isFullscreen?: boolean }>`
|
||||
${(props) =>
|
||||
props.$isFullscreen
|
||||
? `
|
||||
.ant-modal-wrap {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.ant-modal {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
max-width: none !important;
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
height: calc(100vh - 45px) !important;
|
||||
${commonModalBodyStyles}
|
||||
max-height: initial !important;
|
||||
}
|
||||
`
|
||||
: `
|
||||
.ant-modal-body {
|
||||
height: 80vh !important;
|
||||
${commonModalBodyStyles}
|
||||
min-height: 600px !important;
|
||||
}
|
||||
`}
|
||||
|
||||
.ant-modal-body {
|
||||
${commonModalBodyStyles}
|
||||
}
|
||||
|
||||
.ant-modal-content {
|
||||
border-radius: ${(props) => (props.$isFullscreen ? '0px' : '12px')};
|
||||
overflow: hidden;
|
||||
height: ${(props) => (props.$isFullscreen ? '100vh' : 'auto')};
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.ant-modal-header {
|
||||
padding: 10px 12px !important;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-background);
|
||||
border-radius: 0 !important;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.ant-modal-title {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
`
|
||||
|
||||
const ModalHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
position: relative;
|
||||
`
|
||||
|
||||
const HeaderLeft = styled.div<{ $isFullscreen?: boolean }>`
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-left: ${(props) => (props.$isFullscreen && isMac ? '65px' : '12px')};
|
||||
`
|
||||
|
||||
const HeaderCenter = styled.div`
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
`
|
||||
|
||||
const HeaderRight = styled.div`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
`
|
||||
|
||||
const TitleText = styled.span`
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`
|
||||
|
||||
const ViewControls = styled.div`
|
||||
display: flex;
|
||||
width: auto;
|
||||
gap: 8px;
|
||||
padding: 4px;
|
||||
background: var(--color-background-mute);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
-webkit-app-region: no-drag;
|
||||
`
|
||||
|
||||
const ViewButton = styled(Button)`
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
|
||||
&.ant-btn-primary {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.ant-btn-default {
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
background: var(--color-background);
|
||||
`
|
||||
|
||||
const CodeSection = styled.div<{ $visible: boolean }>`
|
||||
flex: ${(props) => (props.$visible ? '1' : '0')};
|
||||
min-width: ${(props) => (props.$visible ? '300px' : '0')};
|
||||
border-right: ${(props) => (props.$visible ? '1px solid var(--color-border)' : 'none')};
|
||||
overflow: hidden;
|
||||
display: ${(props) => (props.$visible ? 'flex' : 'none')};
|
||||
flex-direction: column;
|
||||
`
|
||||
|
||||
const CodeEditorWrapper = styled.div`
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
.monaco-editor {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.cm-editor {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.cm-scroller {
|
||||
height: 100% !important;
|
||||
}
|
||||
`
|
||||
|
||||
const PreviewSection = styled.div<{ $visible: boolean }>`
|
||||
flex: ${(props) => (props.$visible ? '1' : '0')};
|
||||
min-width: ${(props) => (props.$visible ? '300px' : '0')};
|
||||
background: white;
|
||||
overflow: hidden;
|
||||
display: ${(props) => (props.$visible ? 'block' : 'none')};
|
||||
`
|
||||
|
||||
const PreviewFrame = styled.iframe`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
background: white;
|
||||
`
|
||||
const EmptyPreview = styled.div`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 14px;
|
||||
`
|
||||
|
||||
export default HtmlArtifactsPopup
|
||||
@@ -1,5 +1,5 @@
|
||||
import { nanoid } from '@reduxjs/toolkit'
|
||||
import { CodeTool, usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
|
||||
import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
|
||||
import SvgSpinners180Ring from '@renderer/components/Icons/SvgSpinners180Ring'
|
||||
import { useMermaid } from '@renderer/hooks/useMermaid'
|
||||
import { Flex, Spin } from 'antd'
|
||||
@@ -7,16 +7,14 @@ import { debounce } from 'lodash'
|
||||
import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props {
|
||||
children: string
|
||||
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
|
||||
}
|
||||
import PreviewError from './PreviewError'
|
||||
import { BasicPreviewProps } from './types'
|
||||
|
||||
/** 预览 Mermaid 图表
|
||||
* 通过防抖渲染提供比较统一的体验,减少闪烁。
|
||||
* FIXME: 等将来容易判断代码块结束位置时再重构。
|
||||
*/
|
||||
const MermaidPreview: React.FC<Props> = ({ children, setTools }) => {
|
||||
const MermaidPreview: React.FC<BasicPreviewProps> = ({ children, setTools }) => {
|
||||
const { mermaid, isLoading: isLoadingMermaid, error: mermaidError } = useMermaid()
|
||||
const mermaidRef = useRef<HTMLDivElement>(null)
|
||||
const diagramId = useRef<string>(`mermaid-${nanoid(6)}`).current
|
||||
@@ -143,7 +141,7 @@ const MermaidPreview: React.FC<Props> = ({ children, setTools }) => {
|
||||
return (
|
||||
<Spin spinning={isLoading} indicator={<SvgSpinners180Ring color="var(--color-text-2)" />}>
|
||||
<Flex vertical style={{ minHeight: isLoading ? '2rem' : 'auto' }}>
|
||||
{(mermaidError || error) && <StyledError>{mermaidError || error}</StyledError>}
|
||||
{(mermaidError || error) && <PreviewError>{mermaidError || error}</PreviewError>}
|
||||
<StyledMermaid ref={mermaidRef} className="mermaid" />
|
||||
</Flex>
|
||||
</Spin>
|
||||
@@ -154,14 +152,4 @@ const StyledMermaid = styled.div`
|
||||
overflow: auto;
|
||||
`
|
||||
|
||||
const StyledError = styled.div`
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
color: #ff4d4f;
|
||||
border: 1px solid #ff4d4f;
|
||||
border-radius: 4px;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
`
|
||||
|
||||
export default memo(MermaidPreview)
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { LoadingOutlined } from '@ant-design/icons'
|
||||
import { CodeTool, usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
|
||||
import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
|
||||
import { Spin } from 'antd'
|
||||
import pako from 'pako'
|
||||
import React, { memo, useCallback, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { BasicPreviewProps } from './types'
|
||||
|
||||
const PlantUMLServer = 'https://www.plantuml.com/plantuml'
|
||||
function encode64(data: Uint8Array) {
|
||||
let r = ''
|
||||
@@ -132,12 +134,7 @@ const PlantUMLServerImage: React.FC<PlantUMLServerImageProps> = ({ format, diagr
|
||||
)
|
||||
}
|
||||
|
||||
interface PlantUMLProps {
|
||||
children: string
|
||||
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
|
||||
}
|
||||
|
||||
const PlantUmlPreview: React.FC<PlantUMLProps> = ({ children, setTools }) => {
|
||||
const PlantUmlPreview: React.FC<BasicPreviewProps> = ({ children, setTools }) => {
|
||||
const { t } = useTranslation()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -174,7 +171,7 @@ const PlantUmlPreview: React.FC<PlantUMLProps> = ({ children, setTools }) => {
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
<PlantUMLServerImage format="svg" diagram={children} className="plantuml-preview" />
|
||||
<PlantUMLServerImage format="svg" diagram={children} className="plantuml-preview special-preview" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
14
src/renderer/src/components/CodeBlockView/PreviewError.tsx
Normal file
14
src/renderer/src/components/CodeBlockView/PreviewError.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { memo } from 'react'
|
||||
import { styled } from 'styled-components'
|
||||
|
||||
const PreviewError = styled.div`
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
color: #ff4d4f;
|
||||
border: 1px solid #ff4d4f;
|
||||
border-radius: 4px;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
`
|
||||
|
||||
export default memo(PreviewError)
|
||||
@@ -1,15 +1,12 @@
|
||||
import { CodeTool, usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
|
||||
import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
|
||||
import { memo, useEffect, useRef } from 'react'
|
||||
|
||||
interface Props {
|
||||
children: string
|
||||
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
|
||||
}
|
||||
import { BasicPreviewProps } from './types'
|
||||
|
||||
/**
|
||||
* 使用 Shadow DOM 渲染 SVG
|
||||
*/
|
||||
const SvgPreview: React.FC<Props> = ({ children, setTools }) => {
|
||||
const SvgPreview: React.FC<BasicPreviewProps> = ({ children, setTools }) => {
|
||||
const svgContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -58,7 +55,7 @@ const SvgPreview: React.FC<Props> = ({ children, setTools }) => {
|
||||
handleDownload
|
||||
})
|
||||
|
||||
return <div ref={svgContainerRef} className="svg-preview" />
|
||||
return <div ref={svgContainerRef} className="svg-preview special-preview" />
|
||||
}
|
||||
|
||||
export default memo(SvgPreview)
|
||||
|
||||
20
src/renderer/src/components/CodeBlockView/constants.ts
Normal file
20
src/renderer/src/components/CodeBlockView/constants.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import GraphvizPreview from './GraphvizPreview'
|
||||
import MermaidPreview from './MermaidPreview'
|
||||
import PlantUmlPreview from './PlantUmlPreview'
|
||||
import SvgPreview from './SvgPreview'
|
||||
|
||||
/**
|
||||
* 特殊视图语言列表
|
||||
*/
|
||||
export const SPECIAL_VIEWS = ['mermaid', 'plantuml', 'svg', 'dot', 'graphviz']
|
||||
|
||||
/**
|
||||
* 特殊视图组件映射表
|
||||
*/
|
||||
export const SPECIAL_VIEW_COMPONENTS = {
|
||||
mermaid: MermaidPreview,
|
||||
plantuml: PlantUmlPreview,
|
||||
svg: SvgPreview,
|
||||
dot: GraphvizPreview,
|
||||
graphviz: GraphvizPreview
|
||||
} as const
|
||||
2
src/renderer/src/components/CodeBlockView/index.ts
Normal file
2
src/renderer/src/components/CodeBlockView/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './types'
|
||||
export * from './view'
|
||||
14
src/renderer/src/components/CodeBlockView/types.ts
Normal file
14
src/renderer/src/components/CodeBlockView/types.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { CodeTool } from '@renderer/components/CodeToolbar'
|
||||
|
||||
/**
|
||||
* 预览组件的基本 props
|
||||
*/
|
||||
export interface BasicPreviewProps {
|
||||
children: string
|
||||
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 视图模式
|
||||
*/
|
||||
export type ViewMode = 'source' | 'special' | 'split'
|
||||
@@ -12,13 +12,10 @@ import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import CodePreview from './CodePreview'
|
||||
import HtmlArtifacts from './HtmlArtifacts'
|
||||
import MermaidPreview from './MermaidPreview'
|
||||
import PlantUmlPreview from './PlantUmlPreview'
|
||||
import { SPECIAL_VIEW_COMPONENTS, SPECIAL_VIEWS } from './constants'
|
||||
import HtmlArtifactsCard from './HtmlArtifactsCard'
|
||||
import StatusBar from './StatusBar'
|
||||
import SvgPreview from './SvgPreview'
|
||||
|
||||
type ViewMode = 'source' | 'special' | 'split'
|
||||
import { ViewMode } from './types'
|
||||
|
||||
interface Props {
|
||||
children: string
|
||||
@@ -42,9 +39,10 @@ interface Props {
|
||||
* - quick 工具
|
||||
* - core 工具
|
||||
*/
|
||||
const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
|
||||
export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave }) => {
|
||||
const { t } = useTranslation()
|
||||
const { codeEditor, codeExecution } = useSettings()
|
||||
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('special')
|
||||
const [isRunning, setIsRunning] = useState(false)
|
||||
const [output, setOutput] = useState('')
|
||||
@@ -56,7 +54,7 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
|
||||
return codeExecution.enabled && language === 'python'
|
||||
}, [codeExecution.enabled, language])
|
||||
|
||||
const hasSpecialView = useMemo(() => ['mermaid', 'plantuml', 'svg'].includes(language), [language])
|
||||
const hasSpecialView = useMemo(() => SPECIAL_VIEWS.includes(language), [language])
|
||||
|
||||
const isInSpecialView = useMemo(() => {
|
||||
return hasSpecialView && viewMode === 'special'
|
||||
@@ -200,14 +198,16 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
|
||||
|
||||
// 特殊视图组件映射
|
||||
const specialView = useMemo(() => {
|
||||
if (language === 'mermaid') {
|
||||
return <MermaidPreview setTools={setTools}>{children}</MermaidPreview>
|
||||
} else if (language === 'plantuml' && isValidPlantUML(children)) {
|
||||
return <PlantUmlPreview setTools={setTools}>{children}</PlantUmlPreview>
|
||||
} else if (language === 'svg') {
|
||||
return <SvgPreview setTools={setTools}>{children}</SvgPreview>
|
||||
const SpecialView = SPECIAL_VIEW_COMPONENTS[language as keyof typeof SPECIAL_VIEW_COMPONENTS]
|
||||
|
||||
if (!SpecialView) return null
|
||||
|
||||
// PlantUML 语法验证
|
||||
if (language === 'plantuml' && !isValidPlantUML(children)) {
|
||||
return null
|
||||
}
|
||||
return null
|
||||
|
||||
return <SpecialView setTools={setTools}>{children}</SpecialView>
|
||||
}, [children, language])
|
||||
|
||||
const renderHeader = useMemo(() => {
|
||||
@@ -228,27 +228,29 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
|
||||
)
|
||||
}, [specialView, sourceView, viewMode])
|
||||
|
||||
const renderArtifacts = useMemo(() => {
|
||||
if (language === 'html') {
|
||||
return <HtmlArtifacts html={children} />
|
||||
}
|
||||
return null
|
||||
}, [children, language])
|
||||
// HTML 代码块特殊处理 - 在所有 hooks 调用之后
|
||||
if (language === 'html') {
|
||||
return <HtmlArtifactsCard html={children} />
|
||||
}
|
||||
|
||||
return (
|
||||
<CodeBlockWrapper className="code-block" $isInSpecialView={isInSpecialView}>
|
||||
{renderHeader}
|
||||
<CodeToolbar tools={tools} />
|
||||
{renderContent}
|
||||
{renderArtifacts}
|
||||
{isExecutable && output && <StatusBar>{output}</StatusBar>}
|
||||
</CodeBlockWrapper>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const CodeBlockWrapper = styled.div<{ $isInSpecialView: boolean }>`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
/* FIXME: 最小宽度用于解决两个问题。
|
||||
* 一是 CodePreview 在气泡样式下的用户消息中无法撑开气泡,
|
||||
* 二是 代码块内容过少时 toolbar 会和 title 重叠。
|
||||
*/
|
||||
min-width: 45ch;
|
||||
|
||||
.code-toolbar {
|
||||
background-color: ${(props) => (props.$isInSpecialView ? 'transparent' : 'var(--color-background-mute)')};
|
||||
@@ -290,9 +292,12 @@ const SplitViewWrapper = styled.div`
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&:not(:has(+ [class*='Container'])) {
|
||||
&:not(:has(+ .html-artifacts)) {
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
`
|
||||
|
||||
export default memo(CodeBlockView)
|
||||
&:not(:has(+ [class*='Container'])) {
|
||||
border-radius: 0 0 8px 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
`
|
||||
@@ -1,55 +0,0 @@
|
||||
import { linter } from '@codemirror/lint' // statically imported by @uiw/codemirror-extensions-basic-setup
|
||||
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
||||
import { Extension } from '@uiw/react-codemirror'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
// 语言对应的 linter 加载器
|
||||
const linterLoaders: Record<string, () => Promise<any>> = {
|
||||
json: async () => {
|
||||
const jsonParseLinter = await import('@codemirror/lang-json').then((mod) => mod.jsonParseLinter)
|
||||
return linter(jsonParseLinter())
|
||||
}
|
||||
}
|
||||
|
||||
export const useLanguageExtensions = (language: string, lint?: boolean) => {
|
||||
const { languageMap } = useCodeStyle()
|
||||
const [extensions, setExtensions] = useState<Extension[]>([])
|
||||
|
||||
// 加载语言
|
||||
useEffect(() => {
|
||||
let normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase()
|
||||
|
||||
// 如果语言名包含 `-`,转换为驼峰命名法
|
||||
if (normalizedLang.includes('-')) {
|
||||
normalizedLang = normalizedLang.replace(/-([a-z])/g, (_, char) => char.toUpperCase())
|
||||
}
|
||||
|
||||
import('@uiw/codemirror-extensions-langs')
|
||||
.then(({ loadLanguage }) => {
|
||||
const extension = loadLanguage(normalizedLang as any)
|
||||
if (extension) {
|
||||
setExtensions((prev) => [...prev, extension])
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.debug(`Failed to load language: ${normalizedLang}`, error)
|
||||
})
|
||||
}, [language, languageMap])
|
||||
|
||||
useEffect(() => {
|
||||
if (!lint) return
|
||||
|
||||
const loader = linterLoaders[language]
|
||||
if (loader) {
|
||||
loader()
|
||||
.then((extension) => {
|
||||
setExtensions((prev) => [...prev, extension])
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`Failed to load linter for ${language}`, error)
|
||||
})
|
||||
}
|
||||
}, [language, lint])
|
||||
|
||||
return extensions
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user