Compare commits

...

16 Commits

Author SHA1 Message Date
kangfenmao
98c993eae8 feat: add privacy policy popups and quit functionality
- Introduced new privacy policy popups in both English and Chinese.
- Added App_Quit channel to handle application quitting.
- Updated IpcChannel enum to include App_Quit.
- Implemented quit functionality in the IPC handler and preload API.
- Created PrivacyPopup component to manage user consent for privacy policy.
2025-09-22 19:28:49 +08:00
kangfenmao
fa9f59146e chore(version): 1.5.11 2025-09-12 16:20:22 +08:00
kangfenmao
c1d8bf38ef refactor: update styles and improve navbar handling
- Removed unnecessary margin-bottom style from bubble markdown.
- Adjusted margin in Prompt component for better layout.
- Enhanced useAppInit hook to include navbar position logic for background styling.
- Added alignment to ErrorBlock alert for improved visual consistency.
2025-09-12 16:20:07 +08:00
George·Dong
7217a7216e fix/miniapp-tab-cache (#10024)
* feat(minapps): add Tabs-mode webview pool and integrate page shell

* fix(minapp): position tabs pool below toolbar and preserve layout

* style(minapp): fix format issues

* style(minapps): optimize var name

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* feat(minapps): stabilize tab webview lifecycle and mount logic

* refactor(minapps): improve webview detection and state handling

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
# Conflicts:
#	src/renderer/src/components/Tab/TabContainer.tsx
2025-09-12 15:54:16 +08:00
George·Dong
d35998bd74 fix(codetool): incorrect codetool workdir on macOS (#10056)
fix(codetool): run command and cd in same shell on macOS
2025-09-12 15:53:31 +08:00
359156687
dbd090377d fix: workaround for electron build issue on rpm package (#9986)
* fix: add workaround for electron build issue on rpm package

Signed-off-by: 33671 <error_z@yeah.net>

* fix format

Signed-off-by: 33671 <error_z@yeah.net>

---------

Signed-off-by: 33671 <error_z@yeah.net>
2025-09-12 15:51:56 +08:00
Pleasure1234
2ebcb43d50 fix: improve note sorting behavior for drag and drop operations (#9971)
* fix: improve note sorting behavior for drag and drop operations

- Skip automatic sorting when performing same-level drag reordering
- Preserve treePath during same-level moves to maintain manual ordering
- Return special indicator for manual reorder operations to prevent conflicts

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: type safety issue

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-12 15:47:28 +08:00
Konv Suu
826b71deba fix(miniapp): title container background style align with sidebar (#9915) 2025-09-12 15:46:06 +08:00
George·Dong
7c0a800d9d refactor(miniapp): 适配顶部状态栏 (#9695)
* feat(minapp): add top-navbar fixed toolbar and layout adjustments

* refactor(minapps): optimize toolbar

* fix(minapps): hide redundant components

* feat(minapp): improve webview load handling and popup visibility

* feat(minapps): improve WebView load handling and clean up launchpad

* feat(minapp): 实现活跃小程序数量限制与关闭缓存清理

* fix(minapp): 修复WebView高度不正确的问题

* fix(minapp): show popup only for left navbar mode

* feat(minapps): add full-screen loading mask for webview

* fix: lint error

* feat(minapp): fix drawer sizing and layout when side navbar present

* refactor(minapp): 移除固定工具栏组件,优化弹窗容器布局

* feat(minapps): memoize app lookup to avoid unnecessary recompute

* chore(minapps): optimize comments

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix(renderer): remove stray blank line in MinAppFullPageView

* refactor(minapps): remove top navbar opened minapps component

* refactor(tab): remove unused TopNavbarOpenedMinappTabs import

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-12 15:42:50 +08:00
beyondkmp
6f9906fe49 fix: update User-Agent handling in WebviewService to conditionally set based on URL (#9931)
# Conflicts:
#	yarn.lock
2025-09-12 15:22:59 +08:00
kangfenmao
ba7eec64b0 feat: add client ID generation and update user agent headers in AppUpdater
- Introduced a new method in ConfigManager to generate and retrieve a unique client ID.
- Updated AppUpdater to include the client ID in the request headers alongside the user agent.
2025-09-12 09:59:22 +08:00
Phantom
83d2403339 refactor(OGCard): replace static image with dynamic generated graph (#10115)
* refactor(OGCard): replace static image with dynamic generated graph

- Replace CherryLogo import with GeneratedGraph component for dynamic preview
- Extract image height to constant for consistency
- Use useCallback for GeneratedGraph to optimize performance

* chore: remove unused banner.png asset

* style(OGCard): change image height from pixels to rem units

Use rem units for better responsiveness and consistency with the design system
2025-09-12 09:56:14 +08:00
kangfenmao
bc17dcb911 chore(version): 1.5.10 2025-09-11 16:28:24 +08:00
Konv Suu
44e93671fa fix: 对齐模型设置中 avatar 的样式 (#9829)
* fix: 对齐模型设置中 avatar 的样式

* update

* update

* fix: 修复上传弹出两次文件夹的问题

* update
2025-09-11 15:21:27 +08:00
Phantom
a5bfd8f3db fix: handle multiple content source when pasting to translate input (#9919)
* fix(translate): 处理粘贴事件时增加处理中状态检查

* fix(translate): 修复粘贴文本时未阻止默认行为的问题

添加event.preventDefault()以防止粘贴文本时触发默认行为
同时优化粘贴逻辑,优先处理文本内容
2025-09-11 15:20:46 +08:00
LiuVaayne
07c3c33acc refactor(mcp): enhance MCPService logging and error handling (#9878) 2025-09-11 15:19:31 +08:00
44 changed files with 2101 additions and 377 deletions

View File

@@ -110,6 +110,10 @@ linux:
StartupWMClass: CherryStudio
mimeTypes:
- x-scheme-handler/cherrystudio
rpm:
# Workaround for electron build issue on rpm package:
# https://github.com/electron/forge/issues/3594
fpm: ['--rpm-rpmbuild-define=_build_id_links none']
publish:
provider: generic
url: https://releases.cherry-ai.com
@@ -121,24 +125,6 @@ afterSign: scripts/notarize.js
artifactBuildCompleted: scripts/artifact-build-completed.js
releaseInfo:
releaseNotes: |
✨ 重要更新:
- 新增笔记模块,支持富文本编辑和管理
- 内置 GLM-4.5-Flash 免费模型(由智谱开放平台提供)
- 内置 Qwen3-8B 免费模型(由硅基流动提供)
- 新增 Nano BananaGemini 2.5 Flash Image模型支持
- 新增系统 OCR 功能 (macOS & Windows)
- 新增图片 OCR 识别和翻译功能
- 模型切换支持通过标签筛选
- 翻译功能增强:历史搜索和收藏
🔧 性能优化:
- 优化历史页面搜索性能
- 优化拖拽列表组件交互
- 升级 Electron 到 37.4.0
🐛 修复问题:
- 修复知识库加密 PDF 文档处理
- 修复导航栏在左侧时笔记侧边栏按钮缺失
- 修复多个模型兼容性问题
- 修复 MCP 相关问题
- 其他稳定性改进
Top navigation bar mode will display the mini-program using tabs
Fixed the issue with Google mini-program login
Notes support drag and drop sorting

View File

@@ -1,6 +1,6 @@
{
"name": "CherryStudio",
"version": "1.5.9",
"version": "1.5.11",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",

View File

@@ -8,6 +8,7 @@ export enum IpcChannel {
App_ShowUpdateDialog = 'app:show-update-dialog',
App_CheckForUpdate = 'app:check-for-update',
App_Reload = 'app:reload',
App_Quit = 'app:quit',
App_Info = 'app:info',
App_Proxy = 'app:proxy',
App_SetLaunchToTray = 'app:set-launch-to-tray',

View File

@@ -0,0 +1,252 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Privacy Policy</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
background: transparent;
margin: 0 auto;
}
body.dark {
background: transparent;
color: rgba(255, 255, 255, 0.85);
}
h1 {
font-size: 24px;
font-weight: 600;
margin-bottom: 20px;
color: #1a1a1a;
}
body.dark h1 {
color: rgba(255, 255, 255, 0.95);
}
h2 {
font-size: 18px;
font-weight: 600;
margin-top: 24px;
margin-bottom: 12px;
color: #2c2c2c;
}
body.dark h2 {
color: rgba(255, 255, 255, 0.9);
}
p {
margin: 12px 0;
line-height: 1.8;
}
body.dark p {
color: rgba(255, 255, 255, 0.8);
}
ul {
margin: 12px 0;
padding-left: 24px;
}
li {
margin: 6px 0;
line-height: 1.6;
}
body.dark li {
color: rgba(255, 255, 255, 0.75);
}
a {
color: #0066cc;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
body.dark a {
color: #4da6ff;
}
.footer {
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid #e0e0e0;
font-size: 13px;
color: #666;
}
body.dark .footer {
border-top-color: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.5);
}
.content-wrapper {
max-height: calc(100vh - 40px);
overflow-y: auto;
padding-right: 10px;
background: transparent;
}
/* Scrollbar styles - Light mode */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.05);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.3);
}
/* Scrollbar styles - Dark mode */
body.dark ::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
}
body.dark ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
}
body.dark ::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
</style>
<script>
// Detect theme
document.addEventListener('DOMContentLoaded', function () {
const urlParams = new URLSearchParams(window.location.search);
const theme = urlParams.get('theme');
if (theme === 'dark') {
document.documentElement.classList.add('dark');
document.body.classList.add('dark');
}
});
</script>
</head>
<body>
<div class="content-wrapper">
<h1>Privacy Policy</h1>
<p>
Welcome to Cherry Studio (hereinafter referred to as "the Software" or "we"). We highly value your privacy
protection. This Privacy Policy explains how we process and protect your personal information and data.
Please read and understand this policy carefully before using the Software:
</p>
<h2>1. Information We Collect</h2>
<p>To optimize user experience and improve software quality, we may only collect the following anonymous,
non-personal information:</p>
<ul>
<li>Software version information</li>
<li>Activity and usage frequency of software features</li>
<li>Anonymous crash and error log information</li>
</ul>
<p>The above information is completely anonymous, does not involve any personal identity data, and cannot be
linked to your personal information.</p>
<h2>2. Information We Do Not Collect</h2>
<p>To maximize the protection of your privacy and security, we explicitly commit that we:</p>
<ul>
<li>Will not collect, save, transmit, or process model service API Key information you enter into the
Software</li>
<li>Will not collect, save, transmit, or process any conversation data generated during your use of the
Software, including but not limited to chat content, instruction information, knowledge base
information, vector data, and other custom content</li>
<li>Will not collect, save, transmit, or process any sensitive information that can identify personal
identity</li>
</ul>
<h2>3. Data Interaction Description</h2>
<p>
The Software uses API Keys from third-party model service providers that you apply for and configure
yourself to complete model calls and conversation functions. The model services you use (such as large
models, API interfaces, etc.) are directly provided by third-party providers of your choice. We do not
intervene, monitor, or interfere with the data transmission process.
</p>
<p>
Data interactions between you and third-party model services are governed by the privacy policies and user
agreements of third-party service providers. We recommend that you fully understand the privacy terms of
relevant service providers before use.
</p>
<h2>4. Local Data Security Protection</h2>
<p>The Software is a localized application, and all data is stored on your local device by default. We have
taken the following measures to ensure data security:</p>
<ul>
<li>Conversation records, configuration information, and other data are only saved on your local device</li>
<li>Data import/export functions are provided to facilitate your independent management and backup of data
</li>
<li>Your local data will not be uploaded to any server or cloud storage</li>
</ul>
<h2>5. Third-Party Services</h2>
<p>
When using the Software, you may access third-party services (such as AI model APIs, translation services,
etc.). The use of these third-party services is governed by their respective terms of service and privacy
policies. We strongly recommend that you carefully read and understand the relevant terms before use.
</p>
<h2>6. User Rights</h2>
<p>You have complete control over your data:</p>
<ul>
<li>You can view, modify, and delete all locally stored data at any time</li>
<li>You can choose whether to enable specific features or services</li>
<li>You can stop using the Software and delete all related data at any time</li>
</ul>
<h2>7. Children's Privacy Protection</h2>
<p>The Software is not intended for minors under 18 years of age. If you are a minor, please use the Software
under the guidance of a guardian.</p>
<h2>8. Privacy Policy Updates</h2>
<p>
We may update this Privacy Policy based on legal requirements or changes in product features. The updated
policy will be published in the Software and you will be notified before it takes effect. If you do not
agree with the updated terms, you can choose to stop using the Software.
</p>
<h2>9. Contact Us</h2>
<p>If you have any questions, suggestions, or complaints about this Privacy Policy, please contact us through
the following methods:</p>
<ul>
<li>
GitHub: <a href="https://github.com/CherryHQ/cherry-studio" target="_blank"
rel="noopener noreferrer">https://github.com/CherryHQ/cherry-studio</a>
</li>
<li>Email: support@cherry-ai.com</li>
</ul>
<div class="footer">
Last Updated: December 2024
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,230 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>隐私协议</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
background: transparent;
margin: 0 auto;
}
body.dark {
background: transparent;
color: rgba(255, 255, 255, 0.85);
}
h1 {
font-size: 24px;
font-weight: 600;
margin-bottom: 20px;
color: #1a1a1a;
}
body.dark h1 {
color: rgba(255, 255, 255, 0.95);
}
h2 {
font-size: 18px;
font-weight: 600;
margin-top: 24px;
margin-bottom: 12px;
color: #2c2c2c;
}
body.dark h2 {
color: rgba(255, 255, 255, 0.9);
}
p {
margin: 12px 0;
line-height: 1.8;
}
body.dark p {
color: rgba(255, 255, 255, 0.8);
}
ul {
margin: 12px 0;
padding-left: 24px;
}
li {
margin: 6px 0;
line-height: 1.6;
}
body.dark li {
color: rgba(255, 255, 255, 0.75);
}
a {
color: #0066cc;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
body.dark a {
color: #4da6ff;
}
.footer {
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid #e0e0e0;
font-size: 13px;
color: #666;
}
body.dark .footer {
border-top-color: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.5);
}
.content-wrapper {
overflow-y: auto;
padding-right: 10px;
background: transparent;
}
/* 滚动条样式 - 亮色模式 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.05);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.3);
}
/* 滚动条样式 - 暗色模式 */
body.dark ::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
}
body.dark ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
}
body.dark ::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
</style>
<script>
// 检测主题
document.addEventListener('DOMContentLoaded', function () {
const urlParams = new URLSearchParams(window.location.search);
const theme = urlParams.get('theme');
if (theme === 'dark') {
document.documentElement.classList.add('dark');
document.body.classList.add('dark');
}
});
</script>
</head>
<body>
<div class="content-wrapper">
<h1>隐私协议</h1>
<p>
欢迎使用 Cherry Studio以下简称"本软件"或"我们")。我们高度重视您的隐私保护,本隐私协议将说明我们如何处理与保护您的个人信息和数据。请在使用本软件前仔细阅读并理解本协议:
</p>
<h2>一、我们收集的信息范围</h2>
<p>为了优化用户体验和提升软件质量,我们仅可能会匿名收集以下非个人化信息:</p>
<ul>
<li>软件版本信息;</li>
<li>软件功能的活跃度、使用频次;</li>
<li>匿名的崩溃、错误日志信息;</li>
</ul>
<p>上述信息完全匿名,不会涉及任何个人身份数据,也无法关联到您的个人信息。</p>
<h2>二、我们不会收集的任何信息</h2>
<p>为了最大限度保护您的隐私安全,我们明确承诺:</p>
<ul>
<li>不会收集、保存、传输或处理您输入到本软件中的模型服务 API Key 信息;</li>
<li>不会收集、保存、传输或处理您在使用本软件过程中产生的任何对话数据,包括但不限于聊天内容、指令信息、知识库信息、向量数据及其他自定义内容;</li>
<li>不会收集、保存、传输或处理任何可识别个人身份的敏感信息。</li>
</ul>
<h2>三、数据交互说明</h2>
<p>
本软件采用您自行申请并配置的第三方模型服务提供商的 API Key以完成相关模型的调用与对话功能。您使用的模型服务例如大模型、API 接口等)由您选择的第三方提供商直接提供,我们不会介入、监控或干扰数据传输过程。
</p>
<p>
您与第三方模型服务之间的数据交互受第三方服务提供商的隐私政策和用户协议约束,我们建议您在使用前充分了解相关服务商的隐私条款。
</p>
<h2>四、本地数据的安全保护</h2>
<p>本软件为本地化应用程序,所有数据默认存储在您的本地设备上。我们采取了以下措施保障数据安全:</p>
<ul>
<li>对话记录、配置信息等数据仅保存在您的本地设备中;</li>
<li>提供数据导入/导出功能,方便您自主管理和备份数据;</li>
<li>不会将您的本地数据上传至任何服务器或云端存储。</li>
</ul>
<h2>五、第三方服务</h2>
<p>
在使用本软件过程中,您可能会接入第三方服务(如 AI 模型 API、翻译服务等。这些第三方服务的使用受其各自的服务条款和隐私政策约束。我们强烈建议您在使用前仔细阅读并理解相关条款。
</p>
<h2>六、用户权利</h2>
<p>您对自己的数据拥有完全的控制权:</p>
<ul>
<li>您可以随时查看、修改、删除本地存储的所有数据;</li>
<li>您可以选择是否启用特定功能或服务;</li>
<li>您可以随时停止使用本软件并删除所有相关数据。</li>
</ul>
<h2>七、儿童隐私保护</h2>
<p>本软件不面向 18 岁以下的未成年人提供服务。如果您是未成年人,请在监护人的指导下使用本软件。</p>
<h2>八、隐私政策的更新</h2>
<p>
我们可能会根据法律法规要求或产品功能的变化更新本隐私协议。更新后的协议将在软件中发布,并在生效前通知您。如果您不同意更新后的条款,您可以选择停止使用本软件。
</p>
<h2>九、联系我们</h2>
<p>如果您对本隐私协议有任何疑问、建议或投诉,请通过以下方式联系我们:</p>
<ul>
<li>
GitHub: <a href="https://github.com/CherryHQ/cherry-studio" target="_blank"
rel="noopener noreferrer">https://github.com/CherryHQ/cherry-studio</a>
</li>
<li>Email: support@cherry-ai.com</li>
</ul>
<div class="footer">
最后更新日期2024年12月
</div>
</div>
</body>
</html>

View File

@@ -123,6 +123,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
})
ipcMain.handle(IpcChannel.App_Reload, () => mainWindow.reload())
ipcMain.handle(IpcChannel.App_Quit, () => app.quit())
ipcMain.handle(IpcChannel.Open_Website, (_, url: string) => shell.openExternal(url))
// Update

View File

@@ -30,7 +30,8 @@ export default class AppUpdater {
autoUpdater.autoInstallOnAppQuit = configManager.getAutoUpdate()
autoUpdater.requestHeaders = {
...autoUpdater.requestHeaders,
'User-Agent': generateUserAgent()
'User-Agent': generateUserAgent(),
'X-Client-Id': configManager.getClientId()
}
autoUpdater.on('error', (error) => {

View File

@@ -332,14 +332,15 @@ class CodeToolsService {
// macOS - Use osascript to launch terminal and execute command directly, without showing startup command
const envPrefix = buildEnvPrefix(false)
const command = envPrefix ? `${envPrefix} && ${baseCommand}` : baseCommand
// Combine directory change with the main command to ensure they execute in the same shell session
const fullCommand = `cd '${directory.replace(/'/g, "\\'")}' && clear && ${command}`
terminalCommand = 'osascript'
terminalArgs = [
'-e',
`tell application "Terminal"
set newTab to do script "cd '${directory.replace(/'/g, "\\'")}' && clear"
do script "${fullCommand.replace(/"/g, '\\"')}"
activate
do script "${command.replace(/"/g, '\\"')}" in newTab
end tell`
]
break

View File

@@ -2,6 +2,7 @@ import { defaultLanguage, UpgradeChannel, ZOOM_SHORTCUTS } from '@shared/config/
import { LanguageVarious, Shortcut, ThemeMode } from '@types'
import { app } from 'electron'
import Store from 'electron-store'
import { v4 as uuidv4 } from 'uuid'
import { locales } from '../utils/locales'
@@ -27,7 +28,8 @@ export enum ConfigKeys {
SelectionAssistantFilterList = 'selectionAssistantFilterList',
DisableHardwareAcceleration = 'disableHardwareAcceleration',
Proxy = 'proxy',
EnableDeveloperMode = 'enableDeveloperMode'
EnableDeveloperMode = 'enableDeveloperMode',
ClientId = 'clientId'
}
export class ConfigManager {
@@ -241,6 +243,17 @@ export class ConfigManager {
this.set(ConfigKeys.EnableDeveloperMode, value)
}
getClientId(): string {
let clientId = this.get<string>(ConfigKeys.ClientId)
if (!clientId) {
clientId = uuidv4()
this.set(ConfigKeys.ClientId, clientId)
}
return clientId
}
set(key: string, value: unknown, isNotify: boolean = false) {
this.store.set(key, value)
isNotify && this.notifySubscribers(key, value)

View File

@@ -56,6 +56,45 @@ type CallToolArgs = { server: MCPServer; name: string; args: any; callId?: strin
const logger = loggerService.withContext('MCPService')
// Redact potentially sensitive fields in objects (headers, tokens, api keys)
function redactSensitive(input: any): any {
const SENSITIVE_KEYS = ['authorization', 'Authorization', 'apiKey', 'api_key', 'apikey', 'token', 'access_token']
const MAX_STRING = 300
const redact = (val: any): any => {
if (val == null) return val
if (typeof val === 'string') {
return val.length > MAX_STRING ? `${val.slice(0, MAX_STRING)}…<${val.length - MAX_STRING} more>` : val
}
if (Array.isArray(val)) return val.map((v) => redact(v))
if (typeof val === 'object') {
const out: Record<string, any> = {}
for (const [k, v] of Object.entries(val)) {
if (SENSITIVE_KEYS.includes(k)) {
out[k] = '<redacted>'
} else {
out[k] = redact(v)
}
}
return out
}
return val
}
return redact(input)
}
// Create a context-aware logger for a server
function getServerLogger(server: MCPServer, extra?: Record<string, any>) {
const base = {
serverName: server?.name,
serverId: server?.id,
baseUrl: server?.baseUrl,
type: server?.type || (server?.command ? 'stdio' : server?.baseUrl ? 'http' : 'inmemory')
}
return loggerService.withContext('MCPService', { ...base, ...(extra || {}) })
}
/**
* Higher-order function to add caching capability to any async function
* @param fn The original function to be wrapped with caching
@@ -74,15 +113,17 @@ function withCache<T extends unknown[], R>(
const cacheKey = getCacheKey(...args)
if (CacheService.has(cacheKey)) {
logger.debug(`${logPrefix} loaded from cache`)
logger.debug(`${logPrefix} loaded from cache`, { cacheKey })
const cachedData = CacheService.get<R>(cacheKey)
if (cachedData) {
return cachedData
}
}
const start = Date.now()
const result = await fn(...args)
CacheService.set(cacheKey, result, ttl)
logger.debug(`${logPrefix} cached`, { cacheKey, ttlMs: ttl, durationMs: Date.now() - start })
return result
}
}
@@ -128,6 +169,7 @@ class McpService {
// If there's a pending initialization, wait for it
const pendingClient = this.pendingClients.get(serverKey)
if (pendingClient) {
getServerLogger(server).silly(`Waiting for pending client initialization`)
return pendingClient
}
@@ -136,8 +178,11 @@ class McpService {
if (existingClient) {
try {
// Check if the existing client is still connected
const pingResult = await existingClient.ping()
logger.debug(`Ping result for ${server.name}:`, pingResult)
const pingResult = await existingClient.ping({
// add short timeout to prevent hanging
timeout: 1000
})
getServerLogger(server).debug(`Ping result`, { ok: !!pingResult })
// If the ping fails, remove the client from the cache
// and create a new one
if (!pingResult) {
@@ -146,7 +191,7 @@ class McpService {
return existingClient
}
} catch (error: any) {
logger.error(`Error pinging server ${server.name}:`, error?.message)
getServerLogger(server).error(`Error pinging server`, error as Error)
this.clients.delete(serverKey)
}
}
@@ -172,15 +217,15 @@ class McpService {
> => {
// Create appropriate transport based on configuration
if (isBuiltinMCPServer(server) && server.name !== BuiltinMCPServerNames.mcpAutoInstall) {
logger.debug(`Using in-memory transport for server: ${server.name}`)
getServerLogger(server).debug(`Using in-memory transport`)
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
// start the in-memory server with the given name and environment variables
const inMemoryServer = createInMemoryMCPServer(server.name, args, server.env || {})
try {
await inMemoryServer.connect(serverTransport)
logger.debug(`In-memory server started: ${server.name}`)
getServerLogger(server).debug(`In-memory server started`)
} catch (error: Error | any) {
logger.error(`Error starting in-memory server: ${error}`)
getServerLogger(server).error(`Error starting in-memory server`, error as Error)
throw new Error(`Failed to start in-memory server: ${error.message}`)
}
// set the client transport to the client
@@ -193,7 +238,10 @@ class McpService {
},
authProvider
}
logger.debug(`StreamableHTTPClientTransport options:`, options)
// redact headers before logging
getServerLogger(server).debug(`StreamableHTTPClientTransport options`, {
options: redactSensitive(options)
})
return new StreamableHTTPClientTransport(new URL(server.baseUrl!), options)
} else if (server.type === 'sse') {
const options: SSEClientTransportOptions = {
@@ -209,7 +257,7 @@ class McpService {
headers['Authorization'] = `Bearer ${tokens.access_token}`
}
} catch (error) {
logger.error('Failed to fetch tokens:', error as Error)
getServerLogger(server).error('Failed to fetch tokens:', error as Error)
}
}
@@ -239,15 +287,18 @@ class McpService {
...server.env,
...resolvedConfig.env
}
logger.debug(`Using resolved DXT config - command: ${cmd}, args: ${args?.join(' ')}`)
getServerLogger(server).debug(`Using resolved DXT config`, {
command: cmd,
args
})
} else {
logger.warn(`Failed to resolve DXT config for ${server.name}, falling back to manifest values`)
getServerLogger(server).warn(`Failed to resolve DXT config, falling back to manifest values`)
}
}
if (server.command === 'npx') {
cmd = await getBinaryPath('bun')
logger.debug(`Using command: ${cmd}`)
getServerLogger(server).debug(`Using command`, { command: cmd })
// add -x to args if args exist
if (args && args.length > 0) {
@@ -282,7 +333,7 @@ class McpService {
}
}
logger.debug(`Starting server with command: ${cmd} ${args ? args.join(' ') : ''}`)
getServerLogger(server).debug(`Starting server`, { command: cmd, args })
// Logger.info(`[MCP] Environment variables for server:`, server.env)
const loginShellEnv = await this.getLoginShellEnv()
@@ -304,12 +355,14 @@ class McpService {
// For DXT servers, set the working directory to the extracted path
if (server.dxtPath) {
transportOptions.cwd = server.dxtPath
logger.debug(`Setting working directory for DXT server: ${server.dxtPath}`)
getServerLogger(server).debug(`Setting working directory for DXT server`, {
cwd: server.dxtPath
})
}
const stdioTransport = new StdioClientTransport(transportOptions)
stdioTransport.stderr?.on('data', (data) =>
logger.debug(`Stdio stderr for server: ${server.name}` + data.toString())
getServerLogger(server).debug(`Stdio stderr`, { data: data.toString() })
)
return stdioTransport
} else {
@@ -318,7 +371,7 @@ class McpService {
}
const handleAuth = async (client: Client, transport: SSEClientTransport | StreamableHTTPClientTransport) => {
logger.debug(`Starting OAuth flow for server: ${server.name}`)
getServerLogger(server).debug(`Starting OAuth flow`)
// Create an event emitter for the OAuth callback
const events = new EventEmitter()
@@ -331,27 +384,27 @@ class McpService {
// Set a timeout to close the callback server
const timeoutId = setTimeout(() => {
logger.warn(`OAuth flow timed out for server: ${server.name}`)
getServerLogger(server).warn(`OAuth flow timed out`)
callbackServer.close()
}, 300000) // 5 minutes timeout
try {
// Wait for the authorization code
const authCode = await callbackServer.waitForAuthCode()
logger.debug(`Received auth code: ${authCode}`)
getServerLogger(server).debug(`Received auth code`)
// Complete the OAuth flow
await transport.finishAuth(authCode)
logger.debug(`OAuth flow completed for server: ${server.name}`)
getServerLogger(server).debug(`OAuth flow completed`)
const newTransport = await initTransport()
// Try to connect again
await client.connect(newTransport)
logger.debug(`Successfully authenticated with server: ${server.name}`)
getServerLogger(server).debug(`Successfully authenticated`)
} catch (oauthError) {
logger.error(`OAuth authentication failed for server ${server.name}:`, oauthError as Error)
getServerLogger(server).error(`OAuth authentication failed`, oauthError as Error)
throw new Error(
`OAuth authentication failed: ${oauthError instanceof Error ? oauthError.message : String(oauthError)}`
)
@@ -390,7 +443,7 @@ class McpService {
logger.debug(`Activated server: ${server.name}`)
return client
} catch (error: any) {
logger.error(`Error activating server ${server.name}:`, error?.message)
getServerLogger(server).error(`Error activating server`, error as Error)
throw new Error(`[MCP] Error activating server ${server.name}: ${error.message}`)
}
} finally {
@@ -450,9 +503,9 @@ class McpService {
logger.debug(`Message from server ${server.name}:`, notification.params)
})
logger.debug(`Set up notification handlers for server: ${server.name}`)
getServerLogger(server).debug(`Set up notification handlers`)
} catch (error) {
logger.error(`Failed to set up notification handlers for server ${server.name}:`, error as Error)
getServerLogger(server).error(`Failed to set up notification handlers`, error as Error)
}
}
@@ -470,7 +523,7 @@ class McpService {
CacheService.remove(`mcp:list_tool:${serverKey}`)
CacheService.remove(`mcp:list_prompts:${serverKey}`)
CacheService.remove(`mcp:list_resources:${serverKey}`)
logger.debug(`Cleared all caches for server: ${serverKey}`)
logger.debug(`Cleared all caches for server`, { serverKey })
}
async closeClient(serverKey: string) {
@@ -478,18 +531,18 @@ class McpService {
if (client) {
// Remove the client from the cache
await client.close()
logger.debug(`Closed server: ${serverKey}`)
logger.debug(`Closed server`, { serverKey })
this.clients.delete(serverKey)
// Clear all caches for this server
this.clearServerCache(serverKey)
} else {
logger.warn(`No client found for server: ${serverKey}`)
logger.warn(`No client found for server`, { serverKey })
}
}
async stopServer(_: Electron.IpcMainInvokeEvent, server: MCPServer) {
const serverKey = this.getServerKey(server)
logger.debug(`Stopping server: ${server.name}`)
getServerLogger(server).debug(`Stopping server`)
await this.closeClient(serverKey)
}
@@ -505,16 +558,16 @@ class McpService {
try {
const cleaned = this.dxtService.cleanupDxtServer(server.name)
if (cleaned) {
logger.debug(`Cleaned up DXT server directory for: ${server.name}`)
getServerLogger(server).debug(`Cleaned up DXT server directory`)
}
} catch (error) {
logger.error(`Failed to cleanup DXT server: ${server.name}`, error as Error)
getServerLogger(server).error(`Failed to cleanup DXT server`, error as Error)
}
}
}
async restartServer(_: Electron.IpcMainInvokeEvent, server: MCPServer) {
logger.debug(`Restarting server: ${server.name}`)
getServerLogger(server).debug(`Restarting server`)
const serverKey = this.getServerKey(server)
await this.closeClient(serverKey)
// Clear cache before restarting to ensure fresh data
@@ -527,7 +580,7 @@ class McpService {
try {
await this.closeClient(key)
} catch (error: any) {
logger.error(`Failed to close client: ${error?.message}`)
logger.error(`Failed to close client`, error as Error)
}
}
}
@@ -536,9 +589,9 @@ class McpService {
* Check connectivity for an MCP server
*/
public async checkMcpConnectivity(_: Electron.IpcMainInvokeEvent, server: MCPServer): Promise<boolean> {
logger.debug(`Checking connectivity for server: ${server.name}`)
getServerLogger(server).debug(`Checking connectivity`)
try {
logger.debug(`About to call initClient for server: ${server.name}`, { hasInitClient: !!this.initClient })
getServerLogger(server).debug(`About to call initClient`, { hasInitClient: !!this.initClient })
if (!this.initClient) {
throw new Error('initClient method is not available')
@@ -547,10 +600,10 @@ class McpService {
const client = await this.initClient(server)
// Attempt to list tools as a way to check connectivity
await client.listTools()
logger.debug(`Connectivity check successful for server: ${server.name}`)
getServerLogger(server).debug(`Connectivity check successful`)
return true
} catch (error) {
logger.error(`Connectivity check failed for server: ${server.name}`, error as Error)
getServerLogger(server).error(`Connectivity check failed`, error as Error)
// Close the client if connectivity check fails to ensure a clean state for the next attempt
const serverKey = this.getServerKey(server)
await this.closeClient(serverKey)
@@ -559,9 +612,8 @@ class McpService {
}
private async listToolsImpl(server: MCPServer): Promise<MCPTool[]> {
logger.debug(`Listing tools for server: ${server.name}`)
getServerLogger(server).debug(`Listing tools`)
const client = await this.initClient(server)
logger.debug(`Client for server: ${server.name}`, client)
try {
const { tools } = await client.listTools()
const serverTools: MCPTool[] = []
@@ -576,7 +628,7 @@ class McpService {
})
return serverTools
} catch (error: any) {
logger.error(`Failed to list tools for server: ${server.name}`, error?.message)
getServerLogger(server).error(`Failed to list tools`, error as Error)
return []
}
}
@@ -613,12 +665,16 @@ class McpService {
const callToolFunc = async ({ server, name, args }: CallToolArgs) => {
try {
logger.debug(`Calling: ${server.name} ${name} ${JSON.stringify(args)} callId: ${toolCallId}`, server)
getServerLogger(server, { tool: name, callId: toolCallId }).debug(`Calling tool`, {
args: redactSensitive(args)
})
if (typeof args === 'string') {
try {
args = JSON.parse(args)
} catch (e) {
logger.error('args parse error', args)
getServerLogger(server, { tool: name, callId: toolCallId }).error('args parse error', e as Error, {
args
})
}
if (args === '') {
args = {}
@@ -627,8 +683,9 @@ class McpService {
const client = await this.initClient(server)
const result = await client.callTool({ name, arguments: args }, undefined, {
onprogress: (process) => {
logger.debug(`Progress: ${process.progress / (process.total || 1)}`)
logger.debug(`Progress notification received for server: ${server.name}`, process)
getServerLogger(server, { tool: name, callId: toolCallId }).debug(`Progress`, {
ratio: process.progress / (process.total || 1)
})
const mainWindow = windowService.getMainWindow()
if (mainWindow) {
mainWindow.webContents.send('mcp-progress', process.progress / (process.total || 1))
@@ -643,7 +700,7 @@ class McpService {
})
return result as MCPCallToolResponse
} catch (error) {
logger.error(`Error calling tool ${name} on ${server.name}:`, error as Error)
getServerLogger(server, { tool: name, callId: toolCallId }).error(`Error calling tool`, error as Error)
throw error
} finally {
this.activeToolCalls.delete(toolCallId)
@@ -667,7 +724,7 @@ class McpService {
*/
private async listPromptsImpl(server: MCPServer): Promise<MCPPrompt[]> {
const client = await this.initClient(server)
logger.debug(`Listing prompts for server: ${server.name}`)
getServerLogger(server).debug(`Listing prompts`)
try {
const { prompts } = await client.listPrompts()
return prompts.map((prompt: any) => ({
@@ -679,7 +736,7 @@ class McpService {
} catch (error: any) {
// -32601 is the code for the method not found
if (error?.code !== -32601) {
logger.error(`Failed to list prompts for server: ${server.name}`, error?.message)
getServerLogger(server).error(`Failed to list prompts`, error as Error)
}
return []
}
@@ -748,7 +805,7 @@ class McpService {
} catch (error: any) {
// -32601 is the code for the method not found
if (error?.code !== -32601) {
logger.error(`Failed to list resources for server: ${server.name}`, error?.message)
getServerLogger(server).error(`Failed to list resources`, error as Error)
}
return []
}
@@ -774,7 +831,7 @@ class McpService {
* Get a specific resource from an MCP server (implementation)
*/
private async getResourceImpl(server: MCPServer, uri: string): Promise<GetResourceResponse> {
logger.debug(`Getting resource ${uri} from server: ${server.name}`)
getServerLogger(server, { uri }).debug(`Getting resource`)
const client = await this.initClient(server)
try {
const result = await client.readResource({ uri: uri })
@@ -792,7 +849,7 @@ class McpService {
contents: contents
}
} catch (error: Error | any) {
logger.error(`Failed to get resource ${uri} from server: ${server.name}`, error.message)
getServerLogger(server, { uri }).error(`Failed to get resource`, error as Error)
throw new Error(`Failed to get resource ${uri} from server: ${server.name}: ${error.message}`)
}
}
@@ -837,10 +894,10 @@ class McpService {
if (activeToolCall) {
activeToolCall.abort()
this.activeToolCalls.delete(callId)
logger.debug(`Aborted tool call: ${callId}`)
logger.debug(`Aborted tool call`, { callId })
return true
} else {
logger.warn(`No active tool call found for callId: ${callId}`)
logger.warn(`No active tool call found for callId`, { callId })
return false
}
}
@@ -850,22 +907,22 @@ class McpService {
*/
public async getServerVersion(_: Electron.IpcMainInvokeEvent, server: MCPServer): Promise<string | null> {
try {
logger.debug(`Getting server version for: ${server.name}`)
getServerLogger(server).debug(`Getting server version`)
const client = await this.initClient(server)
// Try to get server information which may include version
const serverInfo = client.getServerVersion()
logger.debug(`Server info for ${server.name}:`, serverInfo)
getServerLogger(server).debug(`Server info`, redactSensitive(serverInfo))
if (serverInfo && serverInfo.version) {
logger.debug(`Server version for ${server.name}: ${serverInfo.version}`)
getServerLogger(server).debug(`Server version`, { version: serverInfo.version })
return serverInfo.version
}
logger.warn(`No version information available for server: ${server.name}`)
getServerLogger(server).warn(`No version information available`)
return null
} catch (error: any) {
logger.error(`Failed to get server version for ${server.name}:`, error?.message)
getServerLogger(server).error(`Failed to get server version`, error as Error)
return null
}
}

View File

@@ -13,7 +13,7 @@ export function initSessionUserAgent() {
wvSession.webRequest.onBeforeSendHeaders((details, cb) => {
const headers = {
...details.requestHeaders,
'User-Agent': newUA
'User-Agent': details.url.includes('google.com') ? originUA : newUA
}
cb({ requestHeaders: headers })
})

View File

@@ -45,6 +45,7 @@ export function tracedInvoke(channel: string, spanContext: SpanContext | undefin
const api = {
getAppInfo: () => ipcRenderer.invoke(IpcChannel.App_Info),
reload: () => ipcRenderer.invoke(IpcChannel.App_Reload),
quit: () => ipcRenderer.invoke(IpcChannel.App_Quit),
setProxy: (proxy: string | undefined, bypassRules?: string) =>
ipcRenderer.invoke(IpcChannel.App_Proxy, proxy, bypassRules),
checkForUpdate: () => ipcRenderer.invoke(IpcChannel.App_CheckForUpdate),

View File

@@ -14,6 +14,7 @@ import FilesPage from './pages/files/FilesPage'
import HomePage from './pages/home/HomePage'
import KnowledgePage from './pages/knowledge/KnowledgePage'
import LaunchpadPage from './pages/launchpad/LaunchpadPage'
import MinAppPage from './pages/minapps/MinAppPage'
import MinAppsPage from './pages/minapps/MinAppsPage'
import NotesPage from './pages/notes/NotesPage'
import PaintingsRoutePage from './pages/paintings/PaintingsRoutePage'
@@ -34,6 +35,7 @@ const Router: FC = () => {
<Route path="/files" element={<FilesPage />} />
<Route path="/notes" element={<NotesPage />} />
<Route path="/knowledge" element={<KnowledgePage />} />
<Route path="/apps/:appId" element={<MinAppPage />} />
<Route path="/apps" element={<MinAppsPage />} />
<Route path="/code" element={<CodeToolsPage />} />
<Route path="/settings/*" element={<SettingsPage />} />

Binary file not shown.

Before

Width:  |  Height:  |  Size: 299 KiB

View File

@@ -165,9 +165,6 @@ ul {
}
.markdown {
display: flow-root;
*:last-child {
margin-bottom: 0;
}
}
}

View File

@@ -13,6 +13,7 @@ import { Dropdown } from 'antd'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { useDispatch } from 'react-redux'
import { useNavigate } from 'react-router-dom'
import styled from 'styled-components'
interface Props {
@@ -30,6 +31,7 @@ const MinApp: FC<Props> = ({ app, onClick, size = 60, isLast }) => {
const { minapps, pinned, disabled, updateMinapps, updateDisabledMinapps, updatePinnedMinapps } = useMinapps()
const { openedKeepAliveMinapps, currentMinappId, minappShow } = useRuntime()
const dispatch = useDispatch()
const navigate = useNavigate()
const isPinned = pinned.some((p) => p.id === app.id)
const isVisible = minapps.some((m) => m.id === app.id)
const isActive = minappShow && currentMinappId === app.id
@@ -37,7 +39,13 @@ const MinApp: FC<Props> = ({ app, onClick, size = 60, isLast }) => {
const { isTopNavbar } = useNavbarPosition()
const handleClick = () => {
if (isTopNavbar) {
// 顶部导航栏:导航到小程序页面
navigate(`/apps/${app.id}`)
} else {
// 侧边导航栏:保持原有弹窗行为
openMinappKeepAlive(app)
}
onClick?.()
}

View File

@@ -0,0 +1,143 @@
import { loggerService } from '@logger'
import WebviewContainer from '@renderer/components/MinApp/WebviewContainer'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useNavbarPosition } from '@renderer/hooks/useSettings'
import { getWebviewLoaded, setWebviewLoaded } from '@renderer/utils/webviewStateManager'
import { WebviewTag } from 'electron'
import React, { useEffect, useRef } from 'react'
import { useLocation } from 'react-router-dom'
import styled from 'styled-components'
/**
* Mini-app WebView pool for Tab 模式 (顶部导航).
*
* 与 Popup 模式相似,但独立存在:
* - 仅在 isTopNavbar=true 且访问 /apps 路由时显示
* - 保证已打开的 keep-alive 小程序对应的 <webview> 不被卸载,只通过 display 切换
* - LRU 淘汰通过 openedKeepAliveMinapps 变化自动移除 DOM
*
* 后续可演进:与 Popup 共享同一实例(方案 B
*/
const logger = loggerService.withContext('MinAppTabsPool')
const MinAppTabsPool: React.FC = () => {
const { openedKeepAliveMinapps, currentMinappId } = useRuntime()
const { isTopNavbar } = useNavbarPosition()
const location = useLocation()
// webview refs池内部自用用于控制显示/隐藏)
const webviewRefs = useRef<Map<string, WebviewTag | null>>(new Map())
// 使用集中工具进行更稳健的路由判断
const isAppDetail = (() => {
const pathname = location.pathname
if (pathname === '/apps') return false
if (!pathname.startsWith('/apps/')) return false
const parts = pathname.split('/').filter(Boolean) // ['apps', '<id>', ...]
return parts.length >= 2
})()
const shouldShow = isTopNavbar && isAppDetail
// 组合当前需要渲染的列表(保持顺序即可)
const apps = openedKeepAliveMinapps
/** 设置 ref 回调 */
const handleSetRef = (appid: string, el: WebviewTag | null) => {
if (el) {
webviewRefs.current.set(appid, el)
} else {
webviewRefs.current.delete(appid)
}
}
/** WebView 加载完成回调 */
const handleLoaded = (appid: string) => {
setWebviewLoaded(appid, true)
logger.debug(`TabPool webview loaded: ${appid}`)
}
/** 记录导航(暂未外曝 URL 状态,后续可接入全局 URL Map */
const handleNavigate = (appid: string, url: string) => {
logger.debug(`TabPool webview navigate: ${appid} -> ${url}`)
}
/** 切换显示状态:仅当前 active 的显示,其余隐藏 */
useEffect(() => {
webviewRefs.current.forEach((ref, id) => {
if (!ref) return
const active = id === currentMinappId && shouldShow
ref.style.display = active ? 'inline-flex' : 'none'
})
}, [currentMinappId, shouldShow, apps.length])
/** 当某个已在 Map 里但不再属于 openedKeepAlive 时移除引用React 自身会卸载元素) */
useEffect(() => {
const existing = Array.from(webviewRefs.current.keys())
existing.forEach((id) => {
if (!apps.find((a) => a.id === id)) {
webviewRefs.current.delete(id)
// loaded 状态也清理LRU 已在其它地方清除,双保险)
if (getWebviewLoaded(id)) {
setWebviewLoaded(id, false)
}
}
})
}, [apps])
// 不显示时直接 hidden避免闪烁仍然保留 DOM 做保活
const toolbarHeight = 35 // 与 MinimalToolbar 高度保持一致
return (
<PoolContainer
style={
shouldShow
? {
visibility: 'visible',
top: toolbarHeight,
height: `calc(100% - ${toolbarHeight}px)`
}
: { visibility: 'hidden' }
}
data-minapp-tabs-pool
aria-hidden={!shouldShow}>
{apps.map((app) => (
<WebviewWrapper key={app.id} $active={app.id === currentMinappId}>
<WebviewContainer
appid={app.id}
url={app.url}
onSetRefCallback={handleSetRef}
onLoadedCallback={handleLoaded}
onNavigateCallback={handleNavigate}
/>
</WebviewWrapper>
))}
</PoolContainer>
)
}
const PoolContainer = styled.div`
position: absolute;
left: 0;
right: 0;
bottom: 0;
/* top 在运行时通过 style 注入 (toolbarHeight) */
width: 100%;
overflow: hidden;
border-radius: 0 0 8px 8px;
z-index: 1;
pointer-events: none;
& webview {
pointer-events: auto;
}
`
const WebviewWrapper = styled.div<{ $active: boolean }>`
position: absolute;
inset: 0;
width: 100%;
height: 100%;
/* display 控制在内部 webview 元素上做,这里保持结构稳定 */
pointer-events: ${(props) => (props.$active ? 'auto' : 'none')};
`
export default MinAppTabsPool

View File

@@ -24,6 +24,7 @@ import { useAppDispatch } from '@renderer/store'
import { setMinappsOpenLinkExternal } from '@renderer/store/settings'
import { MinAppType } from '@renderer/types'
import { delay } from '@renderer/utils'
import { clearWebviewState, getWebviewLoaded, setWebviewLoaded } from '@renderer/utils/webviewStateManager'
import { Alert, Avatar, Button, Drawer, Tooltip } from 'antd'
import { WebviewTag } from 'electron'
import { useEffect, useMemo, useRef, useState } from 'react'
@@ -162,8 +163,7 @@ const MinappPopupContainer: React.FC = () => {
/** store the webview refs, one of the key to make them keepalive */
const webviewRefs = useRef<Map<string, WebviewTag | null>>(new Map())
/** indicate whether the webview has loaded */
const webviewLoadedRefs = useRef<Map<string, boolean>>(new Map())
/** Note: WebView loaded states now managed globally via webviewStateManager */
/** whether the minapps open link external is enabled */
const { minappsOpenLinkExternal } = useSettings()
@@ -185,7 +185,7 @@ const MinappPopupContainer: React.FC = () => {
setIsPopupShow(true)
if (webviewLoadedRefs.current.get(currentMinappId)) {
if (getWebviewLoaded(currentMinappId)) {
setIsReady(true)
/** the case that open the minapp from sidebar */
} else if (lastMinappId.current !== currentMinappId && lastMinappShow.current === minappShow) {
@@ -216,17 +216,21 @@ const MinappPopupContainer: React.FC = () => {
webviewRef.style.display = appid === currentMinappId ? 'inline-flex' : 'none'
})
//delete the extra webviewLoadedRefs
webviewLoadedRefs.current.forEach((_, appid) => {
if (!webviewRefs.current.has(appid)) {
webviewLoadedRefs.current.delete(appid)
} else if (appid === currentMinappId) {
const webviewId = webviewRefs.current.get(appid)?.getWebContentsId()
// Set external link behavior for current minapp
if (currentMinappId) {
const webviewElement = webviewRefs.current.get(currentMinappId)
if (webviewElement) {
try {
const webviewId = webviewElement.getWebContentsId()
if (webviewId) {
window.api.webview.setOpenLinkExternal(webviewId, minappsOpenLinkExternal)
}
} catch (error) {
// WebView not ready yet, will be set when it's loaded
logger.debug(`WebView ${currentMinappId} not ready for getWebContentsId()`)
}
}
}
})
}, [currentMinappId, minappsOpenLinkExternal])
/** only the keepalive minapp can be minimized */
@@ -255,15 +259,17 @@ const MinappPopupContainer: React.FC = () => {
/** get the current app info with extra info */
let currentAppInfo: AppInfo | null = null
if (currentMinappId) {
const currentApp = combinedApps.find((item) => item.id === currentMinappId) as MinAppType
const currentApp = combinedApps.find((item) => item.id === currentMinappId)
if (currentApp) {
currentAppInfo = { ...currentApp, ...appsExtraInfo[currentApp.id] }
}
}
/** will close the popup and delete the webview */
const handlePopupClose = async (appid: string) => {
setIsPopupShow(false)
await delay(0.3)
webviewLoadedRefs.current.delete(appid)
clearWebviewState(appid)
closeMinapp(appid)
}
@@ -292,11 +298,18 @@ const MinappPopupContainer: React.FC = () => {
/** the callback function to set the webviews loaded indicator */
const handleWebviewLoaded = (appid: string) => {
webviewLoadedRefs.current.set(appid, true)
const webviewId = webviewRefs.current.get(appid)?.getWebContentsId()
setWebviewLoaded(appid, true)
const webviewElement = webviewRefs.current.get(appid)
if (webviewElement) {
try {
const webviewId = webviewElement.getWebContentsId()
if (webviewId) {
window.api.webview.setOpenLinkExternal(webviewId, minappsOpenLinkExternal)
}
} catch (error) {
logger.debug(`WebView ${appid} not ready for getWebContentsId() in handleWebviewLoaded`)
}
}
if (appid == currentMinappId) {
setTimeoutTimer('handleWebviewLoaded', () => setIsReady(true), 200)
}
@@ -352,17 +365,29 @@ const MinappPopupContainer: React.FC = () => {
/** navigate back in webview history */
const handleGoBack = (appid: string) => {
const webview = webviewRefs.current.get(appid)
if (webview && webview.canGoBack()) {
if (webview) {
try {
if (webview.canGoBack()) {
webview.goBack()
}
} catch (error) {
logger.debug(`WebView ${appid} not ready for goBack()`)
}
}
}
/** navigate forward in webview history */
const handleGoForward = (appid: string) => {
const webview = webviewRefs.current.get(appid)
if (webview && webview.canGoForward()) {
if (webview) {
try {
if (webview.canGoForward()) {
webview.goForward()
}
} catch (error) {
logger.debug(`WebView ${appid} not ready for goForward()`)
}
}
}
/** Title bar of the popup */
@@ -409,7 +434,7 @@ const MinappPopupContainer: React.FC = () => {
</Tooltip>
)}
<Spacer />
<ButtonsGroup className={isWin || isLinux ? 'windows' : ''}>
<ButtonsGroup className={isWin || isLinux ? 'windows' : ''} isTopNavbar={isTopNavbar}>
<Tooltip title={t('minapp.popup.goBack')} mouseEnterDelay={0.8} placement="bottom">
<TitleButton onClick={() => handleGoBack(appInfo.id)}>
<ArrowLeftOutlined />
@@ -498,19 +523,25 @@ const MinappPopupContainer: React.FC = () => {
return (
<Drawer
title={<Title appInfo={currentAppInfo} url={currentUrl} />}
title={isTopNavbar ? null : <Title appInfo={currentAppInfo} url={currentUrl} />}
placement="bottom"
onClose={handlePopupMinimize}
open={isPopupShow}
mask={false}
rootClassName="minapp-drawer"
maskClassName="minapp-mask"
height={'100%'}
height={isTopNavbar ? 'calc(100% - var(--navbar-height))' : '100%'}
maskClosable={false}
closeIcon={null}
style={{
styles={{
wrapper: {
position: 'fixed',
marginLeft: isLeftNavbar ? 'var(--sidebar-width)' : 0,
marginTop: isTopNavbar ? 'var(--navbar-height)' : 0
},
content: {
backgroundColor: window.root.style.background
}
}}>
{/* 在所有小程序中显示GoogleLoginTip */}
<GoogleLoginTip isReady={isReady} currentUrl={currentUrl} currentAppId={currentMinappId} />
@@ -566,7 +597,7 @@ const TitleTextTooltip = styled.span`
}
`
const ButtonsGroup = styled.div`
const ButtonsGroup = styled.div<{ isTopNavbar: boolean }>`
display: flex;
flex-direction: row;
align-items: center;

View File

@@ -1,11 +1,14 @@
import MinappPopupContainer from '@renderer/components/MinApp/MinappPopupContainer'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useNavbarPosition } from '@renderer/hooks/useSettings'
const TopViewMinappContainer = () => {
const { openedKeepAliveMinapps, openedOneOffMinapp } = useRuntime()
const { isLeftNavbar } = useNavbarPosition()
const isCreate = openedKeepAliveMinapps.length > 0 || openedOneOffMinapp !== null
return <>{isCreate && <MinappPopupContainer />}</>
// Only show popup container in sidebar mode (left navbar), not in tab mode (top navbar)
return <>{isCreate && isLeftNavbar && <MinappPopupContainer />}</>
}
export default TopViewMinappContainer

View File

@@ -1,7 +1,10 @@
import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings'
import { loggerService } from '@logger'
import { useSettings } from '@renderer/hooks/useSettings'
import { WebviewTag } from 'electron'
import { memo, useEffect, useRef } from 'react'
const logger = loggerService.withContext('WebviewContainer')
/**
* WebviewContainer is a component that renders a webview element.
* It is used in the MinAppPopupContainer component.
@@ -23,7 +26,6 @@ const WebviewContainer = memo(
}) => {
const webviewRef = useRef<WebviewTag | null>(null)
const { enableSpellCheck } = useSettings()
const { isLeftNavbar } = useNavbarPosition()
const setRef = (appid: string) => {
onSetRefCallback(appid, null)
@@ -41,8 +43,29 @@ const WebviewContainer = memo(
useEffect(() => {
if (!webviewRef.current) return
let loadCallbackFired = false
const handleLoaded = () => {
logger.debug(`WebView did-finish-load for app: ${appid}`)
// Only fire callback once per load cycle
if (!loadCallbackFired) {
loadCallbackFired = true
// Small delay to ensure content is actually visible
setTimeout(() => {
logger.debug(`Calling onLoadedCallback for app: ${appid}`)
onLoadedCallback(appid)
}, 100)
}
}
// Additional callback for when page is ready to show
const handleReadyToShow = () => {
logger.debug(`WebView ready-to-show for app: ${appid}`)
if (!loadCallbackFired) {
loadCallbackFired = true
logger.debug(`Calling onLoadedCallback from ready-to-show for app: ${appid}`)
onLoadedCallback(appid)
}
}
const handleNavigate = (event: any) => {
@@ -56,16 +79,25 @@ const WebviewContainer = memo(
}
}
const handleStartLoading = () => {
// Reset callback flag when starting a new load
loadCallbackFired = false
}
webviewRef.current.addEventListener('did-start-loading', handleStartLoading)
webviewRef.current.addEventListener('dom-ready', handleDomReady)
webviewRef.current.addEventListener('did-finish-load', handleLoaded)
webviewRef.current.addEventListener('ready-to-show', handleReadyToShow)
webviewRef.current.addEventListener('did-navigate-in-page', handleNavigate)
// we set the url when the webview is ready
webviewRef.current.src = url
return () => {
webviewRef.current?.removeEventListener('did-start-loading', handleStartLoading)
webviewRef.current?.removeEventListener('dom-ready', handleDomReady)
webviewRef.current?.removeEventListener('did-finish-load', handleLoaded)
webviewRef.current?.removeEventListener('ready-to-show', handleReadyToShow)
webviewRef.current?.removeEventListener('did-navigate-in-page', handleNavigate)
}
// because the appid and url are enough, no need to add onLoadedCallback
@@ -73,8 +105,8 @@ const WebviewContainer = memo(
}, [appid, url])
const WebviewStyle: React.CSSProperties = {
width: isLeftNavbar ? 'calc(100vw - var(--sidebar-width))' : '100vw',
height: 'calc(100vh - var(--navbar-height))',
width: '100%',
height: '100%',
backgroundColor: 'var(--color-background)',
display: 'inline-flex'
}
@@ -83,6 +115,7 @@ const WebviewContainer = memo(
<webview
key={appid}
ref={setRef(appid)}
data-minapp-id={appid}
style={WebviewStyle}
allowpopups={'true' as any}
partition="persist:webview"

View File

@@ -1,8 +1,7 @@
import CherryLogo from '@renderer/assets/images/banner.png'
import Favicon from '@renderer/components/Icons/FallbackFavicon'
import { useMetaDataParser } from '@renderer/hooks/useMetaDataParser'
import { Skeleton, Typography } from 'antd'
import { useEffect, useMemo } from 'react'
import { useCallback, useEffect, useMemo } from 'react'
import styled from 'styled-components'
const { Title, Paragraph } = Typography
@@ -11,6 +10,8 @@ type Props = {
show: boolean
}
const IMAGE_HEIGHT = '9rem' // equals h-36
export const OGCard = ({ link, show }: Props) => {
const openGraph = ['og:title', 'og:description', 'og:image', 'og:imageAlt'] as const
const { metadata, isLoading, parseMetadata } = useMetaDataParser(link, openGraph)
@@ -32,6 +33,14 @@ export const OGCard = ({ link, show }: Props) => {
}
}, [parseMetadata, isLoading, show])
const GeneratedGraph = useCallback(() => {
return (
<div className="flex h-36 items-center justify-center bg-accent p-4">
<h2 className="text-2xl font-bold">{metadata['og:title'] || hostname}</h2>
</div>
)
}, [hostname, metadata])
if (isLoading) {
return <CardSkeleton />
}
@@ -45,7 +54,7 @@ export const OGCard = ({ link, show }: Props) => {
)}
{!hasImage && (
<PreviewImageContainer>
<PreviewImage src={CherryLogo} alt={'no image'} />
<GeneratedGraph />
</PreviewImageContainer>
)}
@@ -113,8 +122,8 @@ const PreviewContainer = styled.div<{ hasImage?: boolean }>`
const PreviewImageContainer = styled.div`
width: 100%;
height: 140px;
min-height: 140px;
height: ${IMAGE_HEIGHT};
min-height: ${IMAGE_HEIGHT};
overflow: hidden;
`
@@ -128,7 +137,7 @@ const PreviewContent = styled.div`
const PreviewImage = styled.img`
width: 100%;
height: 140px;
height: ${IMAGE_HEIGHT};
object-fit: cover;
`

View File

@@ -0,0 +1,137 @@
import { TopView } from '@renderer/components/TopView'
import { useTheme } from '@renderer/context/ThemeProvider'
import { ThemeMode } from '@renderer/types'
import { runAsyncFunction } from '@renderer/utils'
import { Button, Modal } from 'antd'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
const WebViewContainer = styled.div`
width: 100%;
height: 500px;
overflow: hidden;
webview {
width: 100%;
height: 100%;
border: none;
background: transparent;
}
`
interface ShowParams {
title?: string
showDeclineButton?: boolean
}
interface Props extends ShowParams {
resolve: (data: any) => void
}
const PopupContainer: React.FC<Props> = ({ title, showDeclineButton = true, resolve }) => {
const [open, setOpen] = useState(true)
const [privacyUrl, setPrivacyUrl] = useState<string>('')
const { theme } = useTheme()
const { i18n } = useTranslation()
const getTitle = () => {
if (title) return title
const isChinese = i18n.language.startsWith('zh')
return isChinese ? '隐私协议' : 'Privacy Policy'
}
const handleAccept = () => {
setOpen(false)
localStorage.setItem('privacy-popup-accepted', 'true')
resolve({ accepted: true })
}
const handleDecline = () => {
setOpen(false)
window.api.quit()
resolve({ accepted: false })
}
const onClose = () => {
if (!showDeclineButton) {
handleAccept()
} else {
handleDecline()
}
}
useEffect(() => {
runAsyncFunction(async () => {
const { appPath } = await window.api.getAppInfo()
const isChinese = i18n.language.startsWith('zh')
const htmlFile = isChinese ? 'privacy-zh.html' : 'privacy-en.html'
const url = `file://${appPath}/resources/cherry-studio/${htmlFile}?theme=${theme === ThemeMode.dark ? 'dark' : 'light'}`
setPrivacyUrl(url)
})
}, [theme, i18n.language])
PrivacyPopup.hide = () => setOpen(false)
return (
<Modal
title={getTitle()}
open={open}
onCancel={showDeclineButton ? handleDecline : undefined}
afterClose={onClose}
transitionName=""
maskTransitionName=""
centered
closable={false}
maskClosable={false}
styles={{
mask: { backgroundColor: 'var(--color-background)' },
header: { paddingLeft: 20 },
body: { paddingLeft: 20 }
}}
width={900}
footer={[
showDeclineButton && (
<Button key="decline" onClick={handleDecline}>
{i18n.language.startsWith('zh') ? '拒绝' : 'Decline'}
</Button>
),
<Button key="accept" type="primary" onClick={handleAccept}>
{i18n.language.startsWith('zh') ? '同意并继续' : 'Accept and Continue'}
</Button>
].filter(Boolean)}>
<WebViewContainer>
{privacyUrl && <webview src={privacyUrl} style={{ width: '100%', height: '100%' }} />}
</WebViewContainer>
</Modal>
)
}
const TopViewKey = 'PrivacyPopup'
export default class PrivacyPopup {
static topviewId = 0
static hide() {
TopView.hide(TopViewKey)
}
static async show(props?: ShowParams) {
const accepted = localStorage.getItem('privacy-popup-accepted')
if (accepted) {
return
}
return new Promise<{ accepted: boolean }>((resolve) => {
TopView.show(
<PopupContainer
{...(props || {})}
resolve={(v) => {
resolve(v)
TopView.hide(TopViewKey)
}}
/>,
TopViewKey
)
})
}
}

View File

@@ -0,0 +1,144 @@
import { PoeLogo } from '@renderer/components/Icons'
import { getProviderLogo } from '@renderer/config/providers'
import { Provider } from '@renderer/types'
import { generateColorFromChar, getFirstCharacter, getForegroundColor } from '@renderer/utils'
import { Avatar } from 'antd'
import React from 'react'
import styled from 'styled-components'
interface ProviderAvatarPrimitiveProps {
providerId: string
providerName: string
logoSrc?: string
size?: number
className?: string
style?: React.CSSProperties
}
interface ProviderAvatarProps {
provider: Provider
customLogos?: Record<string, string>
size?: number
className?: string
style?: React.CSSProperties
}
const ProviderSvgLogo = styled.div`
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
border: 0.5px solid var(--color-border);
border-radius: 100%;
& > svg {
width: 80%;
height: 80%;
}
`
const ProviderLogo = styled(Avatar)`
width: 100%;
height: 100%;
border: 0.5px solid var(--color-border);
`
export const ProviderAvatarPrimitive: React.FC<ProviderAvatarPrimitiveProps> = ({
providerId,
providerName,
logoSrc,
size,
className,
style
}) => {
if (providerId === 'poe') {
return (
<ProviderSvgLogo className={className} style={style}>
<PoeLogo fontSize={size} />
</ProviderSvgLogo>
)
}
if (logoSrc) {
return (
<ProviderLogo draggable="false" shape="circle" src={logoSrc} className={className} style={style} size={size} />
)
}
const backgroundColor = generateColorFromChar(providerName)
const color = providerName ? getForegroundColor(backgroundColor) : 'white'
return (
<ProviderLogo
size={size}
shape="circle"
className={className}
style={{
backgroundColor,
color,
...style
}}>
{getFirstCharacter(providerName)}
</ProviderLogo>
)
}
export const ProviderAvatar: React.FC<ProviderAvatarProps> = ({
provider,
customLogos = {},
className,
style,
size
}) => {
const systemLogoSrc = getProviderLogo(provider.id)
if (systemLogoSrc) {
return (
<ProviderAvatarPrimitive
size={size}
providerId={provider.id}
providerName={provider.name}
logoSrc={systemLogoSrc}
className={className}
style={style}
/>
)
}
const customLogo = customLogos[provider.id]
if (customLogo) {
if (customLogo === 'poe') {
return (
<ProviderAvatarPrimitive
size={size}
providerId="poe"
providerName={provider.name}
className={className}
style={style}
/>
)
}
return (
<ProviderAvatarPrimitive
providerId={provider.id}
providerName={provider.name}
logoSrc={customLogo}
size={size}
className={className}
style={style}
/>
)
}
return (
<ProviderAvatarPrimitive
providerId={provider.id}
providerName={provider.name}
size={size}
className={className}
style={style}
/>
)
}

View File

@@ -1,4 +1,5 @@
import { SearchOutlined } from '@ant-design/icons'
import { ProviderAvatarPrimitive } from '@renderer/components/ProviderAvatar'
import { PROVIDER_LOGO_MAP } from '@renderer/config/providers'
import { getProviderLabel } from '@renderer/i18n/label'
import { Input, Tooltip } from 'antd'
@@ -48,10 +49,10 @@ const ProviderLogoPicker: FC<Props> = ({ onProviderClick }) => {
/>
</SearchContainer>
<LogoGrid>
{filteredProviders.map(({ id, logo, name }) => (
{filteredProviders.map(({ id, name, logo }) => (
<Tooltip key={id} title={name} placement="top" mouseLeaveDelay={0}>
<LogoItem onClick={(e) => handleProviderClick(e, id)}>
<img src={logo} alt={name} draggable={false} />
<ProviderAvatarPrimitive providerId={id} size={52} providerName={name} logoSrc={logo} />
</LogoItem>
</Tooltip>
))}
@@ -86,11 +87,12 @@ const LogoGrid = styled.div`
const LogoItem = styled.div`
width: 52px;
height: 52px;
border-radius: 100%;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 8px;
transition: all 0.2s ease;
background: var(--color-background-soft);
border: 0.5px solid var(--color-border);
@@ -102,8 +104,8 @@ const LogoItem = styled.div`
}
img {
width: 32px;
height: 32px;
width: 100%;
height: 100%;
object-fit: contain;
user-select: none;
-webkit-user-drag: none;

View File

@@ -1,11 +1,12 @@
import { PlusOutlined } from '@ant-design/icons'
import { TopNavbarOpenedMinappTabs } from '@renderer/components/app/PinnedMinapps'
import { Sortable, useDndReorder } from '@renderer/components/dnd'
import Scrollbar from '@renderer/components/Scrollbar'
import { isLinux, isMac, isWin } from '@renderer/config/constant'
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useFullscreen } from '@renderer/hooks/useFullscreen'
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
import { useMinapps } from '@renderer/hooks/useMinapps'
import { getThemeModeLabel, getTitleLabel } from '@renderer/i18n/label'
import tabsService from '@renderer/services/TabsService'
import { useAppDispatch, useAppSelector } from '@renderer/store'
@@ -37,11 +38,23 @@ import { useTranslation } from 'react-i18next'
import { useLocation, useNavigate } from 'react-router-dom'
import styled from 'styled-components'
import MinAppIcon from '../Icons/MinAppIcon'
import MinAppTabsPool from '../MinApp/MinAppTabsPool'
interface TabsContainerProps {
children: React.ReactNode
}
const getTabIcon = (tabId: string): React.ReactNode | undefined => {
const getTabIcon = (tabId: string, minapps: any[]): React.ReactNode | undefined => {
// Check if it's a minapp tab (format: apps:appId)
if (tabId.startsWith('apps:')) {
const appId = tabId.replace('apps:', '')
const app = [...DEFAULT_MIN_APPS, ...minapps].find((app) => app.id === appId)
if (app) {
return <MinAppIcon size={14} app={app} />
}
}
switch (tabId) {
case 'home':
return <Home size={14} />
@@ -82,6 +95,7 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
const isFullscreen = useFullscreen()
const { settedTheme, toggleTheme } = useTheme()
const { hideMinappPopup } = useMinappPopup()
const { minapps } = useMinapps()
const { t } = useTranslation()
const scrollRef = useRef<HTMLDivElement>(null)
const [canScroll, setCanScroll] = useState(false)
@@ -89,9 +103,23 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
const getTabId = (path: string): string => {
if (path === '/') return 'home'
const segments = path.split('/')
// Handle minapp paths: /apps/appId -> apps:appId
if (segments[1] === 'apps' && segments[2]) {
return `apps:${segments[2]}`
}
return segments[1] // 获取第一个路径段作为 id
}
const getTabTitle = (tabId: string): string => {
// Check if it's a minapp tab
if (tabId.startsWith('apps:')) {
const appId = tabId.replace('apps:', '')
const app = [...DEFAULT_MIN_APPS, ...minapps].find((app) => app.id === appId)
return app ? app.name : 'MinApp'
}
return getTitleLabel(tabId)
}
const shouldCreateTab = (path: string) => {
if (path === '/') return false
if (path === '/settings') return false
@@ -196,8 +224,8 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
renderItem={(tab) => (
<Tab key={tab.id} active={tab.id === activeTabId} onClick={() => handleTabClick(tab)}>
<TabHeader>
{tab.id && <TabIcon>{getTabIcon(tab.id)}</TabIcon>}
<TabTitle>{getTitleLabel(tab.id)}</TabTitle>
{tab.id && <TabIcon>{getTabIcon(tab.id, minapps)}</TabIcon>}
<TabTitle>{getTabTitle(tab.id)}</TabTitle>
</TabHeader>
{tab.id !== 'home' && (
<CloseButton
@@ -224,7 +252,6 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
</AddTabButton>
</TabsArea>
<RightButtonsContainer>
<TopNavbarOpenedMinappTabs />
<Tooltip
title={t('settings.theme.title') + ': ' + getThemeModeLabel(settedTheme)}
mouseEnterDelay={0.8}
@@ -244,7 +271,11 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
</SettingsButton>
</RightButtonsContainer>
</TabsBar>
<TabContent>{children}</TabContent>
<TabContent>
{/* MiniApp WebView 池Tab 模式保活) */}
<MinAppTabsPool />
{children}
</TabContent>
</Container>
)
}
@@ -443,6 +474,7 @@ const TabContent = styled.div`
margin-top: 0;
border-radius: 8px;
overflow: hidden;
position: relative; /* 约束 MinAppTabsPool 绝对定位范围 */
`
export default TabsContainer

View File

@@ -6,107 +6,13 @@ import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings'
import { MinAppType } from '@renderer/types'
import type { MenuProps } from 'antd'
import { Dropdown, Tooltip } from 'antd'
import { FC, useEffect, useState } from 'react'
import { FC, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { DraggableList } from '../DraggableList'
import MinAppIcon from '../Icons/MinAppIcon'
/** Tabs of opened minapps in top navbar */
export const TopNavbarOpenedMinappTabs: FC = () => {
const { minappShow, openedKeepAliveMinapps, currentMinappId } = useRuntime()
const { openMinappKeepAlive, hideMinappPopup, closeMinapp, closeAllMinapps } = useMinappPopup()
const { showOpenedMinappsInSidebar } = useSettings()
const { theme } = useTheme()
const { t } = useTranslation()
const [keepAliveMinapps, setKeepAliveMinapps] = useState(openedKeepAliveMinapps)
useEffect(() => {
const timer = setTimeout(() => setKeepAliveMinapps(openedKeepAliveMinapps), 300)
return () => clearTimeout(timer)
}, [openedKeepAliveMinapps])
// animation for minapp switch indicator
useEffect(() => {
const iconDefaultWidth = 30 // 22px icon + 8px gap
const iconDefaultOffset = 10 // initial offset
const container = document.querySelector('.TopNavContainer') as HTMLElement
const activeIcon = document.querySelector('.TopNavContainer .opened-active') as HTMLElement
let indicatorLeft = 0,
indicatorBottom = 0
if (minappShow && activeIcon && container) {
indicatorLeft = activeIcon.offsetLeft + activeIcon.offsetWidth / 2 - 4 // 4 is half of the indicator's width (8px)
indicatorBottom = 0
} else {
indicatorLeft =
((keepAliveMinapps.length > 0 ? keepAliveMinapps.length : 1) / 2) * iconDefaultWidth + iconDefaultOffset - 4
indicatorBottom = -50
}
container?.style.setProperty('--indicator-left', `${indicatorLeft}px`)
container?.style.setProperty('--indicator-bottom', `${indicatorBottom}px`)
}, [currentMinappId, keepAliveMinapps, minappShow])
const handleOnClick = (app: MinAppType) => {
if (minappShow && currentMinappId === app.id) {
hideMinappPopup()
} else {
openMinappKeepAlive(app)
}
}
// 检查是否需要显示已打开小程序组件
const isShowOpened = showOpenedMinappsInSidebar && keepAliveMinapps.length > 0
// 如果不需要显示,返回空容器
if (!isShowOpened) return null
return (
<TopNavContainer
className="TopNavContainer"
style={{ backgroundColor: keepAliveMinapps.length > 0 ? 'var(--color-list-item)' : 'transparent' }}>
<TopNavMenus>
{keepAliveMinapps.map((app) => {
const menuItems: MenuProps['items'] = [
{
key: 'closeApp',
label: t('minapp.sidebar.close.title'),
onClick: () => {
closeMinapp(app.id)
}
},
{
key: 'closeAllApp',
label: t('minapp.sidebar.closeall.title'),
onClick: () => {
closeAllMinapps()
}
}
]
const isActive = minappShow && currentMinappId === app.id
return (
<Tooltip key={app.id} title={app.name} mouseEnterDelay={0.8} placement="bottom">
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']} overlayStyle={{ zIndex: 10000 }}>
<TopNavItemContainer
onClick={() => handleOnClick(app)}
theme={theme}
className={`${isActive ? 'opened-active' : ''}`}>
<TopNavIcon theme={theme}>
<MinAppIcon size={22} app={app} style={{ border: 'none', padding: 0 }} />
</TopNavIcon>
</TopNavItemContainer>
</Dropdown>
</Tooltip>
)
})}
</TopNavMenus>
</TopNavContainer>
)
}
/** Tabs of opened minapps in sidebar */
export const SidebarOpenedMinappTabs: FC = () => {
const { minappShow, openedKeepAliveMinapps, currentMinappId } = useRuntime()
@@ -116,7 +22,7 @@ export const SidebarOpenedMinappTabs: FC = () => {
const { t } = useTranslation()
const { isLeftNavbar } = useNavbarPosition()
const handleOnClick = (app) => {
const handleOnClick = (app: MinAppType) => {
if (minappShow && currentMinappId === app.id) {
hideMinappPopup()
} else {
@@ -329,50 +235,3 @@ const TabsWrapper = styled.div`
border-radius: 20px;
overflow: hidden;
`
const TopNavContainer = styled.div`
display: flex;
align-items: center;
padding: 2px;
gap: 4px;
background-color: var(--color-list-item);
border-radius: 20px;
margin: 0 5px;
position: relative;
overflow: hidden;
&::after {
content: '';
position: absolute;
left: var(--indicator-left, 0);
bottom: var(--indicator-bottom, 0);
width: 8px;
height: 4px;
background-color: var(--color-primary);
transition:
left 0.3s cubic-bezier(0.4, 0, 0.2, 1),
bottom 0.3s ease-in-out;
border-radius: 2px;
}
`
const TopNavMenus = styled.div`
display: flex;
align-items: center;
gap: 8px;
height: 100%;
`
const TopNavIcon = styled(Icon)`
width: 22px;
height: 22px;
`
const TopNavItemContainer = styled.div`
display: flex;
transition: border 0.2s ease;
border-radius: 18px;
cursor: pointer;
border-radius: 50%;
padding: 2px;
`

View File

@@ -661,7 +661,7 @@ export const PROVIDER_LOGO_MAP: AtLeast<SystemProviderId, string> = {
vertexai: VertexAIProviderLogo,
'new-api': NewAPIProviderLogo,
'aws-bedrock': AwsProviderLogo,
poe: 'svg' // use svg icon component
poe: 'poe' // use svg icon component
} as const
export function getProviderLogo(providerId: string) {

View File

@@ -20,7 +20,7 @@ import { useEffect } from 'react'
import { useDefaultModel } from './useAssistant'
import useFullScreenNotice from './useFullScreenNotice'
import { useRuntime } from './useRuntime'
import { useSettings } from './useSettings'
import { useNavbarPosition, useSettings } from './useSettings'
import useUpdateHandler from './useUpdateHandler'
const logger = loggerService.withContext('useAppInit')
@@ -37,6 +37,7 @@ export function useAppInit() {
customCss,
enableDataCollection
} = useSettings()
const { isLeftNavbar } = useNavbarPosition()
const { minappShow } = useRuntime()
const { setDefaultModel, setQuickModel, setTranslateModel } = useDefaultModel()
const avatar = useLiveQuery(() => db.settings.get('image://avatar'))
@@ -100,16 +101,15 @@ export function useAppInit() {
}, [language])
useEffect(() => {
const transparentWindow = windowStyle === 'transparent' && isMac && !minappShow
const isMacTransparentWindow = windowStyle === 'transparent' && isMac
if (minappShow) {
window.root.style.background =
windowStyle === 'transparent' && isMac ? 'var(--color-background)' : 'var(--navbar-background)'
if (minappShow && isLeftNavbar) {
window.root.style.background = isMacTransparentWindow ? 'var(--color-background)' : 'var(--navbar-background)'
return
}
window.root.style.background = transparentWindow ? 'var(--navbar-background-mac)' : 'var(--navbar-background)'
}, [windowStyle, minappShow, theme])
window.root.style.background = isMacTransparentWindow ? 'var(--navbar-background-mac)' : 'var(--navbar-background)'
}, [windowStyle, minappShow, theme, isLeftNavbar])
useEffect(() => {
if (isLocalAi) {

View File

@@ -1,6 +1,7 @@
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings' // 使用设置中的值
import TabsService from '@renderer/services/TabsService'
import { useAppDispatch } from '@renderer/store'
import {
setCurrentMinappId,
@@ -9,6 +10,7 @@ import {
setOpenedOneOffMinapp
} from '@renderer/store/runtime'
import { MinAppType } from '@renderer/types'
import { clearWebviewState } from '@renderer/utils/webviewStateManager'
import { LRUCache } from 'lru-cache'
import { useCallback } from 'react'
@@ -36,7 +38,18 @@ export const useMinappPopup = () => {
const createLRUCache = useCallback(() => {
return new LRUCache<string, MinAppType>({
max: maxKeepAliveMinapps,
disposeAfter: () => {
disposeAfter: (_value, key) => {
// Clean up WebView state when app is disposed from cache
clearWebviewState(key)
// Close corresponding tab if it exists
const tabs = TabsService.getTabs()
const tabToClose = tabs.find((tab) => tab.path === `/apps/${key}`)
if (tabToClose) {
TabsService.closeTab(tabToClose.id)
}
// Update Redux state
dispatch(setOpenedKeepAliveMinapps(Array.from(minAppsCache.values())))
},
onInsert: () => {
@@ -158,6 +171,8 @@ export const useMinappPopup = () => {
openMinappById,
closeMinapp,
hideMinappPopup,
closeAllMinapps
closeAllMinapps,
// Expose cache instance for TabsService integration
minAppsCache
}
}

View File

@@ -89,6 +89,7 @@ const Alert = styled(AntdAlert)`
margin: 0.5rem 0 !important;
padding: 10px;
font-size: 12px;
align-items: center;
& .ant-alert-close-icon {
margin: 5px;
}

View File

@@ -73,7 +73,7 @@ const Container = styled.div<{ $isDark: boolean }>`
border-radius: 10px;
cursor: pointer;
border: 0.5px solid var(--color-border);
margin: 15px 24px;
margin: 15px 20px;
margin-bottom: 0;
`

View File

@@ -2,7 +2,6 @@ import App from '@renderer/components/MinApp/MinApp'
import { useMinapps } from '@renderer/hooks/useMinapps'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import tabsService from '@renderer/services/TabsService'
import { Code, FileSearch, Folder, Languages, LayoutGrid, NotepadText, Palette, Sparkle } from 'lucide-react'
import { FC, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
@@ -105,7 +104,7 @@ const LaunchpadPage: FC = () => {
<Grid>
{sortedMinapps.map((app) => (
<AppWrapper key={app.id}>
<App app={app} size={56} onClick={() => setTimeout(() => tabsService.closeTab('launchpad'), 350)} />
<App app={app} size={56} />
</AppWrapper>
))}
</Grid>

View File

@@ -0,0 +1,216 @@
import { loggerService } from '@logger'
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
import { useMinapps } from '@renderer/hooks/useMinapps'
import { useNavbarPosition } from '@renderer/hooks/useSettings'
import TabsService from '@renderer/services/TabsService'
import { getWebviewLoaded, onWebviewStateChange, setWebviewLoaded } from '@renderer/utils/webviewStateManager'
import { Avatar } from 'antd'
import { WebviewTag } from 'electron'
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import BeatLoader from 'react-spinners/BeatLoader'
import styled from 'styled-components'
// Tab 模式下新的页面壳,不再直接创建 WebView而是依赖全局 MinAppTabsPool
import MinimalToolbar from './components/MinimalToolbar'
const logger = loggerService.withContext('MinAppPage')
const MinAppPage: FC = () => {
const { appId } = useParams<{ appId: string }>()
const { isTopNavbar } = useNavbarPosition()
const { openMinappKeepAlive, minAppsCache } = useMinappPopup()
const { minapps } = useMinapps()
// openedKeepAliveMinapps 不再需要作为依赖参与 webview 选择,已通过 MutationObserver 动态发现
// const { openedKeepAliveMinapps } = useRuntime()
const navigate = useNavigate()
// Remember the initial navbar position when component mounts
const initialIsTopNavbar = useRef<boolean>(isTopNavbar)
const hasRedirected = useRef<boolean>(false)
// Initialize TabsService with cache reference
useEffect(() => {
if (minAppsCache) {
TabsService.setMinAppsCache(minAppsCache)
}
}, [minAppsCache])
// Debug: track navbar position changes
useEffect(() => {
if (initialIsTopNavbar.current !== isTopNavbar) {
logger.debug(`NavBar position changed from ${initialIsTopNavbar.current} to ${isTopNavbar}`)
}
}, [isTopNavbar])
// Find the app from all available apps
const app = useMemo(() => {
if (!appId) return null
return [...DEFAULT_MIN_APPS, ...minapps].find((app) => app.id === appId)
}, [appId, minapps])
useEffect(() => {
// If app not found, redirect to apps list
if (!app) {
navigate('/apps')
return
}
// For sidebar navigation, redirect to apps list and open popup
// Only check once and only if we haven't already redirected
if (!initialIsTopNavbar.current && !hasRedirected.current) {
hasRedirected.current = true
navigate('/apps')
// Open popup after navigation
setTimeout(() => {
openMinappKeepAlive(app)
}, 100)
return
}
// For top navbar mode, integrate with cache system
if (initialIsTopNavbar.current) {
// 无论是否已在缓存,都调用以确保 currentMinappId 同步到路由切换的新 appId
openMinappKeepAlive(app)
}
}, [app, navigate, openMinappKeepAlive, initialIsTopNavbar])
// -------------- 新的 Tab Shell 逻辑 --------------
// 注意Hooks 必须在任何 return 之前调用,因此提前定义,并在内部判空
const webviewRef = useRef<WebviewTag | null>(null)
const [isReady, setIsReady] = useState<boolean>(() => (app ? getWebviewLoaded(app.id) : false))
const [currentUrl, setCurrentUrl] = useState<string | null>(app?.url ?? null)
// 获取池中的 webview 元素(避免因为 openedKeepAliveMinapps.length 变化而频繁重跑)
const webviewCleanupRef = useRef<(() => void) | null>(null)
const attachWebview = useCallback(() => {
if (!app) return true // 没有 app 不再继续监控
const selector = `webview[data-minapp-id="${app.id}"]`
const el = document.querySelector(selector) as WebviewTag | null
if (!el) return false
if (webviewRef.current === el) return true // 已附着
webviewRef.current = el
const handleInPageNav = (e: any) => setCurrentUrl(e.url)
el.addEventListener('did-navigate-in-page', handleInPageNav)
webviewCleanupRef.current = () => {
el.removeEventListener('did-navigate-in-page', handleInPageNav)
}
return true
}, [app])
useEffect(() => {
if (!app) return
// 先尝试立即附着
if (attachWebview()) return () => webviewCleanupRef.current?.()
// 若尚未创建,对 DOM 变更做一次监听(轻量 + 自动断开)
const observer = new MutationObserver(() => {
if (attachWebview()) {
observer.disconnect()
}
})
observer.observe(document.body, { childList: true, subtree: true })
return () => {
observer.disconnect()
webviewCleanupRef.current?.()
}
}, [app, attachWebview])
// 事件驱动等待加载完成(移除固定 150ms 轮询)
useEffect(() => {
if (!app) return
if (getWebviewLoaded(app.id)) {
// 已经加载
if (!isReady) setIsReady(true)
return
}
let mounted = true
const unsubscribe = onWebviewStateChange(app.id, (loaded) => {
if (!mounted) return
if (loaded) {
setIsReady(true)
unsubscribe()
}
})
return () => {
mounted = false
unsubscribe()
}
}, [app, isReady])
// 如果条件不满足,提前返回(所有 hooks 已调用)
if (!app || !initialIsTopNavbar.current) {
return null
}
const handleReload = () => {
if (!app) return
if (webviewRef.current) {
setWebviewLoaded(app.id, false)
setIsReady(false)
webviewRef.current.src = app.url
setCurrentUrl(app.url)
}
}
const handleOpenDevTools = () => {
webviewRef.current?.openDevTools()
}
return (
<ShellContainer>
<ToolbarWrapper>
<MinimalToolbar
app={app}
webviewRef={webviewRef}
// currentUrl 可能为 null尚未捕获导航外部打开时会 fallback 到 app.url
currentUrl={currentUrl}
onReload={handleReload}
onOpenDevTools={handleOpenDevTools}
/>
</ToolbarWrapper>
{!isReady && (
<LoadingMask>
<Avatar src={app.logo} size={60} style={{ border: '1px solid var(--color-border)' }} />
<BeatLoader color="var(--color-text-2)" size={8} style={{ marginTop: 12 }} />
</LoadingMask>
)}
</ShellContainer>
)
}
const ShellContainer = styled.div`
position: relative;
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
z-index: 3; /* 高于池中的 webview */
pointer-events: none; /* 让下层 webview 默认可交互 */
> * {
pointer-events: auto;
}
`
const ToolbarWrapper = styled.div`
flex-shrink: 0;
`
const LoadingMask = styled.div`
position: absolute;
inset: 35px 0 0 0; /* 避开 toolbar 高度 */
display: flex;
flex-direction: column; /* 垂直堆叠 */
align-items: center;
justify-content: center;
background: var(--color-background);
z-index: 4;
gap: 12px;
`
export default MinAppPage

View File

@@ -0,0 +1,166 @@
import { loggerService } from '@logger'
import WebviewContainer from '@renderer/components/MinApp/WebviewContainer'
import { useSettings } from '@renderer/hooks/useSettings'
import { MinAppType } from '@renderer/types'
import { getWebviewLoaded, setWebviewLoaded } from '@renderer/utils/webviewStateManager'
import { Avatar } from 'antd'
import { WebviewTag } from 'electron'
import { FC, useCallback, useEffect, useRef, useState } from 'react'
import BeatLoader from 'react-spinners/BeatLoader'
import styled from 'styled-components'
import MinimalToolbar from './MinimalToolbar'
const logger = loggerService.withContext('MinAppFullPageView')
interface Props {
app: MinAppType
}
const MinAppFullPageView: FC<Props> = ({ app }) => {
const webviewRef = useRef<WebviewTag | null>(null)
const [isReady, setIsReady] = useState(false)
const [currentUrl, setCurrentUrl] = useState<string | null>(null)
const { minappsOpenLinkExternal } = useSettings()
// Debug: log isReady state changes
useEffect(() => {
logger.debug(`isReady state changed to: ${isReady}`)
}, [isReady])
// Initialize when app changes - smart loading state detection using global state
useEffect(() => {
setCurrentUrl(app.url)
// Check if this WebView has been loaded before using global state manager
if (getWebviewLoaded(app.id)) {
logger.debug(`App ${app.id} already loaded before, setting ready immediately`)
setIsReady(true)
return // No cleanup needed for immediate ready state
} else {
logger.debug(`App ${app.id} not loaded before, showing loading state`)
setIsReady(false)
// Backup timer logic removed as requested—loading animation will show indefinitely if needed.
// (See version control history for previous implementation.)
}
}, [app])
const handleWebviewSetRef = useCallback((_appId: string, element: WebviewTag | null) => {
webviewRef.current = element
if (element) {
logger.debug('WebView element set')
}
}, [])
const handleWebviewLoaded = useCallback(
(appId: string) => {
logger.debug(`WebView loaded for app: ${appId}`)
const webviewId = webviewRef.current?.getWebContentsId()
if (webviewId) {
window.api.webview.setOpenLinkExternal(webviewId, minappsOpenLinkExternal)
}
// Mark this WebView as loaded for future use in global state
setWebviewLoaded(appId, true)
// Use small delay like MinappPopupContainer (100ms) to ensure content is visible
if (appId === app.id) {
setTimeout(() => {
logger.debug(`WebView loaded callback: setting isReady to true for ${appId}`)
setIsReady(true)
}, 100)
}
},
[minappsOpenLinkExternal, app.id]
)
const handleWebviewNavigate = useCallback((_appId: string, url: string) => {
logger.debug(`URL changed: ${url}`)
setCurrentUrl(url)
}, [])
const handleReload = useCallback(() => {
if (webviewRef.current) {
// Clear the loaded state for this app since we're reloading using global state
setWebviewLoaded(app.id, false)
setIsReady(false) // Set loading state when reloading
webviewRef.current.src = app.url
}
}, [app.url, app.id])
const handleOpenDevTools = useCallback(() => {
if (webviewRef.current) {
webviewRef.current.openDevTools()
}
}, [])
return (
<Container>
<MinimalToolbar
app={app}
webviewRef={webviewRef}
currentUrl={currentUrl}
onReload={handleReload}
onOpenDevTools={handleOpenDevTools}
/>
<WebviewArea>
{!isReady && (
<LoadingMask>
<LoadingOverlay>
<Avatar src={app.logo} size={60} style={{ border: '1px solid var(--color-border)' }} />
<BeatLoader color="var(--color-text-2)" size={8} style={{ marginTop: 12 }} />
</LoadingOverlay>
</LoadingMask>
)}
<WebviewContainer
key={app.id}
appid={app.id}
url={app.url}
onSetRefCallback={handleWebviewSetRef}
onLoadedCallback={handleWebviewLoaded}
onNavigateCallback={handleWebviewNavigate}
/>
</WebviewArea>
</Container>
)
}
const Container = styled.div`
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
`
const WebviewArea = styled.div`
flex: 1;
position: relative;
overflow: hidden;
background-color: var(--color-background);
min-height: 0; /* Ensure flex child can shrink */
`
const LoadingMask = styled.div`
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--color-background);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
`
const LoadingOverlay = styled.div`
display: flex;
flex-direction: column;
align-items: center;
pointer-events: none;
`
export default MinAppFullPageView

View File

@@ -0,0 +1,218 @@
import {
ArrowLeftOutlined,
ArrowRightOutlined,
CodeOutlined,
ExportOutlined,
LinkOutlined,
MinusOutlined,
PushpinOutlined,
ReloadOutlined
} from '@ant-design/icons'
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
import { useMinapps } from '@renderer/hooks/useMinapps'
import { useSettings } from '@renderer/hooks/useSettings'
import { useAppDispatch } from '@renderer/store'
import { setMinappsOpenLinkExternal } from '@renderer/store/settings'
import { MinAppType } from '@renderer/types'
import { Tooltip } from 'antd'
import { WebviewTag } from 'electron'
import { FC, useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import styled from 'styled-components'
interface Props {
app: MinAppType
webviewRef: React.RefObject<WebviewTag | null>
currentUrl: string | null
onReload: () => void
onOpenDevTools: () => void
}
const MinimalToolbar: FC<Props> = ({ app, webviewRef, currentUrl, onReload, onOpenDevTools }) => {
const { t } = useTranslation()
const { pinned, updatePinnedMinapps } = useMinapps()
const { minappsOpenLinkExternal } = useSettings()
const dispatch = useAppDispatch()
const navigate = useNavigate()
const [canGoBack, setCanGoBack] = useState(false)
const [canGoForward, setCanGoForward] = useState(false)
const isInDevelopment = process.env.NODE_ENV === 'development'
const canPinned = DEFAULT_MIN_APPS.some((item) => item.id === app.id)
const isPinned = pinned.some((item) => item.id === app.id)
const canOpenExternalLink = app.url.startsWith('http://') || app.url.startsWith('https://')
// Update navigation state
const updateNavigationState = useCallback(() => {
if (webviewRef.current) {
setCanGoBack(webviewRef.current.canGoBack())
setCanGoForward(webviewRef.current.canGoForward())
}
}, [webviewRef])
const handleGoBack = useCallback(() => {
if (webviewRef.current && webviewRef.current.canGoBack()) {
webviewRef.current.goBack()
updateNavigationState()
}
}, [webviewRef, updateNavigationState])
const handleGoForward = useCallback(() => {
if (webviewRef.current && webviewRef.current.canGoForward()) {
webviewRef.current.goForward()
updateNavigationState()
}
}, [webviewRef, updateNavigationState])
const handleMinimize = useCallback(() => {
navigate('/apps')
}, [navigate])
const handleTogglePin = useCallback(() => {
const newPinned = isPinned ? pinned.filter((item) => item.id !== app.id) : [...pinned, app]
updatePinnedMinapps(newPinned)
}, [app, isPinned, pinned, updatePinnedMinapps])
const handleToggleOpenExternal = useCallback(() => {
dispatch(setMinappsOpenLinkExternal(!minappsOpenLinkExternal))
}, [dispatch, minappsOpenLinkExternal])
const handleOpenLink = useCallback(() => {
const urlToOpen = currentUrl || app.url
window.api.openWebsite(urlToOpen)
}, [currentUrl, app.url])
return (
<ToolbarContainer>
<LeftSection>
<ButtonGroup>
<Tooltip title={t('minapp.popup.goBack')} placement="bottom">
<ToolbarButton onClick={handleGoBack} $disabled={!canGoBack}>
<ArrowLeftOutlined />
</ToolbarButton>
</Tooltip>
<Tooltip title={t('minapp.popup.goForward')} placement="bottom">
<ToolbarButton onClick={handleGoForward} $disabled={!canGoForward}>
<ArrowRightOutlined />
</ToolbarButton>
</Tooltip>
<Tooltip title={t('minapp.popup.refresh')} placement="bottom">
<ToolbarButton onClick={onReload}>
<ReloadOutlined />
</ToolbarButton>
</Tooltip>
</ButtonGroup>
</LeftSection>
<RightSection>
<ButtonGroup>
{canOpenExternalLink && (
<Tooltip title={t('minapp.popup.openExternal')} placement="bottom">
<ToolbarButton onClick={handleOpenLink}>
<ExportOutlined />
</ToolbarButton>
</Tooltip>
)}
{canPinned && (
<Tooltip
title={isPinned ? t('minapp.remove_from_launchpad') : t('minapp.add_to_launchpad')}
placement="bottom">
<ToolbarButton onClick={handleTogglePin} $active={isPinned}>
<PushpinOutlined />
</ToolbarButton>
</Tooltip>
)}
<Tooltip
title={
minappsOpenLinkExternal
? t('minapp.popup.open_link_external_on')
: t('minapp.popup.open_link_external_off')
}
placement="bottom">
<ToolbarButton onClick={handleToggleOpenExternal} $active={minappsOpenLinkExternal}>
<LinkOutlined />
</ToolbarButton>
</Tooltip>
{isInDevelopment && (
<Tooltip title={t('minapp.popup.devtools')} placement="bottom">
<ToolbarButton onClick={onOpenDevTools}>
<CodeOutlined />
</ToolbarButton>
</Tooltip>
)}
<Tooltip title={t('minapp.popup.minimize')} placement="bottom">
<ToolbarButton onClick={handleMinimize}>
<MinusOutlined />
</ToolbarButton>
</Tooltip>
</ButtonGroup>
</RightSection>
</ToolbarContainer>
)
}
const ToolbarContainer = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
height: 35px;
padding: 0 12px;
background-color: var(--color-background);
flex-shrink: 0;
`
const LeftSection = styled.div`
display: flex;
align-items: center;
gap: 8px;
`
const RightSection = styled.div`
display: flex;
align-items: center;
`
const ButtonGroup = styled.div`
display: flex;
align-items: center;
gap: 2px;
`
const ToolbarButton = styled.button<{
$disabled?: boolean
$active?: boolean
}>`
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 4px;
background: ${({ $active }) => ($active ? 'var(--color-primary-bg)' : 'transparent')};
color: ${({ $disabled, $active }) =>
$disabled ? 'var(--color-text-3)' : $active ? 'var(--color-primary)' : 'var(--color-text-2)'};
cursor: ${({ $disabled }) => ($disabled ? 'default' : 'pointer')};
transition: all 0.2s ease;
font-size: 12px;
&:hover {
background: ${({ $disabled, $active }) =>
$disabled ? 'transparent' : $active ? 'var(--color-primary-bg)' : 'var(--color-background-soft)'};
color: ${({ $disabled, $active }) =>
$disabled ? 'var(--color-text-3)' : $active ? 'var(--color-primary)' : 'var(--color-text-1)'};
}
&:active {
transform: ${({ $disabled }) => ($disabled ? 'none' : 'scale(0.95)')};
}
`
export default MinimalToolbar

View File

@@ -563,8 +563,10 @@ const NotesPage: FC = () => {
const handleMoveNode = useCallback(
async (sourceNodeId: string, targetNodeId: string, position: 'before' | 'after' | 'inside') => {
try {
await moveNode(sourceNodeId, targetNodeId, position)
const result = await moveNode(sourceNodeId, targetNodeId, position)
if (result.success && result.type !== 'manual_reorder') {
await sortAllLevels(sortType)
}
} catch (error) {
logger.error('Failed to move nodes:', error as Error)
}

View File

@@ -1,3 +1,4 @@
import { loggerService } from '@logger'
import { nanoid } from '@reduxjs/toolkit'
import CollapsibleSearchBar from '@renderer/components/CollapsibleSearchBar'
import { Sortable, useDndReorder } from '@renderer/components/dnd'
@@ -23,6 +24,8 @@ import McpMarketList from './McpMarketList'
import McpServerCard from './McpServerCard'
import SyncServersPopup from './SyncServersPopup'
const logger = loggerService.withContext('McpServersList')
const McpServersList: FC = () => {
const { mcpServers, addMCPServer, deleteMCPServer, updateMcpServers, updateMCPServer } = useMCPServers()
const { t } = useTranslation()
@@ -158,12 +161,11 @@ const McpServersList: FC = () => {
const handleToggleActive = async (server: MCPServer, active: boolean) => {
setLoadingServerIds((prev) => new Set(prev).add(server.id))
const oldActiveState = server.isActive
logger.silly('toggle activate', { serverId: server.id, active })
try {
if (active) {
await window.api.mcp.listTools(server)
// Fetch version when server is activated
fetchServerVersion({ ...server, isActive: active })
await fetchServerVersion({ ...server, isActive: active })
} else {
await window.api.mcp.stopServer(server)
// Clear version when server is deactivated
@@ -259,7 +261,7 @@ const McpServersList: FC = () => {
server={server}
version={serverVersions[server.id]}
isLoading={loadingServerIds.has(server.id)}
onToggle={(active) => handleToggleActive(server, active)}
onToggle={async (active) => await handleToggleActive(server, active)}
onDelete={() => onDeleteMcpServer(server)}
onEdit={() => navigate(`/settings/mcp/settings/${encodeURIComponent(server.id)}`)}
onOpenUrl={(url) => window.open(url, '_blank')}

View File

@@ -1,5 +1,6 @@
import { loggerService } from '@logger'
import { Center, VStack } from '@renderer/components/Layout'
import { ProviderAvatarPrimitive } from '@renderer/components/ProviderAvatar'
import ProviderLogoPicker from '@renderer/components/ProviderLogoPicker'
import { TopView } from '@renderer/components/TopView'
import { PROVIDER_LOGO_MAP } from '@renderer/config/providers'
@@ -143,7 +144,6 @@ const PopupContainer: React.FC<Props> = ({ provider, resolve }) => {
})
setLogo(tempUrl)
}
setDropdownOpen(false)
} catch (error: any) {
window.message.error(error.message)
@@ -152,7 +152,8 @@ const PopupContainer: React.FC<Props> = ({ provider, resolve }) => {
<MenuItem ref={uploadRef}>{t('settings.general.image_upload')}</MenuItem>
</Upload>
),
onClick: () => {
onClick: (e: any) => {
e.stopPropagation()
uploadRef.current?.click()
}
},
@@ -215,7 +216,9 @@ const PopupContainer: React.FC<Props> = ({ provider, resolve }) => {
}}
placement="bottom">
{logo ? (
<ProviderLogo src={logo} />
<ProviderLogo>
<ProviderAvatarPrimitive providerId={logo} providerName={name} logoSrc={logo} size={60} />
</ProviderLogo>
) : (
<ProviderInitialsLogo style={name ? { backgroundColor, color } : undefined}>
{getInitials()}
@@ -258,16 +261,17 @@ const PopupContainer: React.FC<Props> = ({ provider, resolve }) => {
)
}
const ProviderLogo = styled.img`
const ProviderLogo = styled.div`
cursor: pointer;
width: 60px;
height: 60px;
border-radius: 12px;
object-fit: contain;
border-radius: 100%;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.3s ease;
background-color: var(--color-background-soft);
padding: 5px;
border: 0.5px solid var(--color-border);
&:hover {
opacity: 0.8;
}
@@ -277,7 +281,7 @@ const ProviderInitialsLogo = styled.div`
cursor: pointer;
width: 60px;
height: 60px;
border-radius: 12px;
border-radius: 100%;
display: flex;
align-items: center;
justify-content: center;

View File

@@ -5,22 +5,14 @@ import {
type DraggableVirtualListRef,
useDraggableReorder
} from '@renderer/components/DraggableList'
import { DeleteIcon, EditIcon, PoeLogo } from '@renderer/components/Icons'
import { getProviderLogo } from '@renderer/config/providers'
import { DeleteIcon, EditIcon } from '@renderer/components/Icons'
import { ProviderAvatar } from '@renderer/components/ProviderAvatar'
import { useAllProviders, useProviders } from '@renderer/hooks/useProvider'
import { useTimer } from '@renderer/hooks/useTimer'
import ImageStorage from '@renderer/services/ImageStorage'
import { isSystemProvider, Provider, ProviderType } from '@renderer/types'
import {
generateColorFromChar,
getFancyProviderName,
getFirstCharacter,
getForegroundColor,
matchKeywordsInModel,
matchKeywordsInProvider,
uuid
} from '@renderer/utils'
import { Avatar, Button, Dropdown, Input, MenuProps, Tag } from 'antd'
import { getFancyProviderName, matchKeywordsInModel, matchKeywordsInProvider, uuid } from '@renderer/utils'
import { Button, Dropdown, Input, MenuProps, Tag } from 'antd'
import { GripVertical, PlusIcon, Search, UserPen } from 'lucide-react'
import { FC, startTransition, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -280,36 +272,6 @@ const ProviderList: FC = () => {
}
}
const getProviderAvatar = (provider: Provider, size: number = 25) => {
// 特殊处理一下svg格式
if (isSystemProvider(provider)) {
switch (provider.id) {
case 'poe':
return <PoeLogo fontSize={size} />
}
}
const logoSrc = getProviderLogo(provider.id)
if (logoSrc) {
return <ProviderLogo draggable="false" shape="circle" src={logoSrc} size={size} />
}
const customLogo = providerLogos[provider.id]
if (customLogo) {
return <ProviderLogo draggable="false" shape="square" src={customLogo} size={size} />
}
// generate color for custom provider
const backgroundColor = generateColorFromChar(provider.name)
const color = provider.name ? getForegroundColor(backgroundColor) : 'white'
return (
<ProviderLogo size={size} shape="square" style={{ backgroundColor, color, minWidth: size }}>
{getFirstCharacter(provider.name)}
</ProviderLogo>
)
}
const filteredProviders = providers.filter((provider) => {
const keywords = searchText.toLowerCase().split(/\s+/).filter(Boolean)
const isProviderMatch = matchKeywordsInProvider(keywords, provider)
@@ -382,7 +344,14 @@ const ProviderList: FC = () => {
<DragHandle>
<GripVertical size={12} />
</DragHandle>
{getProviderAvatar(provider)}
<ProviderAvatar
style={{
width: 24,
height: 24
}}
provider={provider}
customLogos={providerLogos}
/>
<ProviderItemName className="text-nowrap">{getFancyProviderName(provider)}</ProviderItemName>
{provider.enabled && (
<Tag color="green" style={{ marginLeft: 'auto', marginRight: 0, borderRadius: 16 }}>
@@ -466,10 +435,6 @@ const DragHandle = styled.div`
}
`
const ProviderLogo = styled(Avatar)`
border: 0.5px solid var(--color-border);
`
const ProviderItemName = styled.div`
margin-left: 10px;
font-weight: 500;

View File

@@ -612,9 +612,14 @@ const TranslatePage: FC = () => {
// 粘贴上传文件
const onPaste = useCallback(
async (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
event.preventDefault()
if (isProcessing) return
setIsProcessing(true)
logger.debug('event', event)
if (event.clipboardData?.files && event.clipboardData.files.length > 0) {
const text = event.clipboardData.getData('text')
if (!isEmpty(text)) {
setText(text)
} else if (event.clipboardData.files && event.clipboardData.files.length > 0) {
event.preventDefault()
const files = event.clipboardData.files
const file = getSingleFile(files) as File
@@ -659,7 +664,7 @@ const TranslatePage: FC = () => {
}
setIsProcessing(false)
},
[getSingleFile, processFile, t]
[getSingleFile, isProcessing, processFile, t]
)
return (
<Container

View File

@@ -19,6 +19,8 @@ const NOTES_TREE_ID = 'notes-tree-structure'
const logger = loggerService.withContext('NotesService')
export type MoveNodeResult = { success: false } | { success: true; type: 'file_system_move' | 'manual_reorder' }
/**
* 初始化/同步笔记树结构
*/
@@ -182,7 +184,7 @@ export async function moveNode(
sourceNodeId: string,
targetNodeId: string,
position: 'before' | 'after' | 'inside'
): Promise<boolean> {
): Promise<MoveNodeResult> {
try {
const tree = await getNotesTree()
@@ -192,19 +194,19 @@ export async function moveNode(
if (!sourceNode || !targetNode) {
logger.error(`Move nodes failed: node not found (source: ${sourceNodeId}, target: ${targetNodeId})`)
return false
return { success: false }
}
// 不允许文件夹被放入文件中
if (position === 'inside' && targetNode.type === 'file' && sourceNode.type === 'folder') {
logger.error('Move nodes failed: cannot move a folder inside a file')
return false
return { success: false }
}
// 不允许将节点移动到自身内部
if (position === 'inside' && isParentNode(tree, sourceNodeId, targetNodeId)) {
logger.error('Move nodes failed: cannot move a node inside itself or its descendants')
return false
return { success: false }
}
let targetPath: string = ''
@@ -215,7 +217,7 @@ export async function moveNode(
targetPath = targetNode.externalPath
} else {
logger.error('Cannot move node inside a file node')
return false
return { success: false }
}
} else {
const targetParent = findParentNode(tree, targetNodeId)
@@ -226,6 +228,20 @@ export async function moveNode(
}
}
// 检查是否为同级拖动排序
const sourceParent = findParentNode(tree, sourceNodeId)
const sourceDir = sourceParent ? sourceParent.externalPath : getFileDirectory(sourceNode.externalPath!)
const isSameLevelReorder = position !== 'inside' && sourceDir === targetPath
if (isSameLevelReorder) {
// 同级拖动排序:跳过文件系统操作,只更新树结构
logger.debug(`Same level reorder detected, skipping file system operations`)
const success = await moveNodeInTree(tree, sourceNodeId, targetNodeId, position)
// 返回一个特殊标识,告诉调用方这是手动排序,不需要重新自动排序
return success ? { success: true, type: 'manual_reorder' } : { success: false }
}
// 构建新的文件路径
const sourceName = sourceNode.externalPath!.split('/').pop()!
const sourceNameWithoutExt = sourceName.replace(sourceNode.type === 'file' ? MARKDOWN_EXT : '', '')
@@ -250,14 +266,15 @@ export async function moveNode(
logger.debug(`Moved external ${sourceNode.type} to: ${newPath}`)
} catch (error) {
logger.error(`Failed to move external ${sourceNode.type}:`, error as Error)
return false
return { success: false }
}
}
return await moveNodeInTree(tree, sourceNodeId, targetNodeId, position)
const success = await moveNodeInTree(tree, sourceNodeId, targetNodeId, position)
return success ? { success: true, type: 'file_system_move' } : { success: false }
} catch (error) {
logger.error('Move nodes failed:', error as Error)
return false
return { success: false }
}
}

View File

@@ -89,8 +89,9 @@ export async function moveNodeInTree(
return false
}
// 先保存源节点的副本,以防操作失败需要恢复(暂未实现恢复逻辑)
// const sourceNodeCopy = { ...sourceNode }
// 在移除节点之前先获取源节点的父节点信息,用于后续判断是否为同级排序
const sourceParent = findParentNode(tree, sourceNodeId)
const targetParent = findParentNode(tree, targetNodeId)
// 从原位置移除节点(不保存数据库,只在内存中操作)
const removed = removeNodeFromTreeInMemory(tree, sourceNodeId)
@@ -110,7 +111,6 @@ export async function moveNodeInTree(
sourceNode.treePath = `${targetNode.treePath}/${sourceNode.name}`
} else {
const targetParent = findParentNode(tree, targetNodeId)
const targetList = targetParent ? targetParent.children! : tree
const targetIndex = targetList.findIndex((node) => node.id === targetNodeId)
@@ -123,13 +123,18 @@ export async function moveNodeInTree(
const insertIndex = position === 'before' ? targetIndex : targetIndex + 1
targetList.splice(insertIndex, 0, sourceNode)
// 更新节点路径
// 检查是否为同级排序,如果是则保持原有的 treePath
const isSameLevelReorder = sourceParent === targetParent
// 只有在跨级移动时才更新节点路径
if (!isSameLevelReorder) {
if (targetParent) {
sourceNode.treePath = `${targetParent.treePath}/${sourceNode.name}`
} else {
sourceNode.treePath = `/${sourceNode.name}`
}
}
}
// 更新修改时间
sourceNode.updatedAt = new Date().toISOString()

View File

@@ -1,12 +1,29 @@
import { loggerService } from '@logger'
import store from '@renderer/store'
import { removeTab, setActiveTab } from '@renderer/store/tabs'
import { MinAppType } from '@renderer/types'
import { clearWebviewState } from '@renderer/utils/webviewStateManager'
import { LRUCache } from 'lru-cache'
import NavigationService from './NavigationService'
const logger = loggerService.withContext('TabsService')
class TabsService {
private minAppsCache: LRUCache<string, MinAppType> | null = null
/**
* Sets the reference to the mini-apps LRU cache used for managing mini-app lifecycle and cleanup.
* This method is required to integrate TabsService with the mini-apps cache system, allowing TabsService
* to perform cache cleanup when tabs associated with mini-apps are closed. The cache instance is typically
* provided by the mini-app popup system and enables TabsService to maintain cache consistency and prevent
* stale data.
* @param cache The LRUCache instance containing mini-app data, provided by useMinappPopup.
*/
public setMinAppsCache(cache: LRUCache<string, MinAppType>) {
this.minAppsCache = cache
logger.debug('Mini-apps cache reference set in TabsService')
}
/**
* 关闭指定的标签页
* @param tabId 要关闭的标签页ID
@@ -49,6 +66,9 @@ class TabsService {
}
}
// Clean up mini-app cache if this is a mini-app tab
this.cleanupMinAppCache(tabId)
// 使用 Redux action 移除标签页
store.dispatch(removeTab(tabId))
@@ -56,6 +76,32 @@ class TabsService {
return true
}
/**
* Clean up mini-app cache and WebView state when tab is closed
* @param tabId The tab ID to clean up
*/
private cleanupMinAppCache(tabId: string) {
// Check if this is a mini-app tab (format: /apps/{appId})
const tabs = store.getState().tabs.tabs
const tab = tabs.find((t) => t.id === tabId)
if (tab && tab.path.startsWith('/apps/')) {
const appId = tab.path.replace('/apps/', '')
if (this.minAppsCache && this.minAppsCache.has(appId)) {
logger.debug(`Cleaning up mini-app cache for app: ${appId}`)
// Remove from LRU cache - this will trigger disposeAfter callback
this.minAppsCache.delete(appId)
// Clear WebView state
clearWebviewState(appId)
logger.info(`Mini-app ${appId} removed from cache due to tab closure`)
}
}
}
/**
* 获取所有标签页
*/

View File

@@ -0,0 +1,120 @@
import { loggerService } from '@logger'
const logger = loggerService.withContext('WebviewStateManager')
// Global WebView loaded states - shared between popup and tab modes
const globalWebviewStates = new Map<string, boolean>()
// Per-app listeners (fine grained)
type WebviewStateListener = (loaded: boolean) => void
const appListeners = new Map<string, Set<WebviewStateListener>>()
const emitState = (appId: string, loaded: boolean) => {
const listeners = appListeners.get(appId)
if (listeners && listeners.size) {
listeners.forEach((cb) => {
try {
cb(loaded)
} catch (e) {
// Swallow listener errors to avoid breaking others
logger.debug(`Listener error for ${appId}: ${(e as Error).message}`)
}
})
}
}
/**
* Set WebView loaded state for a specific app
* @param appId - The mini-app ID
* @param loaded - Whether the WebView is loaded
*/
export const setWebviewLoaded = (appId: string, loaded: boolean) => {
globalWebviewStates.set(appId, loaded)
logger.debug(`WebView state set for ${appId}: ${loaded}`)
emitState(appId, loaded)
}
/**
* Get WebView loaded state for a specific app
* @param appId - The mini-app ID
* @returns Whether the WebView is loaded
*/
export const getWebviewLoaded = (appId: string): boolean => {
return globalWebviewStates.get(appId) || false
}
/**
* Clear WebView state for a specific app
* @param appId - The mini-app ID
*/
export const clearWebviewState = (appId: string) => {
const wasLoaded = globalWebviewStates.delete(appId)
if (wasLoaded) {
logger.debug(`WebView state cleared for ${appId}`)
}
// 清掉监听(避免潜在内存泄漏)
appListeners.delete(appId)
}
/**
* Clear all WebView states
*/
export const clearAllWebviewStates = () => {
const count = globalWebviewStates.size
globalWebviewStates.clear()
logger.debug(`Cleared all WebView states (${count} apps)`)
appListeners.clear()
}
/**
* Get all loaded app IDs
* @returns Array of app IDs that have loaded WebViews
*/
export const getLoadedAppIds = (): string[] => {
return Array.from(globalWebviewStates.entries())
.filter(([, loaded]) => loaded)
.map(([appId]) => appId)
}
/**
* Subscribe to a specific app's webview loaded state changes.
* Returns an unsubscribe function.
*/
export const onWebviewStateChange = (appId: string, listener: WebviewStateListener): (() => void) => {
let listeners = appListeners.get(appId)
if (!listeners) {
listeners = new Set<WebviewStateListener>()
appListeners.set(appId, listeners)
}
listeners.add(listener)
return () => {
listeners!.delete(listener)
if (listeners!.size === 0) appListeners.delete(appId)
}
}
/**
* Promise helper: wait until the webview becomes loaded.
* Optional timeout (ms) to avoid hanging forever; resolves false on timeout.
*/
export const waitForWebviewLoaded = (appId: string, timeout = 15000): Promise<boolean> => {
if (getWebviewLoaded(appId)) return Promise.resolve(true)
return new Promise((resolve) => {
let done = false
const unsubscribe = onWebviewStateChange(appId, (loaded) => {
if (!loaded) return
if (done) return
done = true
unsubscribe()
resolve(true)
})
if (timeout > 0) {
setTimeout(() => {
if (done) return
done = true
unsubscribe()
resolve(false)
}, timeout)
}
})
}